diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 8af4046a7..8e672043d 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -63,10 +63,15 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOMODCACHE=${{ env.cache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - - run: echo "files=$(go list ./... | ForEach-Object { $_ } | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' })" >> $env:GITHUB_ENV + - name: Generate test script + run: | + $packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } + $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" + $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" + Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd - name: test - run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe test -tags=devcert -timeout 10m -p 1 ${{ env.files }} > test-out.txt 2>&1" + run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "${{ github.workspace }}\run-tests.cmd" - name: test output if: ${{ always() }} run: Get-Content test-out.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ac5103d9..83444b541 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,6 +170,7 @@ jobs: run: sudo apt update && sudo apt install -y -q gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu - name: Decode GPG signing key + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }} run: | @@ -211,18 +212,36 @@ jobs: - name: Clean up GPG key if: always() run: rm -f /tmp/gpg-rpm-signing-key.asc - - name: Tag and push PR images (amd64 only) - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + - name: Tag and push images (amd64 only) + if: | + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/main') run: | - PR_TAG="pr-${{ github.event.pull_request.number }}" + resolve_tags() { + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "pr-${{ github.event.pull_request.number }}" + else + echo "main sha-$(git rev-parse --short HEAD)" + fi + } + + tag_and_push() { + local src="$1" img_name tag dst + img_name="${src%%:*}" + for tag in $(resolve_tags); do + dst="${img_name}:${tag}" + echo "Tagging ${src} -> ${dst}" + docker tag "$src" "$dst" + docker push "$dst" + done + } + + export -f tag_and_push resolve_tags + echo '${{ steps.goreleaser.outputs.artifacts }}' | \ jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name' | \ grep '^ghcr.io/' | while read -r SRC; do - IMG_NAME="${SRC%%:*}" - DST="${IMG_NAME}:${PR_TAG}" - echo "Tagging ${SRC} -> ${DST}" - docker tag "$SRC" "$DST" - docker push "$DST" + tag_and_push "$SRC" done - name: upload non tags for debug purposes uses: actions/upload-artifact@v4 @@ -291,6 +310,7 @@ jobs: run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64 - name: Decode GPG signing key + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }} run: | diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index 47e45165b..81ae36e78 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -61,8 +61,8 @@ jobs: echo "Size: ${SIZE} bytes (${SIZE_MB} MB)" - if [ ${SIZE} -gt 57671680 ]; then - echo "Wasm binary size (${SIZE_MB}MB) exceeds 55MB limit!" + if [ ${SIZE} -gt 58720256 ]; then + echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!" exit 1 fi diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0f81229cd..65e63dfa8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -171,6 +171,7 @@ nfpms: - maintainer: Netbird description: Netbird client. homepage: https://netbird.io/ + license: BSD-3-Clause id: netbird_deb bindir: /usr/bin builds: @@ -184,6 +185,7 @@ nfpms: - maintainer: Netbird description: Netbird client. homepage: https://netbird.io/ + license: BSD-3-Clause id: netbird_rpm bindir: /usr/bin builds: diff --git a/CONTRIBUTOR_LICENSE_AGREEMENT.md b/CONTRIBUTOR_LICENSE_AGREEMENT.md index 1fdd072c9..b0a6ee218 100644 --- a/CONTRIBUTOR_LICENSE_AGREEMENT.md +++ b/CONTRIBUTOR_LICENSE_AGREEMENT.md @@ -1,7 +1,7 @@ ## Contributor License Agreement This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual -submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany, +submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany, referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions under which NetBird may utilize software contributions provided by the Contributor for inclusion in its software development projects. By submitting this Agreement, the Contributor confirms their acceptance diff --git a/README.md b/README.md index bca81c20b..dc84af2fd 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ See a complete [architecture overview](https://docs.netbird.io/about-netbird/how ### Community projects - [NetBird installer script](https://github.com/physk/netbird-installer) - [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/) +- [netbird-tui](https://github.com/n0pashkov/netbird-tui) — terminal UI for managing NetBird peers, routes, and settings **Note**: The `main` branch may be in an *unstable or even broken state* during development. For stable versions, see [releases](https://github.com/netbirdio/netbird/releases). diff --git a/client/Dockerfile b/client/Dockerfile index 13e44096f..64d5ba04f 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -17,8 +17,7 @@ ENV \ NETBIRD_BIN="/usr/local/bin/netbird" \ NB_LOG_FILE="console,/var/log/netbird/client.log" \ NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \ - NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \ - NB_ENTRYPOINT_LOGIN_TIMEOUT="5" + NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless index 5fa8de0a5..69d00aaf2 100644 --- a/client/Dockerfile-rootless +++ b/client/Dockerfile-rootless @@ -23,8 +23,7 @@ ENV \ NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \ NB_LOG_FILE="console,/var/lib/netbird/client.log" \ NB_DISABLE_DNS="true" \ - NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \ - NB_ENTRYPOINT_LOGIN_TIMEOUT="1" + NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] diff --git a/client/android/client.go b/client/android/client.go index 3fc571559..d35bf4279 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -205,7 +205,7 @@ func (c *Client) PeersList() *PeerInfoArray { pi := PeerInfo{ p.IP, p.FQDN, - p.ConnStatus.String(), + int(p.ConnStatus), PeerRoutes{routes: maps.Keys(p.GetRoutes())}, } peerInfos[n] = pi diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go index b03947da1..4ec22f3ab 100644 --- a/client/android/peer_notifier.go +++ b/client/android/peer_notifier.go @@ -2,11 +2,20 @@ package android +import "github.com/netbirdio/netbird/client/internal/peer" + +// Connection status constants exported via gomobile. +const ( + ConnStatusIdle = int(peer.StatusIdle) + ConnStatusConnecting = int(peer.StatusConnecting) + ConnStatusConnected = int(peer.StatusConnected) +) + // PeerInfo describe information about the peers. It designed for the UI usage type PeerInfo struct { IP string FQDN string - ConnStatus string // Todo replace to enum + ConnStatus int Routes PeerRoutes } diff --git a/client/cmd/debug.go b/client/cmd/debug.go index e480df4d7..0e2717756 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -181,10 +181,11 @@ func runForDuration(cmd *cobra.Command, args []string) error { if stateWasDown { if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil { - return fmt.Errorf("failed to up: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message()) + } else { + cmd.Println("netbird up") + time.Sleep(time.Second * 10) } - cmd.Println("netbird up") - time.Sleep(time.Second * 10) } initialLevelTrace := initialLogLevel.GetLevel() >= proto.LogLevel_TRACE @@ -199,9 +200,10 @@ func runForDuration(cmd *cobra.Command, args []string) error { } if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil { - return fmt.Errorf("failed to down: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message()) + } else { + cmd.Println("netbird down") } - cmd.Println("netbird down") time.Sleep(1 * time.Second) @@ -209,13 +211,14 @@ func runForDuration(cmd *cobra.Command, args []string) error { if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{ Enabled: true, }); err != nil { - return fmt.Errorf("failed to enable sync response persistence: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message()) } if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil { - return fmt.Errorf("failed to up: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message()) + } else { + cmd.Println("netbird up") } - cmd.Println("netbird up") time.Sleep(3 * time.Second) @@ -263,16 +266,18 @@ func runForDuration(cmd *cobra.Command, args []string) error { if stateWasDown { if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil { - return fmt.Errorf("failed to down: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to restore service down state: %v\n", status.Convert(err).Message()) + } else { + cmd.Println("netbird down") } - cmd.Println("netbird down") } if !initialLevelTrace { if _, err := client.SetLogLevel(cmd.Context(), &proto.SetLogLevelRequest{Level: initialLogLevel.GetLevel()}); err != nil { - return fmt.Errorf("failed to restore log level: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to restore log level: %v\n", status.Convert(err).Message()) + } else { + cmd.Println("Log level restored to", initialLogLevel.GetLevel()) } - cmd.Println("Log level restored to", initialLogLevel.GetLevel()) } cmd.Printf("Local file:\n%s\n", resp.GetPath()) diff --git a/client/cmd/expose.go b/client/cmd/expose.go index 1334617d8..f4727703e 100644 --- a/client/cmd/expose.go +++ b/client/cmd/expose.go @@ -15,6 +15,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "github.com/netbirdio/netbird/client/internal/expose" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/util" ) @@ -211,19 +212,24 @@ func exposeFn(cmd *cobra.Command, args []string) error { } func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) { - switch strings.ToLower(exposeProtocol) { - case "http": + p, err := expose.ParseProtocolType(exposeProtocol) + if err != nil { + return 0, fmt.Errorf("invalid protocol: %w", err) + } + + switch p { + case expose.ProtocolHTTP: return proto.ExposeProtocol_EXPOSE_HTTP, nil - case "https": + case expose.ProtocolHTTPS: return proto.ExposeProtocol_EXPOSE_HTTPS, nil - case "tcp": + case expose.ProtocolTCP: return proto.ExposeProtocol_EXPOSE_TCP, nil - case "udp": + case expose.ProtocolUDP: return proto.ExposeProtocol_EXPOSE_UDP, nil - case "tls": + case expose.ProtocolTLS: return proto.ExposeProtocol_EXPOSE_TLS, nil default: - return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol) + return 0, fmt.Errorf("unhandled protocol type: %d", p) } } diff --git a/client/cmd/service.go b/client/cmd/service.go index e55465875..5ff16eaeb 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -41,7 +41,7 @@ func init() { defaultServiceName = "Netbird" } - serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd) + serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd) serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles") serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings") diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 0545ce6b7..5fe318ddf 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -103,7 +103,7 @@ func (p *program) Stop(srv service.Service) error { // Common setup for service control commands func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) { - SetFlagsFromEnvVars(rootCmd) + // rootCmd env vars are already applied by PersistentPreRunE. SetFlagsFromEnvVars(serviceCmd) cmd.SetOut(cmd.OutOrStdout()) diff --git a/client/cmd/service_installer.go b/client/cmd/service_installer.go index f6828d96a..28770ea16 100644 --- a/client/cmd/service_installer.go +++ b/client/cmd/service_installer.go @@ -119,6 +119,10 @@ var installCmd = &cobra.Command{ return err } + if err := loadAndApplyServiceParams(cmd); err != nil { + cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err) + } + svcConfig, err := createServiceConfigForInstall() if err != nil { return err @@ -136,6 +140,10 @@ var installCmd = &cobra.Command{ return fmt.Errorf("install service: %w", err) } + if err := saveServiceParams(currentServiceParams()); err != nil { + cmd.PrintErrf("Warning: failed to save service params: %v\n", err) + } + cmd.Println("NetBird service has been installed") return nil }, @@ -187,6 +195,10 @@ This command will temporarily stop the service, update its configuration, and re return err } + if err := loadAndApplyServiceParams(cmd); err != nil { + cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err) + } + wasRunning, err := isServiceRunning() if err != nil && !errors.Is(err, ErrGetServiceStatus) { return fmt.Errorf("check service status: %w", err) @@ -222,6 +234,10 @@ This command will temporarily stop the service, update its configuration, and re return fmt.Errorf("install service with new config: %w", err) } + if err := saveServiceParams(currentServiceParams()); err != nil { + cmd.PrintErrf("Warning: failed to save service params: %v\n", err) + } + if wasRunning { cmd.Println("Starting NetBird service...") if err := s.Start(); err != nil { diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go new file mode 100644 index 000000000..81bd2dbb5 --- /dev/null +++ b/client/cmd/service_params.go @@ -0,0 +1,201 @@ +//go:build !ios && !android + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/util" +) + +const serviceParamsFile = "service.json" + +// serviceParams holds install-time service parameters that persist across +// uninstall/reinstall cycles. Saved to /service.json. +type serviceParams struct { + LogLevel string `json:"log_level"` + DaemonAddr string `json:"daemon_addr"` + ManagementURL string `json:"management_url,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + LogFiles []string `json:"log_files,omitempty"` + DisableProfiles bool `json:"disable_profiles,omitempty"` + DisableUpdateSettings bool `json:"disable_update_settings,omitempty"` + ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"` +} + +// serviceParamsPath returns the path to the service params file. +func serviceParamsPath() string { + return filepath.Join(configs.StateDir, serviceParamsFile) +} + +// loadServiceParams reads saved service parameters from disk. +// Returns nil with no error if the file does not exist. +func loadServiceParams() (*serviceParams, error) { + path := serviceParamsPath() + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil //nolint:nilnil + } + return nil, fmt.Errorf("read service params %s: %w", path, err) + } + + var params serviceParams + if err := json.Unmarshal(data, ¶ms); err != nil { + return nil, fmt.Errorf("parse service params %s: %w", path, err) + } + + return ¶ms, nil +} + +// saveServiceParams writes current service parameters to disk atomically +// with restricted permissions. +func saveServiceParams(params *serviceParams) error { + path := serviceParamsPath() + if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, params); err != nil { + return fmt.Errorf("save service params: %w", err) + } + return nil +} + +// currentServiceParams captures the current state of all package-level +// variables into a serviceParams struct. +func currentServiceParams() *serviceParams { + params := &serviceParams{ + LogLevel: logLevel, + DaemonAddr: daemonAddr, + ManagementURL: managementURL, + ConfigPath: configPath, + LogFiles: logFiles, + DisableProfiles: profilesDisabled, + DisableUpdateSettings: updateSettingsDisabled, + } + + if len(serviceEnvVars) > 0 { + parsed, err := parseServiceEnvVars(serviceEnvVars) + if err == nil && len(parsed) > 0 { + params.ServiceEnvVars = parsed + } + } + + return params +} + +// loadAndApplyServiceParams loads saved params from disk and applies them +// to any flags that were not explicitly set. +func loadAndApplyServiceParams(cmd *cobra.Command) error { + params, err := loadServiceParams() + if err != nil { + return err + } + applyServiceParams(cmd, params) + return nil +} + +// applyServiceParams merges saved parameters into package-level variables +// for any flag that was not explicitly set by the user (via CLI or env var). +// Flags that were Changed() are left untouched. +func applyServiceParams(cmd *cobra.Command, params *serviceParams) { + if params == nil { + return + } + + // For fields with non-empty defaults (log-level, daemon-addr), keep the + // != "" guard so that an older service.json missing the field doesn't + // clobber the default with an empty string. + if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" { + logLevel = params.LogLevel + } + + if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" { + daemonAddr = params.DaemonAddr + } + + // For optional fields where empty means "use default", always apply so + // that an explicit clear (--management-url "") persists across reinstalls. + if !rootCmd.PersistentFlags().Changed("management-url") { + managementURL = params.ManagementURL + } + + if !rootCmd.PersistentFlags().Changed("config") { + configPath = params.ConfigPath + } + + if !rootCmd.PersistentFlags().Changed("log-file") { + logFiles = params.LogFiles + } + + if !serviceCmd.PersistentFlags().Changed("disable-profiles") { + profilesDisabled = params.DisableProfiles + } + + if !serviceCmd.PersistentFlags().Changed("disable-update-settings") { + updateSettingsDisabled = params.DisableUpdateSettings + } + + applyServiceEnvParams(cmd, params) +} + +// applyServiceEnvParams merges saved service environment variables. +// If --service-env was explicitly set, explicit values win on key conflict +// but saved keys not in the explicit set are carried over. +// If --service-env was not set, saved env vars are used entirely. +func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) { + if len(params.ServiceEnvVars) == 0 { + return + } + + if !cmd.Flags().Changed("service-env") { + // No explicit env vars: rebuild serviceEnvVars from saved params. + serviceEnvVars = envMapToSlice(params.ServiceEnvVars) + return + } + + // Explicit env vars were provided: merge saved values underneath. + explicit, err := parseServiceEnvVars(serviceEnvVars) + if err != nil { + cmd.PrintErrf("Warning: parse explicit service env vars for merge: %v\n", err) + return + } + + merged := make(map[string]string, len(params.ServiceEnvVars)+len(explicit)) + maps.Copy(merged, params.ServiceEnvVars) + maps.Copy(merged, explicit) // explicit wins on conflict + serviceEnvVars = envMapToSlice(merged) +} + +var resetParamsCmd = &cobra.Command{ + Use: "reset-params", + Short: "Remove saved service install parameters", + Long: "Removes the saved service.json file so the next install uses default parameters.", + RunE: func(cmd *cobra.Command, args []string) error { + path := serviceParamsPath() + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + cmd.Println("No saved service parameters found") + return nil + } + return fmt.Errorf("remove service params: %w", err) + } + cmd.Printf("Removed saved service parameters (%s)\n", path) + return nil + }, +} + +// envMapToSlice converts a map of env vars to a KEY=VALUE slice. +func envMapToSlice(m map[string]string) []string { + s := make([]string, 0, len(m)) + for k, v := range m { + s = append(s, k+"="+v) + } + return s +} diff --git a/client/cmd/service_params_test.go b/client/cmd/service_params_test.go new file mode 100644 index 000000000..684593a00 --- /dev/null +++ b/client/cmd/service_params_test.go @@ -0,0 +1,523 @@ +//go:build !ios && !android + +package cmd + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/configs" +) + +func TestServiceParamsPath(t *testing.T) { + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + + configs.StateDir = "/var/lib/netbird" + assert.Equal(t, "/var/lib/netbird/service.json", serviceParamsPath()) + + configs.StateDir = "/custom/state" + assert.Equal(t, "/custom/state/service.json", serviceParamsPath()) +} + +func TestSaveAndLoadServiceParams(t *testing.T) { + tmpDir := t.TempDir() + + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + configs.StateDir = tmpDir + + params := &serviceParams{ + LogLevel: "debug", + DaemonAddr: "unix:///var/run/netbird.sock", + ManagementURL: "https://my.server.com", + ConfigPath: "/etc/netbird/config.json", + LogFiles: []string{"/var/log/netbird/client.log", "console"}, + DisableProfiles: true, + DisableUpdateSettings: false, + ServiceEnvVars: map[string]string{"NB_LOG_FORMAT": "json", "CUSTOM": "val"}, + } + + err := saveServiceParams(params) + require.NoError(t, err) + + // Verify the file exists and is valid JSON. + data, err := os.ReadFile(filepath.Join(tmpDir, "service.json")) + require.NoError(t, err) + assert.True(t, json.Valid(data)) + + loaded, err := loadServiceParams() + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, params.LogLevel, loaded.LogLevel) + assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr) + assert.Equal(t, params.ManagementURL, loaded.ManagementURL) + assert.Equal(t, params.ConfigPath, loaded.ConfigPath) + assert.Equal(t, params.LogFiles, loaded.LogFiles) + assert.Equal(t, params.DisableProfiles, loaded.DisableProfiles) + assert.Equal(t, params.DisableUpdateSettings, loaded.DisableUpdateSettings) + assert.Equal(t, params.ServiceEnvVars, loaded.ServiceEnvVars) +} + +func TestLoadServiceParams_FileNotExists(t *testing.T) { + tmpDir := t.TempDir() + + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + configs.StateDir = tmpDir + + params, err := loadServiceParams() + assert.NoError(t, err) + assert.Nil(t, params) +} + +func TestLoadServiceParams_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + configs.StateDir = tmpDir + + err := os.WriteFile(filepath.Join(tmpDir, "service.json"), []byte("not json"), 0600) + require.NoError(t, err) + + params, err := loadServiceParams() + assert.Error(t, err) + assert.Nil(t, params) +} + +func TestCurrentServiceParams(t *testing.T) { + origLogLevel := logLevel + origDaemonAddr := daemonAddr + origManagementURL := managementURL + origConfigPath := configPath + origLogFiles := logFiles + origProfilesDisabled := profilesDisabled + origUpdateSettingsDisabled := updateSettingsDisabled + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { + logLevel = origLogLevel + daemonAddr = origDaemonAddr + managementURL = origManagementURL + configPath = origConfigPath + logFiles = origLogFiles + profilesDisabled = origProfilesDisabled + updateSettingsDisabled = origUpdateSettingsDisabled + serviceEnvVars = origServiceEnvVars + }) + + logLevel = "trace" + daemonAddr = "tcp://127.0.0.1:9999" + managementURL = "https://mgmt.example.com" + configPath = "/tmp/test-config.json" + logFiles = []string{"/tmp/test.log"} + profilesDisabled = true + updateSettingsDisabled = true + serviceEnvVars = []string{"FOO=bar", "BAZ=qux"} + + params := currentServiceParams() + + assert.Equal(t, "trace", params.LogLevel) + assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr) + assert.Equal(t, "https://mgmt.example.com", params.ManagementURL) + assert.Equal(t, "/tmp/test-config.json", params.ConfigPath) + assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles) + assert.True(t, params.DisableProfiles) + assert.True(t, params.DisableUpdateSettings) + assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, params.ServiceEnvVars) +} + +func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { + origLogLevel := logLevel + origDaemonAddr := daemonAddr + origManagementURL := managementURL + origConfigPath := configPath + origLogFiles := logFiles + origProfilesDisabled := profilesDisabled + origUpdateSettingsDisabled := updateSettingsDisabled + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { + logLevel = origLogLevel + daemonAddr = origDaemonAddr + managementURL = origManagementURL + configPath = origConfigPath + logFiles = origLogFiles + profilesDisabled = origProfilesDisabled + updateSettingsDisabled = origUpdateSettingsDisabled + serviceEnvVars = origServiceEnvVars + }) + + // Reset all flags to defaults. + logLevel = "info" + daemonAddr = "unix:///var/run/netbird.sock" + managementURL = "" + configPath = "/etc/netbird/config.json" + logFiles = []string{"/var/log/netbird/client.log"} + profilesDisabled = false + updateSettingsDisabled = false + serviceEnvVars = nil + + // Reset Changed state on all relevant flags. + rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + f.Changed = false + }) + serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + f.Changed = false + }) + + // Simulate user explicitly setting --log-level via CLI. + logLevel = "warn" + require.NoError(t, rootCmd.PersistentFlags().Set("log-level", "warn")) + + saved := &serviceParams{ + LogLevel: "debug", + DaemonAddr: "tcp://127.0.0.1:5555", + ManagementURL: "https://saved.example.com", + ConfigPath: "/saved/config.json", + LogFiles: []string{"/saved/client.log"}, + DisableProfiles: true, + DisableUpdateSettings: true, + ServiceEnvVars: map[string]string{"SAVED_KEY": "saved_val"}, + } + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + applyServiceParams(cmd, saved) + + // log-level was Changed, so it should keep "warn", not use saved "debug". + assert.Equal(t, "warn", logLevel) + + // All other fields were not Changed, so they should use saved values. + assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr) + assert.Equal(t, "https://saved.example.com", managementURL) + assert.Equal(t, "/saved/config.json", configPath) + assert.Equal(t, []string{"/saved/client.log"}, logFiles) + assert.True(t, profilesDisabled) + assert.True(t, updateSettingsDisabled) + assert.Equal(t, []string{"SAVED_KEY=saved_val"}, serviceEnvVars) +} + +func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) { + origProfilesDisabled := profilesDisabled + origUpdateSettingsDisabled := updateSettingsDisabled + t.Cleanup(func() { + profilesDisabled = origProfilesDisabled + updateSettingsDisabled = origUpdateSettingsDisabled + }) + + // Simulate current state where booleans are true (e.g. set by previous install). + profilesDisabled = true + updateSettingsDisabled = true + + // Reset Changed state so flags appear unset. + serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + f.Changed = false + }) + + // Saved params have both as false. + saved := &serviceParams{ + DisableProfiles: false, + DisableUpdateSettings: false, + } + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + applyServiceParams(cmd, saved) + + assert.False(t, profilesDisabled, "saved false should override current true") + assert.False(t, updateSettingsDisabled, "saved false should override current true") +} + +func TestApplyServiceParams_ClearManagementURL(t *testing.T) { + origManagementURL := managementURL + t.Cleanup(func() { managementURL = origManagementURL }) + + managementURL = "https://leftover.example.com" + + // Simulate saved params where management URL was explicitly cleared. + saved := &serviceParams{ + LogLevel: "info", + DaemonAddr: "unix:///var/run/netbird.sock", + // ManagementURL intentionally empty: was cleared with --management-url "". + } + + rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + f.Changed = false + }) + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + applyServiceParams(cmd, saved) + + assert.Equal(t, "", managementURL, "saved empty management URL should clear the current value") +} + +func TestApplyServiceParams_NilParams(t *testing.T) { + origLogLevel := logLevel + t.Cleanup(func() { logLevel = origLogLevel }) + + logLevel = "info" + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + + // Should be a no-op. + applyServiceParams(cmd, nil) + assert.Equal(t, "info", logLevel) +} + +func TestApplyServiceEnvParams_MergeExplicitAndSaved(t *testing.T) { + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) + + // Set up a command with --service-env marked as Changed. + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + require.NoError(t, cmd.Flags().Set("service-env", "EXPLICIT=yes,OVERLAP=explicit")) + + serviceEnvVars = []string{"EXPLICIT=yes", "OVERLAP=explicit"} + + saved := &serviceParams{ + ServiceEnvVars: map[string]string{ + "SAVED": "val", + "OVERLAP": "saved", + }, + } + + applyServiceEnvParams(cmd, saved) + + // Parse result for easier assertion. + result, err := parseServiceEnvVars(serviceEnvVars) + require.NoError(t, err) + + assert.Equal(t, "yes", result["EXPLICIT"]) + assert.Equal(t, "val", result["SAVED"]) + // Explicit wins on conflict. + assert.Equal(t, "explicit", result["OVERLAP"]) +} + +func TestApplyServiceEnvParams_NotChanged(t *testing.T) { + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) + + serviceEnvVars = nil + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + + saved := &serviceParams{ + ServiceEnvVars: map[string]string{"FROM_SAVED": "val"}, + } + + applyServiceEnvParams(cmd, saved) + + result, err := parseServiceEnvVars(serviceEnvVars) + require.NoError(t, err) + assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result) +} + +// TestServiceParams_FieldsCoveredInFunctions ensures that all serviceParams fields are +// referenced in both currentServiceParams() and applyServiceParams(). If a new field is +// added to serviceParams but not wired into these functions, this test fails. +func TestServiceParams_FieldsCoveredInFunctions(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "service_params.go", nil, 0) + require.NoError(t, err) + + // Collect all JSON field names from the serviceParams struct. + structFields := extractStructJSONFields(t, file, "serviceParams") + require.NotEmpty(t, structFields, "failed to find serviceParams struct fields") + + // Collect field names referenced in currentServiceParams and applyServiceParams. + currentFields := extractFuncFieldRefs(t, file, "currentServiceParams", structFields) + applyFields := extractFuncFieldRefs(t, file, "applyServiceParams", structFields) + // applyServiceEnvParams handles ServiceEnvVars indirectly. + applyEnvFields := extractFuncFieldRefs(t, file, "applyServiceEnvParams", structFields) + for k, v := range applyEnvFields { + applyFields[k] = v + } + + for _, field := range structFields { + assert.Contains(t, currentFields, field, + "serviceParams field %q is not captured in currentServiceParams()", field) + assert.Contains(t, applyFields, field, + "serviceParams field %q is not restored in applyServiceParams()/applyServiceEnvParams()", field) + } +} + +// TestServiceParams_BuildArgsCoversAllFlags ensures that buildServiceArguments references +// all serviceParams fields that should become CLI args. ServiceEnvVars is excluded because +// it flows through newSVCConfig() EnvVars, not CLI args. +func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "service_params.go", nil, 0) + require.NoError(t, err) + + structFields := extractStructJSONFields(t, file, "serviceParams") + require.NotEmpty(t, structFields) + + installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0) + require.NoError(t, err) + + // Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig). + fieldsNotInArgs := map[string]bool{ + "ServiceEnvVars": true, + } + + buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments") + + // Forward: every struct field must appear in buildServiceArguments. + for _, field := range structFields { + if fieldsNotInArgs[field] { + continue + } + globalVar := fieldToGlobalVar(field) + assert.Contains(t, buildFields, globalVar, + "serviceParams field %q (global %q) is not referenced in buildServiceArguments()", field, globalVar) + } + + // Reverse: every service-related global used in buildServiceArguments must + // have a corresponding serviceParams field. This catches a developer adding + // a new flag to buildServiceArguments without adding it to the struct. + globalToField := make(map[string]string, len(structFields)) + for _, field := range structFields { + globalToField[fieldToGlobalVar(field)] = field + } + // Identifiers in buildServiceArguments that are not service params + // (builtins, boilerplate, loop variables). + nonParamGlobals := map[string]bool{ + "args": true, "append": true, "string": true, "_": true, + "logFile": true, // range variable over logFiles + } + for ref := range buildFields { + if nonParamGlobals[ref] { + continue + } + _, inStruct := globalToField[ref] + assert.True(t, inStruct, + "buildServiceArguments() references global %q which has no corresponding serviceParams field", ref) + } +} + +// extractStructJSONFields returns field names from a named struct type. +func extractStructJSONFields(t *testing.T, file *ast.File, structName string) []string { + t.Helper() + var fields []string + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok || ts.Name.Name != structName { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return false + } + for _, f := range st.Fields.List { + if len(f.Names) > 0 { + fields = append(fields, f.Names[0].Name) + } + } + return false + }) + return fields +} + +// extractFuncFieldRefs returns which of the given field names appear inside the +// named function, either as selector expressions (params.FieldName) or as +// composite literal keys (&serviceParams{FieldName: ...}). +func extractFuncFieldRefs(t *testing.T, file *ast.File, funcName string, fields []string) map[string]bool { + t.Helper() + fieldSet := make(map[string]bool, len(fields)) + for _, f := range fields { + fieldSet[f] = true + } + + found := make(map[string]bool) + fn := findFuncDecl(file, funcName) + require.NotNil(t, fn, "function %s not found", funcName) + + ast.Inspect(fn.Body, func(n ast.Node) bool { + switch v := n.(type) { + case *ast.SelectorExpr: + if fieldSet[v.Sel.Name] { + found[v.Sel.Name] = true + } + case *ast.KeyValueExpr: + if ident, ok := v.Key.(*ast.Ident); ok && fieldSet[ident.Name] { + found[ident.Name] = true + } + } + return true + }) + return found +} + +// extractFuncGlobalRefs returns all identifier names referenced in the named function body. +func extractFuncGlobalRefs(t *testing.T, file *ast.File, funcName string) map[string]bool { + t.Helper() + fn := findFuncDecl(file, funcName) + require.NotNil(t, fn, "function %s not found", funcName) + + refs := make(map[string]bool) + ast.Inspect(fn.Body, func(n ast.Node) bool { + if ident, ok := n.(*ast.Ident); ok { + refs[ident.Name] = true + } + return true + }) + return refs +} + +func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Name.Name == name { + return fn + } + } + return nil +} + +// fieldToGlobalVar maps serviceParams field names to the package-level variable +// names used in buildServiceArguments and applyServiceParams. +func fieldToGlobalVar(field string) string { + m := map[string]string{ + "LogLevel": "logLevel", + "DaemonAddr": "daemonAddr", + "ManagementURL": "managementURL", + "ConfigPath": "configPath", + "LogFiles": "logFiles", + "DisableProfiles": "profilesDisabled", + "DisableUpdateSettings": "updateSettingsDisabled", + "ServiceEnvVars": "serviceEnvVars", + } + if v, ok := m[field]; ok { + return v + } + // Default: lowercase first letter. + return strings.ToLower(field[:1]) + field[1:] +} + +func TestEnvMapToSlice(t *testing.T) { + m := map[string]string{"A": "1", "B": "2"} + s := envMapToSlice(m) + assert.Len(t, s, 2) + assert.Contains(t, s, "A=1") + assert.Contains(t, s, "B=2") +} + +func TestEnvMapToSlice_Empty(t *testing.T) { + s := envMapToSlice(map[string]string{}) + assert.Empty(t, s) +} diff --git a/client/cmd/status.go b/client/cmd/status.go index f09c35c2c..c35a06eb3 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -28,6 +28,7 @@ var ( ipsFilterMap map[string]struct{} prefixNamesFilterMap map[string]struct{} connectionTypeFilter string + checkFlag string ) var statusCmd = &cobra.Command{ @@ -49,6 +50,7 @@ func init() { statusCmd.PersistentFlags().StringSliceVar(&prefixNamesFilter, "filter-by-names", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud") statusCmd.PersistentFlags().StringVar(&statusFilter, "filter-by-status", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected") statusCmd.PersistentFlags().StringVar(&connectionTypeFilter, "filter-by-connection-type", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P") + statusCmd.PersistentFlags().StringVar(&checkFlag, "check", "", "run a health check and exit with code 0 on success, 1 on failure (live|ready|startup)") } func statusFunc(cmd *cobra.Command, args []string) error { @@ -56,6 +58,10 @@ func statusFunc(cmd *cobra.Command, args []string) error { cmd.SetOut(cmd.OutOrStdout()) + if checkFlag != "" { + return runHealthCheck(cmd) + } + err := parseFilters() if err != nil { return err @@ -68,15 +74,17 @@ func statusFunc(cmd *cobra.Command, args []string) error { ctx := internal.CtxInitState(cmd.Context()) - resp, err := getStatus(ctx, false) + resp, err := getStatus(ctx, true, false) if err != nil { return err } status := resp.GetStatus() - if status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) || - status == string(internal.StatusSessionExpired) { + needsAuth := status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) || + status == string(internal.StatusSessionExpired) + + if needsAuth && !jsonFlag && !yamlFlag { cmd.Printf("Daemon status: %s\n\n"+ "Run UP command to log in with SSO (interactive login):\n\n"+ " netbird up \n\n"+ @@ -99,7 +107,17 @@ func statusFunc(cmd *cobra.Command, args []string) error { profName = activeProf.Name } - var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), anonymizeFlag, resp.GetDaemonVersion(), statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilterMap, connectionTypeFilter, profName) + var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{ + Anonymize: anonymizeFlag, + DaemonVersion: resp.GetDaemonVersion(), + DaemonStatus: nbstatus.ParseDaemonStatus(status), + StatusFilter: statusFilter, + PrefixNamesFilter: prefixNamesFilter, + PrefixNamesFilterMap: prefixNamesFilterMap, + IPsFilter: ipsFilterMap, + ConnectionTypeFilter: connectionTypeFilter, + ProfileName: profName, + }) var statusOutputString string switch { case detailFlag: @@ -121,7 +139,7 @@ func statusFunc(cmd *cobra.Command, args []string) error { return nil } -func getStatus(ctx context.Context, shouldRunProbes bool) (*proto.StatusResponse, error) { +func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (*proto.StatusResponse, error) { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { //nolint @@ -131,7 +149,7 @@ func getStatus(ctx context.Context, shouldRunProbes bool) (*proto.StatusResponse } defer conn.Close() - resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true, ShouldRunProbes: shouldRunProbes}) + resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{GetFullPeerStatus: fullPeerStatus, ShouldRunProbes: shouldRunProbes}) if err != nil { return nil, fmt.Errorf("status failed: %v", status.Convert(err).Message()) } @@ -185,6 +203,83 @@ func enableDetailFlagWhenFilterFlag() { } } +func runHealthCheck(cmd *cobra.Command) error { + check := strings.ToLower(checkFlag) + switch check { + case "live", "ready", "startup": + default: + return fmt.Errorf("unknown check %q, must be one of: live, ready, startup", checkFlag) + } + + if err := util.InitLog(logLevel, util.LogConsole); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := internal.CtxInitState(cmd.Context()) + + isStartup := check == "startup" + resp, err := getStatus(ctx, isStartup, false) + if err != nil { + return err + } + + switch check { + case "live": + return nil + case "ready": + return checkReadiness(resp) + case "startup": + return checkStartup(resp) + default: + return nil + } +} + +func checkReadiness(resp *proto.StatusResponse) error { + daemonStatus := internal.StatusType(resp.GetStatus()) + switch daemonStatus { + case internal.StatusIdle, internal.StatusConnecting, internal.StatusConnected: + return nil + case internal.StatusNeedsLogin, internal.StatusLoginFailed, internal.StatusSessionExpired: + return fmt.Errorf("readiness check: daemon status is %s", daemonStatus) + default: + return fmt.Errorf("readiness check: unexpected daemon status %q", daemonStatus) + } +} + +func checkStartup(resp *proto.StatusResponse) error { + fullStatus := resp.GetFullStatus() + if fullStatus == nil { + return fmt.Errorf("startup check: no full status available") + } + + if !fullStatus.GetManagementState().GetConnected() { + return fmt.Errorf("startup check: management not connected") + } + + if !fullStatus.GetSignalState().GetConnected() { + return fmt.Errorf("startup check: signal not connected") + } + + var relayCount, relaysConnected int + for _, r := range fullStatus.GetRelays() { + uri := r.GetURI() + if !strings.HasPrefix(uri, "rel://") && !strings.HasPrefix(uri, "rels://") { + continue + } + relayCount++ + if r.GetAvailable() { + relaysConnected++ + } + } + + if relayCount > 0 && relaysConnected == 0 { + return fmt.Errorf("startup check: no relay servers available (0/%d connected)", relayCount) + } + + return nil +} + func parseInterfaceIP(interfaceIP string) string { ip, _, err := net.ParseCIDR(interfaceIP) if err != nil { diff --git a/client/embed/embed.go b/client/embed/embed.go index 21043cf96..88f7e541c 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -14,6 +14,7 @@ import ( "github.com/sirupsen/logrus" wgnetstack "golang.zx2c4.com/wireguard/tun/netstack" + "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" @@ -21,6 +22,7 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" sshcommon "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" + "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) @@ -31,14 +33,14 @@ var ( ErrConfigNotInitialized = errors.New("config not initialized") ) -// PeerConnStatus is a peer's connection status. -type PeerConnStatus = peer.ConnStatus - const ( // PeerStatusConnected indicates the peer is in connected state. PeerStatusConnected = peer.StatusConnected ) +// PeerConnStatus is a peer's connection status. +type PeerConnStatus = peer.ConnStatus + // Client manages a netbird embedded client instance. type Client struct { deviceName string @@ -81,6 +83,14 @@ type Options struct { BlockInbound bool // WireguardPort is the port for the WireGuard interface. Use 0 for a random port. WireguardPort *int + // MTU is the MTU for the WireGuard interface. + // Valid values are in the range 576..8192 bytes. + // If non-nil, this value overrides any value stored in the config file. + // If nil, the existing config MTU (if non-zero) is preserved; otherwise it defaults to 1280. + // Set to a higher value (e.g. 1400) if carrying QUIC or other protocols that require larger datagrams. + MTU *uint16 + // DNSLabels defines additional DNS labels configured in the peer. + DNSLabels []string } // validateCredentials checks that exactly one credential type is provided @@ -112,6 +122,12 @@ func New(opts Options) (*Client, error) { return nil, err } + if opts.MTU != nil { + if err := iface.ValidateMTU(*opts.MTU); err != nil { + return nil, fmt.Errorf("invalid MTU: %w", err) + } + } + if opts.LogOutput != nil { logrus.SetOutput(opts.LogOutput) } @@ -140,9 +156,14 @@ func New(opts Options) (*Client, error) { } } + var err error + var parsedLabels domain.List + if parsedLabels, err = domain.FromStringList(opts.DNSLabels); err != nil { + return nil, fmt.Errorf("invalid dns labels: %w", err) + } + t := true var config *profilemanager.Config - var err error input := profilemanager.ConfigInput{ ConfigPath: opts.ConfigPath, ManagementURL: opts.ManagementURL, @@ -151,6 +172,8 @@ func New(opts Options) (*Client, error) { DisableClientRoutes: &opts.DisableClientRoutes, BlockInbound: &opts.BlockInbound, WireguardPort: opts.WireguardPort, + MTU: opts.MTU, + DNSLabels: parsedLabels, } if opts.ConfigPath != "" { config, err = profilemanager.UpdateOrCreateConfig(input) @@ -352,6 +375,32 @@ func (c *Client) NewHTTPClient() *http.Client { } } +// Expose exposes a local service via the NetBird reverse proxy, making it accessible through a public URL. +// It returns an ExposeSession. Call Wait on the session to keep it alive. +func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession, error) { + engine, err := c.getEngine() + if err != nil { + return nil, err + } + + mgr := engine.GetExposeManager() + if mgr == nil { + return nil, fmt.Errorf("expose manager not available") + } + + resp, err := mgr.Expose(ctx, req) + if err != nil { + return nil, fmt.Errorf("expose: %w", err) + } + + return &ExposeSession{ + Domain: resp.Domain, + ServiceName: resp.ServiceName, + ServiceURL: resp.ServiceURL, + mgr: mgr, + }, nil +} + // Status returns the current status of the client. func (c *Client) Status() (peer.FullStatus, error) { c.mu.Lock() diff --git a/client/embed/expose.go b/client/embed/expose.go new file mode 100644 index 000000000..825bb90ee --- /dev/null +++ b/client/embed/expose.go @@ -0,0 +1,45 @@ +package embed + +import ( + "context" + "errors" + + "github.com/netbirdio/netbird/client/internal/expose" +) + +const ( + // ExposeProtocolHTTP exposes the service as HTTP. + ExposeProtocolHTTP = expose.ProtocolHTTP + // ExposeProtocolHTTPS exposes the service as HTTPS. + ExposeProtocolHTTPS = expose.ProtocolHTTPS + // ExposeProtocolTCP exposes the service as TCP. + ExposeProtocolTCP = expose.ProtocolTCP + // ExposeProtocolUDP exposes the service as UDP. + ExposeProtocolUDP = expose.ProtocolUDP + // ExposeProtocolTLS exposes the service as TLS. + ExposeProtocolTLS = expose.ProtocolTLS +) + +// ExposeRequest is a request to expose a local service via the NetBird reverse proxy. +type ExposeRequest = expose.Request + +// ExposeProtocolType represents the protocol used for exposing a service. +type ExposeProtocolType = expose.ProtocolType + +// ExposeSession represents an active expose session. Use Wait to block until the session ends. +type ExposeSession struct { + Domain string + ServiceName string + ServiceURL string + + mgr *expose.Manager +} + +// Wait blocks while keeping the expose session alive. +// It returns when ctx is cancelled or a keep-alive error occurs, then terminates the session. +func (s *ExposeSession) Wait(ctx context.Context) error { + if s == nil || s.mgr == nil { + return errors.New("expose session is not initialized") + } + return s.mgr.KeepAlive(ctx, s.Domain) +} diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 716385705..04c338375 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -23,9 +23,10 @@ type Manager struct { wgIface iFaceMapper - ipv4Client *iptables.IPTables - aclMgr *aclManager - router *router + ipv4Client *iptables.IPTables + aclMgr *aclManager + router *router + rawSupported bool } // iFaceMapper defines subset methods of interface required for manager @@ -84,7 +85,7 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { } if err := m.initNoTrackChain(); err != nil { - return fmt.Errorf("init notrack chain: %w", err) + log.Warnf("raw table not available, notrack rules will be disabled: %v", err) } // persist early to ensure cleanup of chains @@ -318,6 +319,10 @@ func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() + if !m.rawSupported { + return fmt.Errorf("raw table not available") + } + wgPortStr := fmt.Sprintf("%d", wgPort) proxyPortStr := fmt.Sprintf("%d", proxyPort) @@ -375,12 +380,16 @@ func (m *Manager) initNoTrackChain() error { return fmt.Errorf("add prerouting jump rule: %w", err) } + m.rawSupported = true return nil } func (m *Manager) cleanupNoTrackChain() error { exists, err := m.ipv4Client.ChainExists(tableRaw, chainNameRaw) if err != nil { + if !m.rawSupported { + return nil + } return fmt.Errorf("check chain exists: %w", err) } if !exists { @@ -401,6 +410,7 @@ func (m *Manager) cleanupNoTrackChain() error { return fmt.Errorf("clear and delete chain: %w", err) } + m.rawSupported = false return nil } diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index acf482f86..f57b28abc 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -95,7 +95,7 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { } if err := m.initNoTrackChains(workTable); err != nil { - return fmt.Errorf("init notrack chains: %w", err) + log.Warnf("raw priority chains not available, notrack rules will be disabled: %v", err) } stateManager.RegisterState(&ShutdownState{}) diff --git a/client/grpc/dialer.go b/client/grpc/dialer.go index 54966b50e..9a6bc0670 100644 --- a/client/grpc/dialer.go +++ b/client/grpc/dialer.go @@ -28,7 +28,7 @@ func Backoff(ctx context.Context) backoff.BackOff { // CreateConnection creates a gRPC client connection with the appropriate transport options. // The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal"). -func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, component string) (*grpc.ClientConn, error) { +func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, component string, extraOpts ...grpc.DialOption) (*grpc.ClientConn, error) { transportOption := grpc.WithTransportCredentials(insecure.NewCredentials()) // for js, the outer websocket layer takes care of tls if tlsEnabled && runtime.GOOS != "js" { @@ -46,9 +46,7 @@ func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, compone connCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - conn, err := grpc.DialContext( - connCtx, - addr, + opts := []grpc.DialOption{ transportOption, WithCustomDialer(tlsEnabled, component), grpc.WithBlock(), @@ -56,7 +54,10 @@ func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, compone Time: 30 * time.Second, Timeout: 10 * time.Second, }), - ) + } + opts = append(opts, extraOpts...) + + conn, err := grpc.DialContext(connCtx, addr, opts...) if err != nil { return nil, fmt.Errorf("dial context: %w", err) } diff --git a/client/internal/connect.go b/client/internal/connect.go index ccd7b6c33..242b25b44 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/listener" + "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/statemanager" @@ -50,6 +51,7 @@ type ConnectClient struct { engine *Engine engineMutex sync.Mutex + clientMetrics *metrics.ClientMetrics updateManager *updater.Manager persistSyncResponse bool @@ -133,10 +135,34 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } }() + // Stop metrics push on exit + defer func() { + if c.clientMetrics != nil { + c.clientMetrics.StopPush() + } + }() + log.Infof("starting NetBird client version %s on %s/%s", version.NetbirdVersion(), runtime.GOOS, runtime.GOARCH) nbnet.Init() + // Initialize metrics once at startup (always active for debug bundles) + if c.clientMetrics == nil { + agentInfo := metrics.AgentInfo{ + DeploymentType: metrics.DeploymentTypeUnknown, + Version: version.NetbirdVersion(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + c.clientMetrics = metrics.NewClientMetrics(agentInfo) + log.Debugf("initialized client metrics") + + // Start metrics push if enabled (uses daemon context, persists across engine restarts) + if metrics.IsMetricsPushEnabled() { + c.clientMetrics.StartPush(c.ctx, metrics.PushConfigFromEnv()) + } + } + backOff := &backoff.ExponentialBackOff{ InitialInterval: time.Second, RandomizationFactor: 1, @@ -223,6 +249,16 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder) mgmClient.SetConnStateListener(mgmNotifier) + // Update metrics with actual deployment type after connection + deploymentType := metrics.DetermineDeploymentType(mgmClient.GetServerURL()) + agentInfo := metrics.AgentInfo{ + DeploymentType: deploymentType, + Version: version.NetbirdVersion(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + c.clientMetrics.UpdateAgentInfo(agentInfo, myPrivateKey.PublicKey().String()) + log.Debugf("connected to the Management service %s", c.config.ManagementURL.Host) defer func() { if err = mgmClient.Close(); err != nil { @@ -231,8 +267,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan }() // connect (just a connection, no stream yet) and login to Management Service to get an initial global Netbird config + loginStarted := time.Now() loginResp, err := loginToManagement(engineCtx, mgmClient, publicSSHKey, c.config) if err != nil { + c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), false) log.Debug(err) if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) { state.Set(StatusNeedsLogin) @@ -241,6 +279,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } return wrapErr(err) } + c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), true) c.statusRecorder.MarkManagementConnected() localPeerState := peer.LocalPeerState{ @@ -317,6 +356,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan Checks: checks, StateManager: stateManager, UpdateManager: c.updateManager, + ClientMetrics: c.clientMetrics, }, mobileDependency) engine.SetSyncResponsePersistence(c.persistSyncResponse) c.engine = engine diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index f0f399bef..c9ebf25e5 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -31,7 +31,6 @@ import ( nbstatus "github.com/netbirdio/netbird/client/status" mgmProto "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/util" - "github.com/netbirdio/netbird/version" ) const readmeContent = `Netbird debug bundle @@ -53,6 +52,7 @@ resolved_domains.txt: Anonymized resolved domain IP addresses from the status re config.txt: Anonymized configuration information of the NetBird client. network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules. state.json: Anonymized client state dump containing netbird states for the active profile. +metrics.txt: Buffered client metrics in InfluxDB line protocol format. Only present when metrics collection is enabled. Peer identifiers are anonymized. mutex.prof: Mutex profiling information. goroutine.prof: Goroutine profiling information. block.prof: Block profiling information. @@ -219,6 +219,11 @@ const ( darwinStdoutLogPath = "/var/log/netbird.err.log" ) +// MetricsExporter is an interface for exporting metrics +type MetricsExporter interface { + Export(w io.Writer) error +} + type BundleGenerator struct { anonymizer *anonymize.Anonymizer @@ -229,6 +234,7 @@ type BundleGenerator struct { logPath string cpuProfile []byte refreshStatus func() // Optional callback to refresh status before bundle generation + clientMetrics MetricsExporter anonymize bool includeSystemInfo bool @@ -250,6 +256,7 @@ type GeneratorDependencies struct { LogPath string CPUProfile []byte RefreshStatus func() // Optional callback to refresh status before bundle generation + ClientMetrics MetricsExporter } func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator { @@ -268,6 +275,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen logPath: deps.LogPath, cpuProfile: deps.CPUProfile, refreshStatus: deps.RefreshStatus, + clientMetrics: deps.ClientMetrics, anonymize: cfg.Anonymize, includeSystemInfo: cfg.IncludeSystemInfo, @@ -351,6 +359,10 @@ func (g *BundleGenerator) createArchive() error { log.Errorf("failed to add corrupted state files to debug bundle: %v", err) } + if err := g.addMetrics(); err != nil { + log.Errorf("failed to add metrics to debug bundle: %v", err) + } + if err := g.addWgShow(); err != nil { log.Errorf("failed to add wg show output: %v", err) } @@ -418,7 +430,10 @@ func (g *BundleGenerator) addStatus() error { fullStatus := g.statusRecorder.GetFullStatus() protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus) protoFullStatus.Events = g.statusRecorder.GetEventHistory() - overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, g.anonymize, version.NetbirdVersion(), "", nil, nil, nil, "", profName) + overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{ + Anonymize: g.anonymize, + ProfileName: profName, + }) statusOutput := overview.FullDetailSummary() statusReader := strings.NewReader(statusOutput) @@ -744,6 +759,30 @@ func (g *BundleGenerator) addCorruptedStateFiles() error { return nil } +func (g *BundleGenerator) addMetrics() error { + if g.clientMetrics == nil { + log.Debugf("skipping metrics in debug bundle: no metrics collector") + return nil + } + + var buf bytes.Buffer + if err := g.clientMetrics.Export(&buf); err != nil { + return fmt.Errorf("export metrics: %w", err) + } + + if buf.Len() == 0 { + log.Debugf("skipping metrics.txt in debug bundle: no metrics data") + return nil + } + + if err := g.addFileToZip(&buf, "metrics.txt"); err != nil { + return fmt.Errorf("add metrics file to zip: %w", err) + } + + log.Debugf("added metrics to debug bundle") + return nil +} + func (g *BundleGenerator) addLogfile() error { if g.logPath == "" { log.Debugf("skipping empty log file in debug bundle") diff --git a/client/internal/dns/mock_server.go b/client/internal/dns/mock_server.go index fe160e20a..1df57d1db 100644 --- a/client/internal/dns/mock_server.go +++ b/client/internal/dns/mock_server.go @@ -85,6 +85,11 @@ func (m *MockServer) PopulateManagementDomain(mgmtURL *url.URL) error { return nil } +// SetRouteChecker mock implementation of SetRouteChecker from Server interface +func (m *MockServer) SetRouteChecker(func(netip.Addr) bool) { + // Mock implementation - no-op +} + // BeginBatch mock implementation of BeginBatch from Server interface func (m *MockServer) BeginBatch() { // Mock implementation - no-op diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 6ca4f7957..3c47f4ee6 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -57,6 +57,7 @@ type Server interface { ProbeAvailability() UpdateServerConfig(domains dnsconfig.ServerDomains) error PopulateManagementDomain(mgmtURL *url.URL) error + SetRouteChecker(func(netip.Addr) bool) } type nsGroupsByDomain struct { @@ -104,6 +105,7 @@ type DefaultServer struct { statusRecorder *peer.Status stateManager *statemanager.Manager + routeMatch func(netip.Addr) bool probeMu sync.Mutex probeCancel context.CancelFunc @@ -229,6 +231,14 @@ func newDefaultServer( return defaultServer } +// SetRouteChecker sets the function used by upstream resolvers to determine +// whether an IP is routed through the tunnel. +func (s *DefaultServer) SetRouteChecker(f func(netip.Addr) bool) { + s.mux.Lock() + defer s.mux.Unlock() + s.routeMatch = f +} + // RegisterHandler registers a handler for the given domains with the given priority. // Any previously registered handler for the same domain and priority will be replaced. func (s *DefaultServer) RegisterHandler(domains domain.List, handler dns.Handler, priority int) { @@ -743,6 +753,7 @@ func (s *DefaultServer) registerFallback(config HostDNSConfig) { log.Errorf("failed to create upstream resolver for original nameservers: %v", err) return } + handler.routeMatch = s.routeMatch for _, ns := range originalNameservers { if ns == config.ServerIP { @@ -852,6 +863,7 @@ func (s *DefaultServer) createHandlersForDomainGroup(domainGroup nsGroupsByDomai if err != nil { return nil, fmt.Errorf("create upstream resolver: %v", err) } + handler.routeMatch = s.routeMatch for _, ns := range nsGroup.NameServers { if ns.NSType != nbdns.UDPNameServerType { @@ -1036,6 +1048,7 @@ func (s *DefaultServer) addHostRootZone() { log.Errorf("unable to create a new upstream resolver, error: %v", err) return } + handler.routeMatch = s.routeMatch handler.upstreamServers = maps.Keys(hostDNSServers) handler.deactivate = func(error) {} diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 806559444..f7ddfd40f 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "runtime" + "strconv" "sync" "time" @@ -69,7 +70,7 @@ func (s *serviceViaListener) Listen() error { return fmt.Errorf("eval listen address: %w", err) } s.listenIP = s.listenIP.Unmap() - s.server.Addr = fmt.Sprintf("%s:%d", s.listenIP, s.listenPort) + s.server.Addr = net.JoinHostPort(s.listenIP.String(), strconv.Itoa(int(s.listenPort))) log.Debugf("starting dns on %s", s.server.Addr) go func() { s.setListenerStatus(true) @@ -186,7 +187,7 @@ func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) { } func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool { - addrString := fmt.Sprintf("%s:%d", ip, port) + addrString := net.JoinHostPort(ip.String(), strconv.Itoa(port)) udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort(addrString)) probeListener, err := net.ListenUDP("udp", udpAddr) if err != nil { diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index 18128a942..5b8135132 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -70,6 +70,7 @@ type upstreamResolverBase struct { deactivate func(error) reactivate func() statusRecorder *peer.Status + routeMatch func(netip.Addr) bool } type upstreamFailure struct { diff --git a/client/internal/dns/upstream_ios.go b/client/internal/dns/upstream_ios.go index 4d053a5a1..02c11173b 100644 --- a/client/internal/dns/upstream_ios.go +++ b/client/internal/dns/upstream_ios.go @@ -65,11 +65,13 @@ func (u *upstreamResolverIOS) exchange(ctx context.Context, upstream string, r * } else { upstreamIP = upstreamIP.Unmap() } - if u.lNet.Contains(upstreamIP) || upstreamIP.IsPrivate() { - log.Debugf("using private client to query upstream: %s", upstream) + needsPrivate := u.lNet.Contains(upstreamIP) || + (u.routeMatch != nil && u.routeMatch(upstreamIP)) + if needsPrivate { + log.Debugf("using private client to query %s via upstream %s", r.Question[0].Name, upstream) client, err = GetClientPrivate(u.lIP, u.interfaceName, timeout) if err != nil { - return nil, 0, fmt.Errorf("error while creating private client: %s", err) + return nil, 0, fmt.Errorf("create private client: %s", err) } } diff --git a/client/internal/engine.go b/client/internal/engine.go index fd3bdf7af..7b100bd0c 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -38,6 +38,7 @@ import ( "github.com/netbirdio/netbird/client/internal/dnsfwd" "github.com/netbirdio/netbird/client/internal/expose" "github.com/netbirdio/netbird/client/internal/ingressgw" + "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/netflow" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/networkmonitor" @@ -149,6 +150,7 @@ type EngineServices struct { Checks []*mgmProto.Checks StateManager *statemanager.Manager UpdateManager *updater.Manager + ClientMetrics *metrics.ClientMetrics } // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. @@ -229,6 +231,9 @@ type Engine struct { probeStunTurn *relay.StunTurnProbe + // clientMetrics collects and pushes metrics + clientMetrics *metrics.ClientMetrics + jobExecutor *jobexec.Executor jobExecutorWG sync.WaitGroup @@ -272,6 +277,7 @@ func NewEngine( checks: services.Checks, probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL), jobExecutor: jobexec.NewExecutor(), + clientMetrics: services.ClientMetrics, updateManager: services.UpdateManager, } @@ -493,6 +499,17 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.routeManager.SetRouteChangeListener(e.mobileDep.NetworkChangeListener) + e.dnsServer.SetRouteChecker(func(ip netip.Addr) bool { + for _, routes := range e.routeManager.GetClientRoutes() { + for _, r := range routes { + if r.Network.Contains(ip) { + return true + } + } + } + return false + }) + if err = e.wgInterfaceCreate(); err != nil { log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error()) e.close() @@ -813,7 +830,9 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { started := time.Now() defer func() { - log.Infof("sync finished in %s", time.Since(started)) + duration := time.Since(started) + log.Infof("sync finished in %s", duration) + e.clientMetrics.RecordSyncDuration(e.ctx, duration) }() e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() @@ -989,10 +1008,11 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { return errors.New("wireguard interface is not initialized") } - // Cannot update the IP address without restarting the engine because - // the firewall, route manager, and other components cache the old address if e.wgInterface.Address().String() != conf.Address { - log.Infof("peer IP address has changed from %s to %s", e.wgInterface.Address().String(), conf.Address) + log.Infof("peer IP address changed from %s to %s, restarting client", e.wgInterface.Address().String(), conf.Address) + _ = CtxGetState(e.ctx).Wrap(ErrResetConnection) + e.clientCancel() + return ErrResetConnection } if conf.GetSshConfig() != nil { @@ -1060,6 +1080,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR StatusRecorder: e.statusRecorder, SyncResponse: syncResponse, LogPath: e.config.LogPath, + ClientMetrics: e.clientMetrics, RefreshStatus: func() { e.RunHealthProbes(true) }, @@ -1514,11 +1535,12 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV } serviceDependencies := peer.ServiceDependencies{ - StatusRecorder: e.statusRecorder, - Signaler: e.signaler, - IFaceDiscover: e.mobileDep.IFaceDiscover, - RelayManager: e.relayManager, - SrWatcher: e.srWatcher, + StatusRecorder: e.statusRecorder, + Signaler: e.signaler, + IFaceDiscover: e.mobileDep.IFaceDiscover, + RelayManager: e.relayManager, + SrWatcher: e.srWatcher, + MetricsRecorder: e.clientMetrics, } peerConn, err := peer.NewConn(config, serviceDependencies) if err != nil { @@ -1815,6 +1837,11 @@ func (e *Engine) GetExposeManager() *expose.Manager { return e.exposeManager } +// GetClientMetrics returns the client metrics +func (e *Engine) GetClientMetrics() *metrics.ClientMetrics { + return e.clientMetrics +} + func findIPFromInterfaceName(ifaceName string) (net.IP, error) { iface, err := net.InterfaceByName(ifaceName) if err != nil { diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index f9e7f8fa0..77fe9049b 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -828,7 +828,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, - }, EngineServices{ + }, EngineServices{ SignalClient: &signal.MockClient{}, MgmClient: &mgmt.MockClient{}, RelayManager: relayMgr, @@ -1035,7 +1035,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, - }, EngineServices{ + }, EngineServices{ SignalClient: &signal.MockClient{}, MgmClient: &mgmt.MockClient{}, RelayManager: relayMgr, @@ -1566,7 +1566,7 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin } relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - e, err := NewEngine(ctx, cancel, conf, EngineServices{ +e, err := NewEngine(ctx, cancel, conf, EngineServices{ SignalClient: signalClient, MgmClient: mgmtClient, RelayManager: relayMgr, diff --git a/client/internal/expose/manager.go b/client/internal/expose/manager.go index c59a1a7bd..076f92043 100644 --- a/client/internal/expose/manager.go +++ b/client/internal/expose/manager.go @@ -4,11 +4,14 @@ import ( "context" "time" - mgm "github.com/netbirdio/netbird/shared/management/client" log "github.com/sirupsen/logrus" + + mgm "github.com/netbirdio/netbird/shared/management/client" ) -const renewTimeout = 10 * time.Second +const ( + renewTimeout = 10 * time.Second +) // Response holds the response from exposing a service. type Response struct { @@ -18,11 +21,13 @@ type Response struct { PortAutoAssigned bool } +// Request holds the parameters for exposing a local service via the management server. +// It is part of the embed API surface and exposed via a type alias. type Request struct { NamePrefix string Domain string Port uint16 - Protocol int + Protocol ProtocolType Pin string Password string UserGroups []string @@ -59,6 +64,8 @@ func (m *Manager) Expose(ctx context.Context, req Request) (*Response, error) { return fromClientExposeResponse(resp), nil } +// KeepAlive periodically renews the expose session for the given domain until the context is canceled or an error occurs. +// It is part of the embed API surface and exposed via a type alias. func (m *Manager) KeepAlive(ctx context.Context, domain string) error { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() diff --git a/client/internal/expose/manager_test.go b/client/internal/expose/manager_test.go index 87d43cdb0..7d76c9838 100644 --- a/client/internal/expose/manager_test.go +++ b/client/internal/expose/manager_test.go @@ -86,7 +86,7 @@ func TestNewRequest(t *testing.T) { exposeReq := NewRequest(req) assert.Equal(t, uint16(8080), exposeReq.Port, "port should match") - assert.Equal(t, int(daemonProto.ExposeProtocol_EXPOSE_HTTPS), exposeReq.Protocol, "protocol should match") + assert.Equal(t, ProtocolType(daemonProto.ExposeProtocol_EXPOSE_HTTPS), exposeReq.Protocol, "protocol should match") assert.Equal(t, "123456", exposeReq.Pin, "pin should match") assert.Equal(t, "secret", exposeReq.Password, "password should match") assert.Equal(t, []string{"group1", "group2"}, exposeReq.UserGroups, "user groups should match") diff --git a/client/internal/expose/protocol.go b/client/internal/expose/protocol.go new file mode 100644 index 000000000..d5026d51e --- /dev/null +++ b/client/internal/expose/protocol.go @@ -0,0 +1,40 @@ +package expose + +import ( + "fmt" + "strings" +) + +// ProtocolType represents the protocol used for exposing a service. +type ProtocolType int + +const ( + // ProtocolHTTP exposes the service as HTTP. + ProtocolHTTP ProtocolType = 0 + // ProtocolHTTPS exposes the service as HTTPS. + ProtocolHTTPS ProtocolType = 1 + // ProtocolTCP exposes the service as TCP. + ProtocolTCP ProtocolType = 2 + // ProtocolUDP exposes the service as UDP. + ProtocolUDP ProtocolType = 3 + // ProtocolTLS exposes the service as TLS. + ProtocolTLS ProtocolType = 4 +) + +// ParseProtocolType parses a protocol string into a ProtocolType. +func ParseProtocolType(s string) (ProtocolType, error) { + switch strings.ToLower(s) { + case "http": + return ProtocolHTTP, nil + case "https": + return ProtocolHTTPS, nil + case "tcp": + return ProtocolTCP, nil + case "udp": + return ProtocolUDP, nil + case "tls": + return ProtocolTLS, nil + default: + return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", s) + } +} diff --git a/client/internal/expose/request.go b/client/internal/expose/request.go index bff4f2ce7..ec75bb276 100644 --- a/client/internal/expose/request.go +++ b/client/internal/expose/request.go @@ -9,7 +9,7 @@ import ( func NewRequest(req *daemonProto.ExposeServiceRequest) *Request { return &Request{ Port: uint16(req.Port), - Protocol: int(req.Protocol), + Protocol: ProtocolType(req.Protocol), Pin: req.Pin, Password: req.Password, UserGroups: req.UserGroups, @@ -24,7 +24,7 @@ func toClientExposeRequest(req Request) mgm.ExposeRequest { NamePrefix: req.NamePrefix, Domain: req.Domain, Port: req.Port, - Protocol: req.Protocol, + Protocol: int(req.Protocol), Pin: req.Pin, Password: req.Password, UserGroups: req.UserGroups, diff --git a/client/internal/metrics/connection_type.go b/client/internal/metrics/connection_type.go new file mode 100644 index 000000000..a3406a6b8 --- /dev/null +++ b/client/internal/metrics/connection_type.go @@ -0,0 +1,17 @@ +package metrics + +// ConnectionType represents the type of peer connection +type ConnectionType string + +const ( + // ConnectionTypeICE represents a direct peer-to-peer connection using ICE + ConnectionTypeICE ConnectionType = "ice" + + // ConnectionTypeRelay represents a relayed connection + ConnectionTypeRelay ConnectionType = "relay" +) + +// String returns the string representation of the connection type +func (c ConnectionType) String() string { + return string(c) +} diff --git a/client/internal/metrics/deployment_type.go b/client/internal/metrics/deployment_type.go new file mode 100644 index 000000000..141173cb8 --- /dev/null +++ b/client/internal/metrics/deployment_type.go @@ -0,0 +1,51 @@ +package metrics + +import ( + "net/url" + "strings" +) + +// DeploymentType represents the type of NetBird deployment +type DeploymentType int + +const ( + // DeploymentTypeUnknown represents an unknown or uninitialized deployment type + DeploymentTypeUnknown DeploymentType = iota + + // DeploymentTypeCloud represents a cloud-hosted NetBird deployment + DeploymentTypeCloud + + // DeploymentTypeSelfHosted represents a self-hosted NetBird deployment + DeploymentTypeSelfHosted +) + +// String returns the string representation of the deployment type +func (d DeploymentType) String() string { + switch d { + case DeploymentTypeCloud: + return "cloud" + case DeploymentTypeSelfHosted: + return "selfhosted" + default: + return "unknown" + } +} + +// DetermineDeploymentType determines if the deployment is cloud or self-hosted +// based on the management URL string +func DetermineDeploymentType(managementURL string) DeploymentType { + if managementURL == "" { + return DeploymentTypeUnknown + } + + u, err := url.Parse(managementURL) + if err != nil { + return DeploymentTypeSelfHosted + } + + if strings.ToLower(u.Hostname()) == "api.netbird.io" { + return DeploymentTypeCloud + } + + return DeploymentTypeSelfHosted +} diff --git a/client/internal/metrics/env.go b/client/internal/metrics/env.go new file mode 100644 index 000000000..1f06ce484 --- /dev/null +++ b/client/internal/metrics/env.go @@ -0,0 +1,93 @@ +package metrics + +import ( + "net/url" + "os" + "strconv" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + // EnvMetricsPushEnabled controls whether collected metrics are pushed to the backend. + // Metrics collection itself is always active (for debug bundles). + // Disabled by default. Set NB_METRICS_PUSH_ENABLED=true to enable push. + EnvMetricsPushEnabled = "NB_METRICS_PUSH_ENABLED" + + // EnvMetricsForceSending if set to true, skips remote configuration fetch and forces metric sending + EnvMetricsForceSending = "NB_METRICS_FORCE_SENDING" + + // EnvMetricsConfigURL is the environment variable to override the metrics push config ServerAddress + EnvMetricsConfigURL = "NB_METRICS_CONFIG_URL" + + // EnvMetricsServerURL is the environment variable to override the metrics server address. + // When set, this takes precedence over the server_url from remote push config. + EnvMetricsServerURL = "NB_METRICS_SERVER_URL" + + // EnvMetricsInterval overrides the push interval from the remote config. + // Only affects how often metrics are pushed; remote config availability + // and version range checks are still respected. + // Format: duration string like "1h", "30m", "4h" + EnvMetricsInterval = "NB_METRICS_INTERVAL" + + defaultMetricsConfigURL = "https://ingest.netbird.io/config" +) + +// IsMetricsPushEnabled returns true if metrics push is enabled via NB_METRICS_PUSH_ENABLED env var. +// Disabled by default. Metrics collection is always active for debug bundles. +func IsMetricsPushEnabled() bool { + enabled, _ := strconv.ParseBool(os.Getenv(EnvMetricsPushEnabled)) + return enabled +} + +// getMetricsInterval returns the metrics push interval from NB_METRICS_INTERVAL env var. +// Returns 0 if not set or invalid. +func getMetricsInterval() time.Duration { + intervalStr := os.Getenv(EnvMetricsInterval) + if intervalStr == "" { + return 0 + } + interval, err := time.ParseDuration(intervalStr) + if err != nil { + log.Warnf("invalid metrics interval from env %q: %v", intervalStr, err) + return 0 + } + if interval <= 0 { + log.Warnf("invalid metrics interval from env %q: must be positive", intervalStr) + return 0 + } + return interval +} + +func isForceSending() bool { + force, _ := strconv.ParseBool(os.Getenv(EnvMetricsForceSending)) + return force +} + +// getMetricsConfigURL returns the URL to fetch push configuration from +func getMetricsConfigURL() string { + if envURL := os.Getenv(EnvMetricsConfigURL); envURL != "" { + return envURL + } + return defaultMetricsConfigURL +} + +// getMetricsServerURL returns the metrics server URL from NB_METRICS_SERVER_URL env var. +// Returns nil if not set or invalid. +func getMetricsServerURL() *url.URL { + envURL := os.Getenv(EnvMetricsServerURL) + if envURL == "" { + return nil + } + parsed, err := url.ParseRequestURI(envURL) + if err != nil || parsed.Host == "" { + log.Warnf("invalid metrics server URL %q: must be an absolute HTTP(S) URL", envURL) + return nil + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + log.Warnf("invalid metrics server URL %q: unsupported scheme %q", envURL, parsed.Scheme) + return nil + } + return parsed +} diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go new file mode 100644 index 000000000..531f6a986 --- /dev/null +++ b/client/internal/metrics/influxdb.go @@ -0,0 +1,219 @@ +package metrics + +import ( + "context" + "fmt" + "io" + "maps" + "slices" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + maxSampleAge = 5 * 24 * time.Hour // drop samples older than 5 days + maxBufferSize = 5 * 1024 * 1024 // drop oldest samples when estimated size exceeds 5 MB + // estimatedSampleSize is a rough per-sample memory estimate (measurement + tags + fields + timestamp) + estimatedSampleSize = 256 +) + +// influxSample is a single InfluxDB line protocol entry. +type influxSample struct { + measurement string + tags string + fields map[string]float64 + timestamp time.Time +} + +// influxDBMetrics collects metric events as timestamped samples. +// Each event is recorded with its exact timestamp, pushed once, then cleared. +type influxDBMetrics struct { + mu sync.Mutex + samples []influxSample +} + +func newInfluxDBMetrics() metricsImplementation { + return &influxDBMetrics{} +} +func (m *influxDBMetrics) RecordConnectionStages( + _ context.Context, + agentInfo AgentInfo, + connectionPairID string, + connectionType ConnectionType, + isReconnection bool, + timestamps ConnectionStageTimestamps, +) { + var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64 + + if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() { + signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds() + } + + if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { + connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds() + } + + if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { + totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds() + } + + attemptType := "initial" + if isReconnection { + attemptType = "reconnection" + } + + connTypeStr := connectionType.String() + tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s", + agentInfo.DeploymentType.String(), + connTypeStr, + attemptType, + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + connectionPairID, + ) + + now := time.Now() + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_peer_connection", + tags: tags, + fields: map[string]float64{ + "signaling_to_connection_seconds": signalingReceivedToConnection, + "connection_to_wg_handshake_seconds": connectionToWgHandshake, + "total_seconds": totalDuration, + }, + timestamp: now, + }) + m.trimLocked() + + log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs", + agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration) +} + +func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) { + tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s", + agentInfo.DeploymentType.String(), + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + ) + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_sync", + tags: tags, + fields: map[string]float64{ + "duration_seconds": duration.Seconds(), + }, + timestamp: time.Now(), + }) + m.trimLocked() +} + +func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) { + result := "success" + if !success { + result = "failure" + } + + tags := fmt.Sprintf("deployment_type=%s,result=%s,version=%s,os=%s,arch=%s,peer_id=%s", + agentInfo.DeploymentType.String(), + result, + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + ) + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_login", + tags: tags, + fields: map[string]float64{ + "duration_seconds": duration.Seconds(), + }, + timestamp: time.Now(), + }) + m.trimLocked() + + log.Tracef("login metrics [%s, %s]: duration=%.3fs", agentInfo.DeploymentType.String(), result, duration.Seconds()) +} + +// Export writes pending samples in InfluxDB line protocol format. +// Format: measurement,tag=val,tag=val field=val,field=val timestamp_ns +func (m *influxDBMetrics) Export(w io.Writer) error { + m.mu.Lock() + samples := make([]influxSample, len(m.samples)) + copy(samples, m.samples) + m.mu.Unlock() + + for _, s := range samples { + if _, err := fmt.Fprintf(w, "%s,%s ", s.measurement, s.tags); err != nil { + return err + } + + sortedKeys := slices.Sorted(maps.Keys(s.fields)) + first := true + for _, k := range sortedKeys { + if !first { + if _, err := fmt.Fprint(w, ","); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, "%s=%g", k, s.fields[k]); err != nil { + return err + } + first = false + } + + if _, err := fmt.Fprintf(w, " %d\n", s.timestamp.UnixNano()); err != nil { + return err + } + } + return nil +} + +// Reset clears pending samples after a successful push +func (m *influxDBMetrics) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.samples = m.samples[:0] +} + +// trimLocked removes samples that exceed age or size limits. +// Must be called with m.mu held. +func (m *influxDBMetrics) trimLocked() { + now := time.Now() + + // drop samples older than maxSampleAge + cutoff := 0 + for cutoff < len(m.samples) && now.Sub(m.samples[cutoff].timestamp) > maxSampleAge { + cutoff++ + } + if cutoff > 0 { + copy(m.samples, m.samples[cutoff:]) + m.samples = m.samples[:len(m.samples)-cutoff] + log.Debugf("influxdb metrics: dropped %d samples older than %s", cutoff, maxSampleAge) + } + + // drop oldest samples if estimated size exceeds maxBufferSize + maxSamples := maxBufferSize / estimatedSampleSize + if len(m.samples) > maxSamples { + drop := len(m.samples) - maxSamples + copy(m.samples, m.samples[drop:]) + m.samples = m.samples[:maxSamples] + log.Debugf("influxdb metrics: dropped %d oldest samples to stay under %d MB size limit", drop, maxBufferSize/(1024*1024)) + } +} diff --git a/client/internal/metrics/influxdb_test.go b/client/internal/metrics/influxdb_test.go new file mode 100644 index 000000000..b964e31a3 --- /dev/null +++ b/client/internal/metrics/influxdb_test.go @@ -0,0 +1,229 @@ +package metrics + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInfluxDBMetrics_RecordAndExport(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeCloud, + Version: "1.0.0", + OS: "linux", + Arch: "amd64", + peerID: "abc123", + } + + ts := ConnectionStageTimestamps{ + SignalingReceived: time.Now().Add(-3 * time.Second), + ConnectionReady: time.Now().Add(-2 * time.Second), + WgHandshakeSuccess: time.Now().Add(-1 * time.Second), + } + + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "netbird_peer_connection,") + assert.Contains(t, output, "connection_to_wg_handshake_seconds=") + assert.Contains(t, output, "signaling_to_connection_seconds=") + assert.Contains(t, output, "total_seconds=") +} + +func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeCloud, + Version: "1.0.0", + OS: "linux", + Arch: "amd64", + peerID: "abc123", + } + + ts := ConnectionStageTimestamps{ + SignalingReceived: time.Now().Add(-3 * time.Second), + ConnectionReady: time.Now().Add(-2 * time.Second), + WgHandshakeSuccess: time.Now().Add(-1 * time.Second), + } + + // Record multiple times and verify consistent field order + for i := 0; i < 10; i++ { + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + } + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + require.Len(t, lines, 10) + + // Extract field portion from each line and verify they're all identical + var fieldSections []string + for _, line := range lines { + parts := strings.SplitN(line, " ", 3) + require.Len(t, parts, 3, "each line should have measurement, fields, timestamp") + fieldSections = append(fieldSections, parts[1]) + } + + for i := 1; i < len(fieldSections); i++ { + assert.Equal(t, fieldSections[0], fieldSections[i], "field order should be deterministic across samples") + } + + // Fields should be alphabetically sorted + assert.True(t, strings.HasPrefix(fieldSections[0], "connection_to_wg_handshake_seconds="), + "fields should be sorted: connection_to_wg < signaling_to < total") +} + +func TestInfluxDBMetrics_RecordSyncDuration(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeSelfHosted, + Version: "2.0.0", + OS: "darwin", + Arch: "arm64", + peerID: "def456", + } + + m.RecordSyncDuration(context.Background(), agentInfo, 1500*time.Millisecond) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "netbird_sync,") + assert.Contains(t, output, "duration_seconds=1.5") + assert.Contains(t, output, "deployment_type=selfhosted") +} + +func TestInfluxDBMetrics_Reset(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeCloud, + Version: "1.0.0", + OS: "linux", + Arch: "amd64", + peerID: "abc123", + } + + m.RecordSyncDuration(context.Background(), agentInfo, time.Second) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + assert.NotEmpty(t, buf.String()) + + m.Reset() + + buf.Reset() + err = m.Export(&buf) + require.NoError(t, err) + assert.Empty(t, buf.String(), "should be empty after reset") +} + +func TestInfluxDBMetrics_ExportEmpty(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + assert.Empty(t, buf.String()) +} + +func TestInfluxDBMetrics_TrimByAge(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + m.mu.Lock() + m.samples = append(m.samples, influxSample{ + measurement: "old", + tags: "t=1", + fields: map[string]float64{"v": 1}, + timestamp: time.Now().Add(-maxSampleAge - time.Hour), + }) + m.trimLocked() + remaining := len(m.samples) + m.mu.Unlock() + + assert.Equal(t, 0, remaining, "old samples should be trimmed") +} + +func TestInfluxDBMetrics_RecordLoginDuration(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeCloud, + Version: "1.0.0", + OS: "linux", + Arch: "amd64", + peerID: "abc123", + } + + m.RecordLoginDuration(context.Background(), agentInfo, 2500*time.Millisecond, true) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "netbird_login,") + assert.Contains(t, output, "duration_seconds=2.5") + assert.Contains(t, output, "result=success") +} + +func TestInfluxDBMetrics_RecordLoginDurationFailure(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeSelfHosted, + Version: "1.0.0", + OS: "darwin", + Arch: "arm64", + peerID: "xyz789", + } + + m.RecordLoginDuration(context.Background(), agentInfo, 5*time.Second, false) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "netbird_login,") + assert.Contains(t, output, "result=failure") + assert.Contains(t, output, "deployment_type=selfhosted") +} + +func TestInfluxDBMetrics_TrimBySize(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + maxSamples := maxBufferSize / estimatedSampleSize + m.mu.Lock() + for i := 0; i < maxSamples+100; i++ { + m.samples = append(m.samples, influxSample{ + measurement: "test", + tags: "t=1", + fields: map[string]float64{"v": float64(i)}, + timestamp: time.Now(), + }) + } + m.trimLocked() + remaining := len(m.samples) + m.mu.Unlock() + + assert.Equal(t, maxSamples, remaining, "should trim to max samples") +} diff --git a/client/internal/metrics/infra/.env.example b/client/internal/metrics/infra/.env.example new file mode 100644 index 000000000..9c5c1a258 --- /dev/null +++ b/client/internal/metrics/infra/.env.example @@ -0,0 +1,16 @@ +# Copy to .env and adjust values before running docker compose + +# InfluxDB admin (server-side only, never exposed to clients) +INFLUXDB_ADMIN_PASSWORD=changeme +INFLUXDB_ADMIN_TOKEN=changeme + +# Grafana admin credentials +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=changeme + +# Remote config served by ingest at /config +# Set CONFIG_METRICS_SERVER_URL to the ingest server's public address to enable +CONFIG_METRICS_SERVER_URL= +CONFIG_VERSION_SINCE=0.0.0 +CONFIG_VERSION_UNTIL=99.99.99 +CONFIG_PERIOD_MINUTES=5 diff --git a/client/internal/metrics/infra/.gitignore b/client/internal/metrics/infra/.gitignore new file mode 100644 index 000000000..4c49bd78f --- /dev/null +++ b/client/internal/metrics/infra/.gitignore @@ -0,0 +1 @@ +.env diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md new file mode 100644 index 000000000..5a93dbd87 --- /dev/null +++ b/client/internal/metrics/infra/README.md @@ -0,0 +1,194 @@ +# Client Metrics + +Internal documentation for the NetBird client metrics system. + +## Overview + +Client metrics track connection performance and sync durations using InfluxDB line protocol (`influxdb.go`). Each event is pushed once then cleared. + +Metrics collection is always active (for debug bundles). Push to backend is: +- Disabled by default (opt-in via `NB_METRICS_PUSH_ENABLED=true`) +- Managed at daemon layer (survives engine restarts) + +## Architecture + +### Layer Separation + +```text +Daemon Layer (connect.go) + ├─ Creates ClientMetrics instance once + ├─ Starts/stops push lifecycle + └─ Updates AgentInfo on profile switch + │ + ▼ +Engine Layer (engine.go) + └─ Records metrics via ClientMetrics methods +``` + +### Ingest Server + +Clients do not talk to InfluxDB directly. An ingest server sits between clients and InfluxDB: + +```text +Client ──POST──▶ Ingest Server (:8087) ──▶ InfluxDB (internal) + │ + ├─ Validates line protocol + ├─ Allowlists measurements, fields, and tags + ├─ Rejects out-of-bound values + └─ Serves remote config at /config +``` + +- **No secret/token-based client auth** — the ingest server holds the InfluxDB token server-side. Clients must send a hashed peer ID via `X-Peer-ID` header. +- **InfluxDB is not exposed** — only accessible within the docker network +- Source: `ingest/main.go` + +## Metrics Collected + +### Connection Stage Timing + +Measurement: `netbird_peer_connection` + +| Field | Timestamps | Description | +|-------|-----------|-------------| +| `signaling_to_connection_seconds` | `SignalingReceived → ConnectionReady` | ICE/relay negotiation time after the first signal is received from the remote peer | +| `connection_to_wg_handshake_seconds` | `ConnectionReady → WgHandshakeSuccess` | WireGuard cryptographic handshake latency once the transport layer is ready | +| `total_seconds` | `SignalingReceived → WgHandshakeSuccess` | End-to-end connection time anchored at the first received signal | + +Tags: +- `deployment_type`: "cloud" | "selfhosted" | "unknown" +- `connection_type`: "ice" | "relay" +- `attempt_type`: "initial" | "reconnection" +- `version`: NetBird version string +- `os`: Operating system (linux, darwin, windows, android, ios, etc.) +- `arch`: CPU architecture (amd64, arm64, etc.) + +**Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online. + +### Sync Duration + +Measurement: `netbird_sync` + +| Field | Description | +|-------|-------------| +| `duration_seconds` | Time to process a sync message from management server | + +Tags: +- `deployment_type`: "cloud" | "selfhosted" | "unknown" +- `version`: NetBird version string +- `os`: Operating system (linux, darwin, windows, android, ios, etc.) +- `arch`: CPU architecture (amd64, arm64, etc.) + +### Login Duration + +Measurement: `netbird_login` + +| Field | Description | +|-------|-------------| +| `duration_seconds` | Time to complete the login/auth exchange with management server | + +Tags: +- `deployment_type`: "cloud" | "selfhosted" | "unknown" +- `result`: "success" | "failure" +- `version`: NetBird version string +- `os`: Operating system (linux, darwin, windows, android, ios, etc.) +- `arch`: CPU architecture (amd64, arm64, etc.) + +## Buffer Limits + +The InfluxDB backend limits in-memory sample storage to prevent unbounded growth when pushes fail: +- **Max age:** Samples older than 5 days are dropped +- **Max size:** Estimated buffer size capped at 5 MB (~20k samples) + +## Configuration + +### Client Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NB_METRICS_PUSH_ENABLED` | `false` | Enable metrics push to backend | +| `NB_METRICS_SERVER_URL` | *(from remote config)* | Ingest server URL (e.g., `https://ingest.netbird.io`) | +| `NB_METRICS_INTERVAL` | *(from remote config)* | Push interval (e.g., "1m", "30m", "4h") | +| `NB_METRICS_FORCE_SENDING` | `false` | Skip remote config, push unconditionally | +| `NB_METRICS_CONFIG_URL` | `https://ingest.netbird.io/config` | Remote push config URL | + +`NB_METRICS_SERVER_URL` and `NB_METRICS_INTERVAL` override their respective values but do not bypass remote config eligibility checks (version range). Use `NB_METRICS_FORCE_SENDING=true` to skip all remote config gating. + +### Ingest Server Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `INGEST_LISTEN_ADDR` | `:8087` | Listen address | +| `INFLUXDB_URL` | `http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns` | InfluxDB write endpoint | +| `INFLUXDB_TOKEN` | *(required)* | InfluxDB auth token (server-side only) | +| `CONFIG_METRICS_SERVER_URL` | *(empty — disables /config)* | `server_url` in the remote config JSON (the URL clients push metrics to) | +| `CONFIG_VERSION_SINCE` | `0.0.0` | Minimum client version to push metrics | +| `CONFIG_VERSION_UNTIL` | `99.99.99` | Maximum client version to push metrics | +| `CONFIG_PERIOD_MINUTES` | `5` | Push interval in minutes | + +The ingest server serves a remote config JSON at `GET /config` when `CONFIG_METRICS_SERVER_URL` is set. Clients can use `NB_METRICS_CONFIG_URL=http:///config` to fetch it. + +### Configuration Precedence + +For URL and Interval, the precedence is: +1. **Environment variable** - `NB_METRICS_SERVER_URL` / `NB_METRICS_INTERVAL` +2. **Remote config** - fetched from `NB_METRICS_CONFIG_URL` +3. **Default** - 5 minute interval, URL from remote config + +## Push Behavior + +1. `StartPush()` spawns background goroutine with timer +2. First push happens immediately on startup +3. Periodically: `push()` → `Export()` → HTTP POST to ingest server +4. On failure: log error, continue (non-blocking) +5. On success: `Reset()` clears pushed samples +6. `StopPush()` cancels context and waits for goroutine + +Samples are collected with exact timestamps, pushed once, then cleared. No data is resent. + +## Local Development Setup + +### 1. Configure and Start Services + +```bash +# From this directory (client/internal/metrics/infra) +cp .env.example .env +# Edit .env to set INFLUXDB_ADMIN_PASSWORD, INFLUXDB_ADMIN_TOKEN, and GRAFANA_ADMIN_PASSWORD +docker compose up -d +``` + +This starts: +- **Ingest server** on http://localhost:8087 — accepts client metrics (requires `X-Peer-ID` header, no secret/token auth) +- **InfluxDB** — internal only, not exposed to host +- **Grafana** on http://localhost:3001 + +### 2. Configure Client + +```bash +export NB_METRICS_PUSH_ENABLED=true +export NB_METRICS_FORCE_SENDING=true +export NB_METRICS_SERVER_URL=http://localhost:8087 +export NB_METRICS_INTERVAL=1m +``` + +### 3. Run Client + +```bash +cd ../../../.. +go run ./client/ up +``` + +### 4. View in Grafana + +- **InfluxDB dashboard:** http://localhost:3001/d/netbird-influxdb-metrics + +### 5. Verify Data + +```bash +# Query via InfluxDB (using admin token from .env) +docker compose exec influxdb influx query \ + 'from(bucket: "metrics") |> range(start: -1h)' \ + --org netbird + +# Check ingest server health +curl http://localhost:8087/health +``` \ No newline at end of file diff --git a/client/internal/metrics/infra/docker-compose.yml b/client/internal/metrics/infra/docker-compose.yml new file mode 100644 index 000000000..0f2b6b889 --- /dev/null +++ b/client/internal/metrics/infra/docker-compose.yml @@ -0,0 +1,69 @@ +version: '3.8' + +services: + ingest: + container_name: ingest + build: + context: ./ingest + ports: + - "8087:8087" + environment: + - INGEST_LISTEN_ADDR=:8087 + - INFLUXDB_URL=http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns + - INFLUXDB_TOKEN=${INFLUXDB_ADMIN_TOKEN:?required} + - CONFIG_METRICS_SERVER_URL=${CONFIG_METRICS_SERVER_URL:-} + - CONFIG_VERSION_SINCE=${CONFIG_VERSION_SINCE:-0.0.0} + - CONFIG_VERSION_UNTIL=${CONFIG_VERSION_UNTIL:-99.99.99} + - CONFIG_PERIOD_MINUTES=${CONFIG_PERIOD_MINUTES:-5} + depends_on: + - influxdb + restart: unless-stopped + networks: + - metrics + + influxdb: + container_name: influxdb + image: influxdb:2 + # No ports exposed — only accessible within the metrics network + volumes: + - influxdb-data:/var/lib/influxdb2 + - ./influxdb/scripts:/docker-entrypoint-initdb.d + environment: + - DOCKER_INFLUXDB_INIT_MODE=setup + - DOCKER_INFLUXDB_INIT_USERNAME=admin + - DOCKER_INFLUXDB_INIT_PASSWORD=${INFLUXDB_ADMIN_PASSWORD:?required} + - DOCKER_INFLUXDB_INIT_ORG=netbird + - DOCKER_INFLUXDB_INIT_BUCKET=metrics + - DOCKER_INFLUXDB_INIT_RETENTION=365d + - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${INFLUXDB_ADMIN_TOKEN:-} + restart: unless-stopped + networks: + - metrics + + grafana: + container_name: grafana + image: grafana/grafana:11.6.0 + ports: + - "3001:3000" + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?required} + - GF_USERS_ALLOW_SIGN_UP=false + - GF_INSTALL_PLUGINS= + - INFLUXDB_ADMIN_TOKEN=${INFLUXDB_ADMIN_TOKEN:-} + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + depends_on: + - influxdb + restart: unless-stopped + networks: + - metrics + +volumes: + influxdb-data: + grafana-data: + +networks: + metrics: + driver: bridge diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/dashboard.yml b/client/internal/metrics/infra/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 000000000..a7e8d3989 --- /dev/null +++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'NetBird Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards/json \ No newline at end of file diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-influxdb-metrics.json b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-influxdb-metrics.json new file mode 100644 index 000000000..2bcc9cbab --- /dev/null +++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-influxdb-metrics.json @@ -0,0 +1,280 @@ +{ + "uid": "netbird-influxdb-metrics", + "title": "NetBird Client Metrics (InfluxDB)", + "tags": ["netbird", "connections", "influxdb"], + "timezone": "browser", + "panels": [ + { + "id": 5, + "title": "Sync Duration Extremes", + "type": "stat", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> min()\n |> set(key: \"_field\", value: \"Min\")", + "refId": "A" + }, + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> max()\n |> set(key: \"_field\", value: \"Max\")", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0 + } + }, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "auto" + } + }, + { + "id": 6, + "title": "Total Connection Time Extremes", + "type": "stat", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> min()\n |> set(key: \"_field\", value: \"Min\")", + "refId": "A" + }, + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> max()\n |> set(key: \"_field\", value: \"Max\")", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0 + } + }, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "auto" + } + }, + { + "id": 1, + "title": "Sync Duration", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> set(key: \"_field\", value: \"Sync Duration\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0, + "custom": { + "drawStyle": "points", + "pointSize": 5 + } + } + } + }, + { + "id": 4, + "title": "ICE vs Relay", + "type": "piechart", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> drop(columns: [\"deployment_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> group(columns: [\"connection_pair_id\"])\n |> last()\n |> group(columns: [\"connection_type\"])\n |> count()", + "refId": "A" + } + ], + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "pieType": "donut", + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 2, + "title": "Connection Stage Durations (avg)", + "type": "bargauge", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"signaling_to_connection_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> mean()\n |> drop(columns: [\"_start\", \"_stop\", \"_measurement\", \"_time\", \"_field\"])\n |> rename(columns: {_value: \"Avg Signaling to Connection\"})", + "refId": "A" + }, + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"connection_to_wg_handshake_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> mean()\n |> drop(columns: [\"_start\", \"_stop\", \"_measurement\", \"_time\", \"_field\"])\n |> rename(columns: {_value: \"Avg Connection to WG Handshake\"})", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0 + } + }, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 3, + "title": "Total Connection Time", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> set(key: \"_field\", value: \"Total Connection Time\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0, + "custom": { + "drawStyle": "points", + "pointSize": 5 + } + } + } + }, + { + "id": 7, + "title": "Login Duration", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_login\" and r._field == \"duration_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> set(key: \"_field\", value: \"Login Duration\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0, + "custom": { + "drawStyle": "points", + "pointSize": 5 + } + } + } + }, + { + "id": 8, + "title": "Login Success vs Failure", + "type": "piechart", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_login\" and r._field == \"duration_seconds\")\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> group(columns: [\"result\"])\n |> count()", + "refId": "A" + } + ], + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "pieType": "donut", + "tooltip": { + "mode": "multi" + } + } + } + ], + "schemaVersion": 27, + "version": 2, + "refresh": "30s" +} diff --git a/client/internal/metrics/infra/grafana/provisioning/datasources/influxdb.yml b/client/internal/metrics/infra/grafana/provisioning/datasources/influxdb.yml new file mode 100644 index 000000000..69b96a93a --- /dev/null +++ b/client/internal/metrics/infra/grafana/provisioning/datasources/influxdb.yml @@ -0,0 +1,15 @@ +apiVersion: 1 + +datasources: + - name: InfluxDB + uid: influxdb + type: influxdb + access: proxy + url: http://influxdb:8086 + editable: true + jsonData: + version: Flux + organization: netbird + defaultBucket: metrics + secureJsonData: + token: ${INFLUXDB_ADMIN_TOKEN} \ No newline at end of file diff --git a/client/internal/metrics/infra/influxdb/scripts/create-tokens.sh b/client/internal/metrics/infra/influxdb/scripts/create-tokens.sh new file mode 100755 index 000000000..2464803e8 --- /dev/null +++ b/client/internal/metrics/infra/influxdb/scripts/create-tokens.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Creates a scoped InfluxDB read-only token for Grafana. +# Clients do not need a token — they push via the ingest server. + +BUCKET_ID=$(influx bucket list --org netbird --name metrics --json | grep -oP '"id"\s*:\s*"\K[^"]+' | head -1) +ORG_ID=$(influx org list --name netbird --json | grep -oP '"id"\s*:\s*"\K[^"]+' | head -1) + +if [[ -z "$BUCKET_ID" ]] || [[ -z "$ORG_ID" ]]; then + echo "ERROR: Could not determine bucket or org ID" >&2 + echo "BUCKET_ID=$BUCKET_ID ORG_ID=$ORG_ID" >&2 + exit 1 +fi + +# Create read-only token for Grafana +READ_TOKEN=$(influx auth create \ + --org netbird \ + --read-bucket "$BUCKET_ID" \ + --description "Grafana read-only token" \ + --json | grep -oP '"token"\s*:\s*"\K[^"]+' | head -1) + +echo "" +echo "============================================" +echo "GRAFANA READ-ONLY TOKEN:" +echo "$READ_TOKEN" +echo "============================================" \ No newline at end of file diff --git a/client/internal/metrics/infra/ingest/Dockerfile b/client/internal/metrics/infra/ingest/Dockerfile new file mode 100644 index 000000000..3620c524b --- /dev/null +++ b/client/internal/metrics/infra/ingest/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.25-alpine AS build +WORKDIR /app +COPY go.mod main.go ./ +RUN CGO_ENABLED=0 go build -o ingest . + +FROM alpine:3.20 +RUN adduser -D -H ingest +COPY --from=build /app/ingest /usr/local/bin/ingest +USER ingest +ENTRYPOINT ["ingest"] \ No newline at end of file diff --git a/client/internal/metrics/infra/ingest/go.mod b/client/internal/metrics/infra/ingest/go.mod new file mode 100644 index 000000000..aaf1ea9da --- /dev/null +++ b/client/internal/metrics/infra/ingest/go.mod @@ -0,0 +1,11 @@ +module github.com/netbirdio/netbird/client/internal/metrics/infra/ingest + +go 1.25 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/client/internal/metrics/infra/ingest/go.sum b/client/internal/metrics/infra/ingest/go.sum new file mode 100644 index 000000000..c4c1710c4 --- /dev/null +++ b/client/internal/metrics/infra/ingest/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go new file mode 100644 index 000000000..a5031a873 --- /dev/null +++ b/client/internal/metrics/infra/ingest/main.go @@ -0,0 +1,355 @@ +package main + +import ( + "bytes" + "compress/gzip" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +const ( + defaultListenAddr = ":8087" + defaultInfluxDBURL = "http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns" + maxBodySize = 50 * 1024 * 1024 // 50 MB max request body + maxDurationSeconds = 300.0 // reject any duration field > 5 minutes + peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars + maxTagValueLength = 64 // reject tag values longer than this +) + +type measurementSpec struct { + allowedFields map[string]bool + allowedTags map[string]bool +} + +var allowedMeasurements = map[string]measurementSpec{ + "netbird_peer_connection": { + allowedFields: map[string]bool{ + "signaling_to_connection_seconds": true, + "connection_to_wg_handshake_seconds": true, + "total_seconds": true, + }, + allowedTags: map[string]bool{ + "deployment_type": true, + "connection_type": true, + "attempt_type": true, + "version": true, + "os": true, + "arch": true, + "peer_id": true, + "connection_pair_id": true, + }, + }, + "netbird_sync": { + allowedFields: map[string]bool{ + "duration_seconds": true, + }, + allowedTags: map[string]bool{ + "deployment_type": true, + "version": true, + "os": true, + "arch": true, + "peer_id": true, + }, + }, + "netbird_login": { + allowedFields: map[string]bool{ + "duration_seconds": true, + }, + allowedTags: map[string]bool{ + "deployment_type": true, + "result": true, + "version": true, + "os": true, + "arch": true, + "peer_id": true, + }, + }, +} + +func main() { + listenAddr := envOr("INGEST_LISTEN_ADDR", defaultListenAddr) + influxURL := envOr("INFLUXDB_URL", defaultInfluxDBURL) + influxToken := os.Getenv("INFLUXDB_TOKEN") + + if influxToken == "" { + log.Fatal("INFLUXDB_TOKEN is required") + } + + client := &http.Client{Timeout: 10 * time.Second} + + http.HandleFunc("/", handleIngest(client, influxURL, influxToken)) + + // Build config JSON once at startup from env vars + configJSON := buildConfigJSON() + if configJSON != nil { + log.Printf("serving remote config at /config") + } + + http.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if configJSON == nil { + http.Error(w, "config not configured", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(configJSON) //nolint:errcheck + }) + + http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") //nolint:errcheck + }) + + log.Printf("ingest server listening on %s, forwarding to %s", listenAddr, influxURL) + if err := http.ListenAndServe(listenAddr, nil); err != nil { //nolint:gosec + log.Fatal(err) + } +} + +func handleIngest(client *http.Client, influxURL, influxToken string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := validateAuth(r); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + body, err := readBody(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(body) > maxBodySize { + http.Error(w, "body too large", http.StatusRequestEntityTooLarge) + return + } + + validated, err := validateLineProtocol(body) + if err != nil { + log.Printf("WARN validation failed from %s: %v", r.RemoteAddr, err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + forwardToInflux(w, r, client, influxURL, influxToken, validated) + } +} + +func forwardToInflux(w http.ResponseWriter, r *http.Request, client *http.Client, influxURL, influxToken string, body []byte) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, influxURL, bytes.NewReader(body)) + if err != nil { + log.Printf("ERROR create request: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + req.Header.Set("Authorization", "Token "+influxToken) + + resp, err := client.Do(req) + if err != nil { + log.Printf("ERROR forward to influxdb: %v", err) + http.Error(w, "upstream error", http.StatusBadGateway) + return + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) //nolint:errcheck +} + +// validateAuth checks that the X-Peer-ID header contains a valid hashed peer ID. +func validateAuth(r *http.Request) error { + peerID := r.Header.Get("X-Peer-ID") + if peerID == "" { + return fmt.Errorf("missing X-Peer-ID header") + } + if len(peerID) != peerIDLength { + return fmt.Errorf("invalid X-Peer-ID header length") + } + if _, err := hex.DecodeString(peerID); err != nil { + return fmt.Errorf("invalid X-Peer-ID header format") + } + return nil +} + +// readBody reads the request body, decompressing gzip if Content-Encoding indicates it. +func readBody(r *http.Request) ([]byte, error) { + reader := io.LimitReader(r.Body, maxBodySize+1) + + if r.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(reader) + if err != nil { + return nil, fmt.Errorf("invalid gzip: %w", err) + } + defer gz.Close() + reader = io.LimitReader(gz, maxBodySize+1) + } + + return io.ReadAll(reader) +} + +// validateLineProtocol parses InfluxDB line protocol lines, +// whitelists measurements and fields, and checks value bounds. +func validateLineProtocol(body []byte) ([]byte, error) { + lines := strings.Split(strings.TrimSpace(string(body)), "\n") + var valid []string + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + if err := validateLine(line); err != nil { + return nil, err + } + + valid = append(valid, line) + } + + if len(valid) == 0 { + return nil, fmt.Errorf("no valid lines") + } + + return []byte(strings.Join(valid, "\n") + "\n"), nil +} + +func validateLine(line string) error { + // line protocol: measurement,tag=val,tag=val field=val,field=val timestamp + parts := strings.SplitN(line, " ", 3) + if len(parts) < 2 { + return fmt.Errorf("invalid line protocol: %q", truncate(line, 100)) + } + + // parts[0] is "measurement,tag=val,tag=val" + measurementAndTags := strings.Split(parts[0], ",") + measurement := measurementAndTags[0] + + spec, ok := allowedMeasurements[measurement] + if !ok { + return fmt.Errorf("unknown measurement: %q", measurement) + } + + // Validate tags (everything after measurement name in parts[0]) + for _, tagPair := range measurementAndTags[1:] { + if err := validateTag(tagPair, measurement, spec.allowedTags); err != nil { + return err + } + } + + // Validate fields + for _, pair := range strings.Split(parts[1], ",") { + if err := validateField(pair, measurement, spec.allowedFields); err != nil { + return err + } + } + + return nil +} + +func validateTag(pair, measurement string, allowedTags map[string]bool) error { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid tag: %q", pair) + } + + tagName := kv[0] + if !allowedTags[tagName] { + return fmt.Errorf("unknown tag %q in measurement %q", tagName, measurement) + } + + if len(kv[1]) > maxTagValueLength { + return fmt.Errorf("tag value too long for %q: %d > %d", tagName, len(kv[1]), maxTagValueLength) + } + + return nil +} + +func validateField(pair, measurement string, allowedFields map[string]bool) error { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid field: %q", pair) + } + + fieldName := kv[0] + if !allowedFields[fieldName] { + return fmt.Errorf("unknown field %q in measurement %q", fieldName, measurement) + } + + val, err := strconv.ParseFloat(kv[1], 64) + if err != nil { + return fmt.Errorf("invalid field value %q for %q", kv[1], fieldName) + } + if val < 0 { + return fmt.Errorf("negative value for %q: %g", fieldName, val) + } + if strings.HasSuffix(fieldName, "_seconds") && val > maxDurationSeconds { + return fmt.Errorf("%q too large: %g > %g", fieldName, val, maxDurationSeconds) + } + + return nil +} + +// buildConfigJSON builds the remote config JSON from env vars. +// Returns nil if required vars are not set. +func buildConfigJSON() []byte { + serverURL := os.Getenv("CONFIG_METRICS_SERVER_URL") + versionSince := envOr("CONFIG_VERSION_SINCE", "0.0.0") + versionUntil := envOr("CONFIG_VERSION_UNTIL", "99.99.99") + periodMinutes := envOr("CONFIG_PERIOD_MINUTES", "5") + + if serverURL == "" { + return nil + } + + period, err := strconv.Atoi(periodMinutes) + if err != nil || period <= 0 { + log.Printf("WARN invalid CONFIG_PERIOD_MINUTES: %q, using 5", periodMinutes) + period = 5 + } + + cfg := map[string]any{ + "server_url": serverURL, + "version-since": versionSince, + "version-until": versionUntil, + "period_minutes": period, + } + + data, err := json.Marshal(cfg) + if err != nil { + log.Printf("ERROR failed to marshal config: %v", err) + return nil + } + return data +} + +func envOr(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go new file mode 100644 index 000000000..bacaa4588 --- /dev/null +++ b/client/internal/metrics/infra/ingest/main_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateLine_ValidPeerConnection(t *testing.T) { + line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abcdef0123456789,connection_pair_id=pair1234 signaling_to_connection_seconds=1.5,connection_to_wg_handshake_seconds=0.5,total_seconds=2 1234567890` + assert.NoError(t, validateLine(line)) +} + +func TestValidateLine_ValidSync(t *testing.T) { + line := `netbird_sync,deployment_type=selfhosted,version=2.0.0,os=darwin,arch=arm64,peer_id=abcdef0123456789 duration_seconds=1.5 1234567890` + assert.NoError(t, validateLine(line)) +} + +func TestValidateLine_ValidLogin(t *testing.T) { + line := `netbird_login,deployment_type=cloud,result=success,version=1.0.0,os=linux,arch=amd64,peer_id=abcdef0123456789 duration_seconds=3.2 1234567890` + assert.NoError(t, validateLine(line)) +} + +func TestValidateLine_UnknownMeasurement(t *testing.T) { + line := `unknown_metric,foo=bar value=1 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown measurement") +} + +func TestValidateLine_UnknownTag(t *testing.T) { + line := `netbird_sync,deployment_type=cloud,evil_tag=injected,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown tag") +} + +func TestValidateLine_UnknownField(t *testing.T) { + line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc injected_field=1 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown field") +} + +func TestValidateLine_NegativeValue(t *testing.T) { + line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=-1.5 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "negative") +} + +func TestValidateLine_DurationTooLarge(t *testing.T) { + line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=999 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "too large") +} + +func TestValidateLine_TotalSecondsTooLarge(t *testing.T) { + line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=500 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "too large") +} + +func TestValidateLine_TagValueTooLong(t *testing.T) { + longTag := strings.Repeat("a", maxTagValueLength+1) + line := `netbird_sync,deployment_type=` + longTag + `,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "tag value too long") +} + +func TestValidateLineProtocol_MultipleLines(t *testing.T) { + body := []byte( + "netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890\n" + + "netbird_login,deployment_type=cloud,result=success,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=2.0 1234567890\n", + ) + validated, err := validateLineProtocol(body) + require.NoError(t, err) + assert.Contains(t, string(validated), "netbird_sync") + assert.Contains(t, string(validated), "netbird_login") +} + +func TestValidateLineProtocol_RejectsOnBadLine(t *testing.T) { + body := []byte( + "netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890\n" + + "evil_metric,foo=bar value=1 1234567890\n", + ) + _, err := validateLineProtocol(body) + require.Error(t, err) +} + +func TestValidateAuth(t *testing.T) { + tests := []struct { + name string + peerID string + wantErr bool + }{ + {"valid hex", "abcdef0123456789", false}, + {"empty", "", true}, + {"too short", "abcdef01234567", true}, + {"too long", "abcdef01234567890", true}, + {"invalid hex", "ghijklmnopqrstuv", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, _ := http.NewRequest(http.MethodPost, "/", nil) + if tt.peerID != "" { + r.Header.Set("X-Peer-ID", tt.peerID) + } + err := validateAuth(r) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go new file mode 100644 index 000000000..4ebb43496 --- /dev/null +++ b/client/internal/metrics/metrics.go @@ -0,0 +1,224 @@ +package metrics + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/metrics/remoteconfig" +) + +// AgentInfo holds static information about the agent +type AgentInfo struct { + DeploymentType DeploymentType + Version string + OS string // runtime.GOOS (linux, darwin, windows, etc.) + Arch string // runtime.GOARCH (amd64, arm64, etc.) + peerID string // anonymised peer identifier (SHA-256 of WireGuard public key) +} + +// peerIDFromPublicKey returns a truncated SHA-256 hash (8 bytes / 16 hex chars) of the given WireGuard public key. +func peerIDFromPublicKey(pubKey string) string { + hash := sha256.Sum256([]byte(pubKey)) + return hex.EncodeToString(hash[:8]) +} + +// connectionPairID returns a deterministic identifier for a connection between two peers. +// It sorts the two peer IDs before hashing so the same pair always produces the same ID +// regardless of which side computes it. +func connectionPairID(peerID1, peerID2 string) string { + a, b := peerID1, peerID2 + if a > b { + a, b = b, a + } + hash := sha256.Sum256([]byte(a + b)) + return hex.EncodeToString(hash[:8]) +} + +// metricsImplementation defines the internal interface for metrics implementations +type metricsImplementation interface { + // RecordConnectionStages records connection stage metrics from timestamps + RecordConnectionStages( + ctx context.Context, + agentInfo AgentInfo, + connectionPairID string, + connectionType ConnectionType, + isReconnection bool, + timestamps ConnectionStageTimestamps, + ) + + // RecordSyncDuration records how long it took to process a sync message + RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) + + // RecordLoginDuration records how long the login to management took + RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) + + // Export exports metrics in InfluxDB line protocol format + Export(w io.Writer) error + + // Reset clears all collected metrics + Reset() +} + +type ClientMetrics struct { + impl metricsImplementation + + agentInfo AgentInfo + mu sync.RWMutex + + push *Push + pushMu sync.Mutex + wg sync.WaitGroup + pushCancel context.CancelFunc +} + +// ConnectionStageTimestamps holds timestamps for each connection stage +type ConnectionStageTimestamps struct { + SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection) + ConnectionReady time.Time + WgHandshakeSuccess time.Time +} + +// String returns a human-readable representation of the connection stage timestamps +func (c ConnectionStageTimestamps) String() string { + return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}", + c.SignalingReceived.Format(time.RFC3339Nano), + c.ConnectionReady.Format(time.RFC3339Nano), + c.WgHandshakeSuccess.Format(time.RFC3339Nano), + ) +} + +// RecordConnectionStages calculates stage durations from timestamps and records them. +// remotePubKey is the remote peer's WireGuard public key; it will be hashed for anonymisation. +func (c *ClientMetrics) RecordConnectionStages( + ctx context.Context, + remotePubKey string, + connectionType ConnectionType, + isReconnection bool, + timestamps ConnectionStageTimestamps, +) { + if c == nil { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + + remotePeerID := peerIDFromPublicKey(remotePubKey) + pairID := connectionPairID(agentInfo.peerID, remotePeerID) + c.impl.RecordConnectionStages(ctx, agentInfo, pairID, connectionType, isReconnection, timestamps) +} + +// RecordSyncDuration records the duration of sync message processing +func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Duration) { + if c == nil { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + + c.impl.RecordSyncDuration(ctx, agentInfo, duration) +} + +// RecordLoginDuration records how long the login to management server took +func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) { + if c == nil { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + + c.impl.RecordLoginDuration(ctx, agentInfo, duration, success) +} + +// UpdateAgentInfo updates the agent information (e.g., when switching profiles). +// publicKey is the WireGuard public key; it will be hashed for anonymisation. +func (c *ClientMetrics) UpdateAgentInfo(agentInfo AgentInfo, publicKey string) { + if c == nil { + return + } + + agentInfo.peerID = peerIDFromPublicKey(publicKey) + + c.mu.Lock() + c.agentInfo = agentInfo + c.mu.Unlock() + + c.pushMu.Lock() + push := c.push + c.pushMu.Unlock() + if push != nil { + push.SetPeerID(agentInfo.peerID) + } +} + +// Export exports metrics to the writer +func (c *ClientMetrics) Export(w io.Writer) error { + if c == nil { + return nil + } + + return c.impl.Export(w) +} + +// StartPush starts periodic pushing of metrics with the given configuration +// Precedence: PushConfig.ServerAddress > remote config server_url +func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { + if c == nil { + return + } + + c.pushMu.Lock() + defer c.pushMu.Unlock() + + if c.push != nil { + log.Warnf("metrics push already running") + return + } + + c.mu.RLock() + agentVersion := c.agentInfo.Version + peerID := c.agentInfo.peerID + c.mu.RUnlock() + + configManager := remoteconfig.NewManager(getMetricsConfigURL(), remoteconfig.DefaultMinRefreshInterval) + push, err := NewPush(c.impl, configManager, config, agentVersion) + if err != nil { + log.Errorf("failed to create metrics push: %v", err) + return + } + push.SetPeerID(peerID) + + ctx, cancel := context.WithCancel(ctx) + c.pushCancel = cancel + + c.wg.Add(1) + go func() { + defer c.wg.Done() + push.Start(ctx) + }() + c.push = push +} + +func (c *ClientMetrics) StopPush() { + if c == nil { + return + } + c.pushMu.Lock() + defer c.pushMu.Unlock() + if c.push == nil { + return + } + + c.pushCancel() + c.wg.Wait() + c.push = nil +} diff --git a/client/internal/metrics/metrics_default.go b/client/internal/metrics/metrics_default.go new file mode 100644 index 000000000..927ab51d1 --- /dev/null +++ b/client/internal/metrics/metrics_default.go @@ -0,0 +1,11 @@ +//go:build !js + +package metrics + +// NewClientMetrics creates a new ClientMetrics instance +func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics { + return &ClientMetrics{ + impl: newInfluxDBMetrics(), + agentInfo: agentInfo, + } +} diff --git a/client/internal/metrics/metrics_js.go b/client/internal/metrics/metrics_js.go new file mode 100644 index 000000000..dfa6d8243 --- /dev/null +++ b/client/internal/metrics/metrics_js.go @@ -0,0 +1,8 @@ +//go:build js + +package metrics + +// NewClientMetrics returns nil on WASM builds — all ClientMetrics methods are nil-safe. +func NewClientMetrics(AgentInfo) *ClientMetrics { + return nil +} diff --git a/client/internal/metrics/push.go b/client/internal/metrics/push.go new file mode 100644 index 000000000..ee0508f36 --- /dev/null +++ b/client/internal/metrics/push.go @@ -0,0 +1,289 @@ +package metrics + +import ( + "bytes" + "compress/gzip" + "context" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + goversion "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/metrics/remoteconfig" +) + +const ( + // defaultPushInterval is the default interval for pushing metrics + defaultPushInterval = 5 * time.Minute +) + +// defaultMetricsServerURL is used as fallback when NB_METRICS_FORCE_SENDING is true +var defaultMetricsServerURL *url.URL + +func init() { + defaultMetricsServerURL, _ = url.Parse("https://ingest.netbird.io") +} + +// PushConfig holds configuration for metrics push +type PushConfig struct { + // ServerAddress is the metrics server URL. If nil, uses remote config server_url. + ServerAddress *url.URL + // Interval is how often to push metrics. If 0, uses remote config interval or defaultPushInterval. + Interval time.Duration + // ForceSending skips remote configuration fetch and version checks, pushing unconditionally. + ForceSending bool +} + +// PushConfigFromEnv builds a PushConfig from environment variables. +func PushConfigFromEnv() PushConfig { + config := PushConfig{} + + config.ForceSending = isForceSending() + config.ServerAddress = getMetricsServerURL() + config.Interval = getMetricsInterval() + + return config +} + +// remoteConfigProvider abstracts remote push config fetching for testability +type remoteConfigProvider interface { + RefreshIfNeeded(ctx context.Context) *remoteconfig.Config +} + +// Push handles periodic pushing of metrics +type Push struct { + metrics metricsImplementation + configManager remoteConfigProvider + agentVersion *goversion.Version + + peerID string + peerMu sync.RWMutex + + client *http.Client + cfgForceSending bool + cfgInterval time.Duration + cfgAddress *url.URL +} + +// NewPush creates a new Push instance with configuration resolution +func NewPush(metrics metricsImplementation, configManager remoteConfigProvider, config PushConfig, agentVersion string) (*Push, error) { + var cfgInterval time.Duration + var cfgAddress *url.URL + + if config.ForceSending { + cfgInterval = config.Interval + if config.Interval <= 0 { + cfgInterval = defaultPushInterval + } + + cfgAddress = config.ServerAddress + if cfgAddress == nil { + cfgAddress = defaultMetricsServerURL + } + } else { + cfgAddress = config.ServerAddress + + if config.Interval < 0 { + log.Warnf("negative metrics push interval %s", config.Interval) + } else { + cfgInterval = config.Interval + } + } + + parsedVersion, err := goversion.NewVersion(agentVersion) + if err != nil { + if !config.ForceSending { + return nil, fmt.Errorf("parse agent version %q: %w", agentVersion, err) + } + } + + return &Push{ + metrics: metrics, + configManager: configManager, + agentVersion: parsedVersion, + cfgForceSending: config.ForceSending, + cfgInterval: cfgInterval, + cfgAddress: cfgAddress, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + }, nil +} + +// SetPeerID updates the hashed peer ID used for the Authorization header. +func (p *Push) SetPeerID(peerID string) { + p.peerMu.Lock() + p.peerID = peerID + p.peerMu.Unlock() +} + +// Start starts the periodic push loop. +// The env interval override controls tick frequency but does not bypass remote config +// version gating. Use ForceSending to skip remote config entirely. +func (p *Push) Start(ctx context.Context) { + // Log initial state + switch { + case p.cfgForceSending: + log.Infof("started metrics push with force sending to %s, interval %s", p.cfgAddress, p.cfgInterval) + case p.cfgAddress != nil: + log.Infof("started metrics push with server URL override: %s", p.cfgAddress.String()) + default: + log.Infof("started metrics push, server URL will be resolved from remote config") + } + + timer := time.NewTimer(0) // fire immediately on first iteration + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + log.Debug("stopping metrics push") + return + case <-timer.C: + } + + pushURL, interval := p.resolve(ctx) + if pushURL != "" { + if err := p.push(ctx, pushURL); err != nil { + log.Errorf("failed to push metrics: %v", err) + } + } + + if interval <= 0 { + interval = defaultPushInterval + } + timer.Reset(interval) + } +} + +// resolve returns the push URL and interval for the next cycle. +// Returns empty pushURL to skip this cycle. +func (p *Push) resolve(ctx context.Context) (pushURL string, interval time.Duration) { + if p.cfgForceSending { + return p.resolveServerURL(nil), p.cfgInterval + } + + config := p.configManager.RefreshIfNeeded(ctx) + if config == nil { + log.Debug("no metrics push config available, waiting to retry") + return "", defaultPushInterval + } + + // prefer env variables instead of remote config + if p.cfgInterval > 0 { + interval = p.cfgInterval + } else { + interval = config.Interval + } + + if !isVersionInRange(p.agentVersion, config.VersionSince, config.VersionUntil) { + log.Debugf("agent version %s not in range [%s, %s), skipping metrics push", + p.agentVersion, config.VersionSince, config.VersionUntil) + return "", interval + } + + pushURL = p.resolveServerURL(&config.ServerURL) + if pushURL == "" { + log.Warn("no metrics server URL available, skipping push") + } + return pushURL, interval +} + +// push exports metrics and sends them to the metrics server +func (p *Push) push(ctx context.Context, pushURL string) error { + // Export metrics without clearing + var buf bytes.Buffer + if err := p.metrics.Export(&buf); err != nil { + return fmt.Errorf("export metrics: %w", err) + } + + // Don't push if there are no metrics + if buf.Len() == 0 { + log.Tracef("no metrics to push") + return nil + } + + // Gzip compress the body + compressed, err := gzipCompress(buf.Bytes()) + if err != nil { + return fmt.Errorf("gzip compress: %w", err) + } + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, "POST", pushURL, compressed) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + req.Header.Set("Content-Encoding", "gzip") + + p.peerMu.RLock() + peerID := p.peerID + p.peerMu.RUnlock() + if peerID != "" { + req.Header.Set("X-Peer-ID", peerID) + } + + // Send request + resp, err := p.client.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + defer func() { + if resp.Body == nil { + return + } + if err := resp.Body.Close(); err != nil { + log.Warnf("failed to close response body: %v", err) + } + }() + + // Check response status + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("push failed with status %d", resp.StatusCode) + } + + log.Debugf("successfully pushed metrics to %s", pushURL) + p.metrics.Reset() + return nil +} + +// resolveServerURL determines the push URL. +// Precedence: envAddress (env var) > remote config server_url +func (p *Push) resolveServerURL(remoteServerURL *url.URL) string { + var baseURL *url.URL + if p.cfgAddress != nil { + baseURL = p.cfgAddress + } else { + baseURL = remoteServerURL + } + + if baseURL == nil { + return "" + } + + return baseURL.String() +} + +// gzipCompress compresses data using gzip and returns the compressed buffer. +func gzipCompress(data []byte) (*bytes.Buffer, error) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(data); err != nil { + _ = gz.Close() + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + return &buf, nil +} + +// isVersionInRange checks if current falls within [since, until) +func isVersionInRange(current, since, until *goversion.Version) bool { + return !current.LessThan(since) && current.LessThan(until) +} diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go new file mode 100644 index 000000000..20a509da1 --- /dev/null +++ b/client/internal/metrics/push_test.go @@ -0,0 +1,343 @@ +package metrics + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + goversion "github.com/hashicorp/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/metrics/remoteconfig" +) + +func mustVersion(s string) *goversion.Version { + v, err := goversion.NewVersion(s) + if err != nil { + panic(err) + } + return v +} + +func mustURL(s string) url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return *u +} + +func parseURL(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return u +} + +func testConfig(serverURL, since, until string, period time.Duration) *remoteconfig.Config { + return &remoteconfig.Config{ + ServerURL: mustURL(serverURL), + VersionSince: mustVersion(since), + VersionUntil: mustVersion(until), + Interval: period, + } +} + +// mockConfigProvider implements remoteConfigProvider for testing +type mockConfigProvider struct { + config *remoteconfig.Config +} + +func (m *mockConfigProvider) RefreshIfNeeded(_ context.Context) *remoteconfig.Config { + return m.config +} + +// mockMetrics implements metricsImplementation for testing +type mockMetrics struct { + exportData string +} + +func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ string, _ ConnectionType, _ bool, _ ConnectionStageTimestamps) { +} + +func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.Duration) { +} + +func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) { +} + +func (m *mockMetrics) Export(w io.Writer) error { + if m.exportData != "" { + _, err := w.Write([]byte(m.exportData)) + return err + } + return nil +} + +func (m *mockMetrics) Reset() { +} + +func TestPush_OverrideIntervalPushes(t *testing.T) { + var pushCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pushCount.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 60*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 50 * time.Millisecond, + ServerAddress: parseURL(server.URL), + }, "1.0.0") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + push.Start(ctx) + close(done) + }() + + require.Eventually(t, func() bool { + return pushCount.Load() >= 3 + }, 2*time.Second, 10*time.Millisecond) + + cancel() + <-done +} + +func TestPush_RemoteConfigVersionInRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 1*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "1.5.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.NotEmpty(t, pushURL) + assert.Equal(t, 1*time.Minute, interval) +} + +func TestPush_RemoteConfigVersionOutOfRange(t *testing.T) { + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig("http://localhost", "1.0.0", "1.5.0", 1*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "2.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Empty(t, pushURL) + assert.Equal(t, 1*time.Minute, interval) +} + +func TestPush_NoConfigReturnsDefault(t *testing.T) { + metrics := &mockMetrics{} + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Empty(t, pushURL) + assert.Equal(t, defaultPushInterval, interval) +} + +func TestPush_OverrideIntervalRespectsVersionCheck(t *testing.T) { + metrics := &mockMetrics{} + configProvider := &mockConfigProvider{config: testConfig("http://localhost", "3.0.0", "4.0.0", 60*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 30 * time.Second, + ServerAddress: parseURL("http://localhost"), + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Empty(t, pushURL) // version out of range + assert.Equal(t, 30*time.Second, interval) // but uses override interval +} + +func TestPush_OverrideIntervalUsedWhenVersionInRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 60*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 30 * time.Second, + }, "1.5.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.NotEmpty(t, pushURL) + assert.Equal(t, 30*time.Second, interval) +} + +func TestPush_NoMetricsSkipsPush(t *testing.T) { + var pushCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pushCount.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: ""} // no metrics to export + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "1.0.0") + require.NoError(t, err) + + err = push.push(context.Background(), server.URL) + assert.NoError(t, err) + assert.Equal(t, int32(0), pushCount.Load()) +} + +func TestPush_ServerURLFromRemoteConfig(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 1*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "1.5.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Contains(t, pushURL, server.URL) + assert.Equal(t, 1*time.Minute, interval) +} + +func TestPush_ServerAddressOverridesTakePrecedenceOverRemoteConfig(t *testing.T) { + overrideServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer overrideServer.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig("http://remote-config-server", "1.0.0", "2.0.0", 1*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + ServerAddress: parseURL(overrideServer.URL), + }, "1.5.0") + require.NoError(t, err) + + pushURL, _ := push.resolve(context.Background()) + assert.Contains(t, pushURL, overrideServer.URL) + assert.NotContains(t, pushURL, "remote-config-server") +} + +func TestPush_OverrideIntervalWithoutOverrideURL_UsesRemoteConfigURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 60*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 30 * time.Second, + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Contains(t, pushURL, server.URL) + assert.Equal(t, 30*time.Second, interval) +} + +func TestPush_NoConfigSkipsPush(t *testing.T) { + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 30 * time.Second, + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Empty(t, pushURL) + assert.Equal(t, defaultPushInterval, interval) // no config available, use default retry interval +} + +func TestPush_ForceSendingSkipsRemoteConfig(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{ + ForceSending: true, + Interval: 1 * time.Minute, + ServerAddress: parseURL(server.URL), + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.NotEmpty(t, pushURL) + assert.Equal(t, 1*time.Minute, interval) +} + +func TestPush_ForceSendingUsesDefaultInterval(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{ + ForceSending: true, + ServerAddress: parseURL(server.URL), + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.NotEmpty(t, pushURL) + assert.Equal(t, defaultPushInterval, interval) +} + +func TestIsVersionInRange(t *testing.T) { + tests := []struct { + name string + current string + since string + until string + expected bool + }{ + {"at lower bound inclusive", "1.2.2", "1.2.2", "1.2.3", true}, + {"in range", "1.2.2", "1.2.0", "1.3.0", true}, + {"at upper bound exclusive", "1.2.3", "1.2.2", "1.2.3", false}, + {"below range", "1.2.1", "1.2.2", "1.2.3", false}, + {"above range", "1.3.0", "1.2.2", "1.2.3", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, isVersionInRange(mustVersion(tt.current), mustVersion(tt.since), mustVersion(tt.until))) + }) + } +} diff --git a/client/internal/metrics/remoteconfig/manager.go b/client/internal/metrics/remoteconfig/manager.go new file mode 100644 index 000000000..01c37891f --- /dev/null +++ b/client/internal/metrics/remoteconfig/manager.go @@ -0,0 +1,149 @@ +package remoteconfig + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + goversion "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" +) + +const ( + DefaultMinRefreshInterval = 30 * time.Minute +) + +// Config holds the parsed remote push configuration +type Config struct { + ServerURL url.URL + VersionSince *goversion.Version + VersionUntil *goversion.Version + Interval time.Duration +} + +// rawConfig is the JSON wire format fetched from the remote server +type rawConfig struct { + ServerURL string `json:"server_url"` + VersionSince string `json:"version-since"` + VersionUntil string `json:"version-until"` + PeriodMinutes int `json:"period_minutes"` +} + +// Manager handles fetching and caching remote push configuration +type Manager struct { + configURL string + minRefreshInterval time.Duration + client *http.Client + + mu sync.Mutex + lastConfig *Config + lastFetched time.Time +} + +func NewManager(configURL string, minRefreshInterval time.Duration) *Manager { + return &Manager{ + configURL: configURL, + minRefreshInterval: minRefreshInterval, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// RefreshIfNeeded fetches new config if the cached one is stale. +// Returns the current config (possibly just fetched) or nil if unavailable. +func (m *Manager) RefreshIfNeeded(ctx context.Context) *Config { + m.mu.Lock() + defer m.mu.Unlock() + + if m.isConfigFresh() { + return m.lastConfig + } + + fetchedConfig, err := m.fetch(ctx) + m.lastFetched = time.Now() + if err != nil { + log.Warnf("failed to fetch metrics remote config: %v", err) + return m.lastConfig // return cached (may be nil) + } + + m.lastConfig = fetchedConfig + + log.Tracef("fetched metrics remote config: version-since=%s version-until=%s period=%s", + fetchedConfig.VersionSince, fetchedConfig.VersionUntil, fetchedConfig.Interval) + + return fetchedConfig +} + +func (m *Manager) isConfigFresh() bool { + if m.lastConfig == nil { + return false + } + return time.Since(m.lastFetched) < m.minRefreshInterval +} + +func (m *Manager) fetch(ctx context.Context) (*Config, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.configURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := m.client.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer func() { + if resp.Body != nil { + _ = resp.Body.Close() + } + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + var raw rawConfig + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + if raw.PeriodMinutes <= 0 { + return nil, fmt.Errorf("invalid period_minutes: %d", raw.PeriodMinutes) + } + + if raw.ServerURL == "" { + return nil, fmt.Errorf("server_url is required") + } + + serverURL, err := url.Parse(raw.ServerURL) + if err != nil { + return nil, fmt.Errorf("parse server_url %q: %w", raw.ServerURL, err) + } + + since, err := goversion.NewVersion(raw.VersionSince) + if err != nil { + return nil, fmt.Errorf("parse version-since %q: %w", raw.VersionSince, err) + } + + until, err := goversion.NewVersion(raw.VersionUntil) + if err != nil { + return nil, fmt.Errorf("parse version-until %q: %w", raw.VersionUntil, err) + } + + return &Config{ + ServerURL: *serverURL, + VersionSince: since, + VersionUntil: until, + Interval: time.Duration(raw.PeriodMinutes) * time.Minute, + }, nil +} diff --git a/client/internal/metrics/remoteconfig/manager_test.go b/client/internal/metrics/remoteconfig/manager_test.go new file mode 100644 index 000000000..68ca3b4c4 --- /dev/null +++ b/client/internal/metrics/remoteconfig/manager_test.go @@ -0,0 +1,197 @@ +package remoteconfig + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testMinRefresh = 100 * time.Millisecond + +func TestManager_FetchSuccess(t *testing.T) { + server := newConfigServer(t, rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + + require.NotNil(t, config) + assert.Equal(t, "https://ingest.example.com", config.ServerURL.String()) + assert.Equal(t, "1.0.0", config.VersionSince.String()) + assert.Equal(t, "2.0.0", config.VersionUntil.String()) + assert.Equal(t, 60*time.Minute, config.Interval) +} + +func TestManager_CachesConfig(t *testing.T) { + var fetchCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount.Add(1) + err := json.NewEncoder(w).Encode(rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + require.NoError(t, err) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + + // First call fetches + config1 := mgr.RefreshIfNeeded(context.Background()) + require.NotNil(t, config1) + assert.Equal(t, int32(1), fetchCount.Load()) + + // Second call uses cache (within minRefreshInterval) + config2 := mgr.RefreshIfNeeded(context.Background()) + require.NotNil(t, config2) + assert.Equal(t, int32(1), fetchCount.Load()) + assert.Equal(t, config1, config2) +} + +func TestManager_RefetchesWhenStale(t *testing.T) { + var fetchCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount.Add(1) + err := json.NewEncoder(w).Encode(rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + require.NoError(t, err) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + + // First fetch + mgr.RefreshIfNeeded(context.Background()) + assert.Equal(t, int32(1), fetchCount.Load()) + + // Wait for config to become stale + time.Sleep(testMinRefresh + 10*time.Millisecond) + + // Should refetch + mgr.RefreshIfNeeded(context.Background()) + assert.Equal(t, int32(2), fetchCount.Load()) +} + +func TestManager_FetchFailureReturnsNil(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + + assert.Nil(t, config) +} + +func TestManager_FetchFailureReturnsCached(t *testing.T) { + var fetchCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount.Add(1) + if fetchCount.Load() > 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } + err := json.NewEncoder(w).Encode(rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + require.NoError(t, err) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + + // First call succeeds + config1 := mgr.RefreshIfNeeded(context.Background()) + require.NotNil(t, config1) + + // Wait for config to become stale + time.Sleep(testMinRefresh + 10*time.Millisecond) + + // Second call fails but returns cached + config2 := mgr.RefreshIfNeeded(context.Background()) + require.NotNil(t, config2) + assert.Equal(t, config1, config2) +} + +func TestManager_RejectsInvalidPeriod(t *testing.T) { + tests := []struct { + name string + period int + }{ + {"zero", 0}, + {"negative", -5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newConfigServer(t, rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: tt.period, + }) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + assert.Nil(t, config) + }) + } +} + +func TestManager_RejectsEmptyServerURL(t *testing.T) { + server := newConfigServer(t, rawConfig{ + ServerURL: "", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + assert.Nil(t, config) +} + +func TestManager_RejectsInvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("not json")) + require.NoError(t, err) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + assert.Nil(t, config) +} + +func newConfigServer(t *testing.T, config rawConfig) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(config) + require.NoError(t, err) + })) +} diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index b4f97016d..bea0725f2 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/wgproxy" + "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/peer/conntype" "github.com/netbirdio/netbird/client/internal/peer/dispatcher" "github.com/netbirdio/netbird/client/internal/peer/guard" @@ -26,6 +27,17 @@ import ( relayClient "github.com/netbirdio/netbird/shared/relay/client" ) +// MetricsRecorder is an interface for recording peer connection metrics +type MetricsRecorder interface { + RecordConnectionStages( + ctx context.Context, + remotePubKey string, + connectionType metrics.ConnectionType, + isReconnection bool, + timestamps metrics.ConnectionStageTimestamps, + ) +} + type ServiceDependencies struct { StatusRecorder *Status Signaler *Signaler @@ -33,6 +45,7 @@ type ServiceDependencies struct { RelayManager *relayClient.Manager SrWatcher *guard.SRWatcher PeerConnDispatcher *dispatcher.ConnectionDispatcher + MetricsRecorder MetricsRecorder } type WgConfig struct { @@ -115,6 +128,10 @@ type Conn struct { dumpState *stateDump endpointUpdater *EndpointUpdater + + // Connection stage timestamps for metrics + metricsRecorder MetricsRecorder + metricsStages *MetricsStages } // NewConn creates a new not opened Conn to the remote peer. @@ -140,6 +157,7 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { dumpState: dumpState, endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)), wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState), + metricsRecorder: services.MetricsRecorder, } return conn, nil @@ -156,6 +174,9 @@ func (conn *Conn) Open(engineCtx context.Context) error { return nil } + // Allocate new metrics stages so old goroutines don't corrupt new state + conn.metricsStages = &MetricsStages{} + conn.ctx, conn.ctxCancel = context.WithCancel(engineCtx) conn.workerRelay = NewWorkerRelay(conn.ctx, conn.Log, isController(conn.config), conn.config, conn, conn.relayManager) @@ -167,7 +188,7 @@ func (conn *Conn) Open(engineCtx context.Context) error { } conn.workerICE = workerICE - conn.handshaker = NewHandshaker(conn.Log, conn.config, conn.signaler, conn.workerICE, conn.workerRelay) + conn.handshaker = NewHandshaker(conn.Log, conn.config, conn.signaler, conn.workerICE, conn.workerRelay, conn.metricsStages) conn.handshaker.AddRelayListener(conn.workerRelay.OnNewOffer) if !isForceRelayed() { @@ -335,7 +356,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn if conn.currentConnPriority > priority { conn.Log.Infof("current connection priority (%s) is higher than the new one (%s), do not upgrade connection", conn.currentConnPriority, priority) conn.statusICE.SetConnected() - conn.updateIceState(iceConnInfo) + conn.updateIceState(iceConnInfo, time.Now()) return } @@ -375,7 +396,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn } conn.Log.Infof("configure WireGuard endpoint to: %s", ep.String()) - conn.enableWgWatcherIfNeeded() + updateTime := time.Now() + conn.enableWgWatcherIfNeeded(updateTime) presharedKey := conn.presharedKey(iceConnInfo.RosenpassPubKey) if err = conn.endpointUpdater.ConfigureWGEndpoint(ep, presharedKey); err != nil { @@ -391,8 +413,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn conn.currentConnPriority = priority conn.statusICE.SetConnected() - conn.updateIceState(iceConnInfo) - conn.doOnConnected(iceConnInfo.RosenpassPubKey, iceConnInfo.RosenpassAddr) + conn.updateIceState(iceConnInfo, updateTime) + conn.doOnConnected(iceConnInfo.RosenpassPubKey, iceConnInfo.RosenpassAddr, updateTime) } func (conn *Conn) onICEStateDisconnected(sessionChanged bool) { @@ -444,6 +466,10 @@ func (conn *Conn) onICEStateDisconnected(sessionChanged bool) { conn.disableWgWatcherIfNeeded() + if conn.currentConnPriority == conntype.None { + conn.metricsStages.Disconnected() + } + peerState := State{ PubKey: conn.config.Key, ConnStatus: conn.evalStatus(), @@ -484,7 +510,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { conn.Log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String()) conn.setRelayedProxy(wgProxy) conn.statusRelay.SetConnected() - conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey) + conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, time.Now()) return } @@ -493,7 +519,8 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { if controller { wgProxy.Work() } - conn.enableWgWatcherIfNeeded() + updateTime := time.Now() + conn.enableWgWatcherIfNeeded(updateTime) if err := conn.endpointUpdater.ConfigureWGEndpoint(wgProxy.EndpointAddr(), conn.presharedKey(rci.rosenpassPubKey)); err != nil { if err := wgProxy.CloseConn(); err != nil { conn.Log.Warnf("Failed to close relay connection: %v", err) @@ -504,13 +531,16 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { if !controller { wgProxy.Work() } + + wgConfigWorkaround() + conn.rosenpassRemoteKey = rci.rosenpassPubKey conn.currentConnPriority = conntype.Relay conn.statusRelay.SetConnected() conn.setRelayedProxy(wgProxy) - conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey) + conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, updateTime) conn.Log.Infof("start to communicate with peer via relay") - conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr) + conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr, updateTime) } func (conn *Conn) onRelayDisconnected() { @@ -548,6 +578,10 @@ func (conn *Conn) handleRelayDisconnectedLocked() { conn.disableWgWatcherIfNeeded() + if conn.currentConnPriority == conntype.None { + conn.metricsStages.Disconnected() + } + peerState := State{ PubKey: conn.config.Key, ConnStatus: conn.evalStatus(), @@ -588,10 +622,10 @@ func (conn *Conn) onWGDisconnected() { } } -func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte) { +func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) { peerState := State{ PubKey: conn.config.Key, - ConnStatusUpdate: time.Now(), + ConnStatusUpdate: updateTime, ConnStatus: conn.evalStatus(), Relayed: conn.isRelayed(), RelayServerAddress: relayServerAddr, @@ -604,10 +638,10 @@ func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []by } } -func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo) { +func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo, updateTime time.Time) { peerState := State{ PubKey: conn.config.Key, - ConnStatusUpdate: time.Now(), + ConnStatusUpdate: updateTime, ConnStatus: conn.evalStatus(), Relayed: iceConnInfo.Relayed, LocalIceCandidateType: iceConnInfo.LocalIceCandidateType, @@ -645,11 +679,13 @@ func (conn *Conn) setStatusToDisconnected() { } } -func (conn *Conn) doOnConnected(remoteRosenpassPubKey []byte, remoteRosenpassAddr string) { +func (conn *Conn) doOnConnected(remoteRosenpassPubKey []byte, remoteRosenpassAddr string, updateTime time.Time) { if runtime.GOOS == "ios" { runtime.GC() } + conn.metricsStages.RecordConnectionReady(updateTime) + if conn.onConnected != nil { conn.onConnected(conn.config.Key, remoteRosenpassPubKey, conn.config.WgConfig.AllowedIps[0].Addr().String(), remoteRosenpassAddr) } @@ -701,14 +737,14 @@ func (conn *Conn) isConnectedOnAllWay() (connected bool) { return true } -func (conn *Conn) enableWgWatcherIfNeeded() { +func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) { if !conn.wgWatcher.IsEnabled() { wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx) conn.wgWatcherCancel = wgWatcherCancel conn.wgWatcherWg.Add(1) go func() { defer conn.wgWatcherWg.Done() - conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, conn.onWGDisconnected) + conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess) }() } } @@ -783,6 +819,41 @@ func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy) { conn.wgProxyRelay = proxy } +// onWGHandshakeSuccess is called when the first WireGuard handshake is detected +func (conn *Conn) onWGHandshakeSuccess(when time.Time) { + conn.metricsStages.RecordWGHandshakeSuccess(when) + conn.recordConnectionMetrics() +} + +// recordConnectionMetrics records connection stage timestamps as metrics +func (conn *Conn) recordConnectionMetrics() { + if conn.metricsRecorder == nil { + return + } + + // Determine connection type based on current priority + conn.mu.Lock() + priority := conn.currentConnPriority + conn.mu.Unlock() + + var connType metrics.ConnectionType + switch priority { + case conntype.Relay: + connType = metrics.ConnectionTypeRelay + default: + connType = metrics.ConnectionTypeICE + } + + // Record metrics with timestamps - duration calculation happens in metrics package + conn.metricsRecorder.RecordConnectionStages( + context.Background(), + conn.config.Key, + connType, + conn.metricsStages.IsReconnection(), + conn.metricsStages.GetTimestamps(), + ) +} + // AllowedIP returns the allowed IP of the remote peer func (conn *Conn) AllowedIP() netip.Addr { return conn.config.WgConfig.AllowedIps[0].Addr() diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index aff26f847..9b50cecd1 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -44,12 +44,13 @@ type OfferAnswer struct { } type Handshaker struct { - mu sync.Mutex - log *log.Entry - config ConnConfig - signaler *Signaler - ice *WorkerICE - relay *WorkerRelay + mu sync.Mutex + log *log.Entry + config ConnConfig + signaler *Signaler + ice *WorkerICE + relay *WorkerRelay + metricsStages *MetricsStages // relayListener is not blocking because the listener is using a goroutine to process the messages // and it will only keep the latest message if multiple offers are received in a short time // this is to avoid blocking the handshaker if the listener is doing some heavy processing @@ -64,13 +65,14 @@ type Handshaker struct { remoteAnswerCh chan OfferAnswer } -func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay) *Handshaker { +func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker { return &Handshaker{ log: log, config: config, signaler: signaler, ice: ice, relay: relay, + metricsStages: metricsStages, remoteOffersCh: make(chan OfferAnswer), remoteAnswerCh: make(chan OfferAnswer), } @@ -89,6 +91,12 @@ func (h *Handshaker) Listen(ctx context.Context) { select { case remoteOfferAnswer := <-h.remoteOffersCh: h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString()) + + // Record signaling received for reconnection attempts + if h.metricsStages != nil { + h.metricsStages.RecordSignalingReceived() + } + if h.relayListener != nil { h.relayListener.Notify(&remoteOfferAnswer) } @@ -103,6 +111,12 @@ func (h *Handshaker) Listen(ctx context.Context) { } case remoteOfferAnswer := <-h.remoteAnswerCh: h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString()) + + // Record signaling received for reconnection attempts + if h.metricsStages != nil { + h.metricsStages.RecordSignalingReceived() + } + if h.relayListener != nil { h.relayListener.Notify(&remoteOfferAnswer) } diff --git a/client/internal/peer/metrics_saver.go b/client/internal/peer/metrics_saver.go new file mode 100644 index 000000000..e32afbfe5 --- /dev/null +++ b/client/internal/peer/metrics_saver.go @@ -0,0 +1,73 @@ +package peer + +import ( + "sync" + "time" + + "github.com/netbirdio/netbird/client/internal/metrics" +) + +type MetricsStages struct { + isReconnectionAttempt bool // Track if current attempt is a reconnection + stageTimestamps metrics.ConnectionStageTimestamps + mu sync.Mutex +} + +// RecordSignalingReceived records when the first signal is received from the remote peer. +// Used as the base for all subsequent stage durations to avoid inflating metrics when +// the remote peer was offline. +func (s *MetricsStages) RecordSignalingReceived() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.stageTimestamps.SignalingReceived.IsZero() { + s.stageTimestamps.SignalingReceived = time.Now() + } +} + +func (s *MetricsStages) RecordConnectionReady(when time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + if s.stageTimestamps.ConnectionReady.IsZero() { + s.stageTimestamps.ConnectionReady = when + } +} + +func (s *MetricsStages) RecordWGHandshakeSuccess(handshakeTime time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.stageTimestamps.ConnectionReady.IsZero() && s.stageTimestamps.WgHandshakeSuccess.IsZero() { + // WireGuard only reports handshake times with second precision, but ConnectionReady + // is captured with microsecond precision. If handshake appears before ConnectionReady + // due to truncation (e.g., handshake at 6.042s truncated to 6.000s), normalize to + // ConnectionReady to avoid negative duration metrics. + if handshakeTime.Before(s.stageTimestamps.ConnectionReady) { + s.stageTimestamps.WgHandshakeSuccess = s.stageTimestamps.ConnectionReady + } else { + s.stageTimestamps.WgHandshakeSuccess = handshakeTime + } + } +} + +// Disconnected sets the mode to reconnection. It is called only when both ICE and Relay have been disconnected at the same time. +func (s *MetricsStages) Disconnected() { + s.mu.Lock() + defer s.mu.Unlock() + + // Reset all timestamps for reconnection + s.stageTimestamps = metrics.ConnectionStageTimestamps{} + s.isReconnectionAttempt = true +} + +func (s *MetricsStages) IsReconnection() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.isReconnectionAttempt +} + +func (s *MetricsStages) GetTimestamps() metrics.ConnectionStageTimestamps { + s.mu.Lock() + defer s.mu.Unlock() + return s.stageTimestamps +} diff --git a/client/internal/peer/metrics_saver_test.go b/client/internal/peer/metrics_saver_test.go new file mode 100644 index 000000000..01c0aa9ac --- /dev/null +++ b/client/internal/peer/metrics_saver_test.go @@ -0,0 +1,125 @@ +package peer + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/metrics" +) + +func TestMetricsStages_RecordSignalingReceived(t *testing.T) { + s := &MetricsStages{} + + s.RecordSignalingReceived() + ts := s.GetTimestamps() + require.False(t, ts.SignalingReceived.IsZero()) + + // Second call should not overwrite + first := ts.SignalingReceived + time.Sleep(time.Millisecond) + s.RecordSignalingReceived() + ts = s.GetTimestamps() + assert.Equal(t, first, ts.SignalingReceived, "should keep the first signaling timestamp") +} + +func TestMetricsStages_RecordConnectionReady(t *testing.T) { + s := &MetricsStages{} + + now := time.Now() + s.RecordConnectionReady(now) + ts := s.GetTimestamps() + assert.Equal(t, now, ts.ConnectionReady) + + // Second call should not overwrite + later := now.Add(time.Second) + s.RecordConnectionReady(later) + ts = s.GetTimestamps() + assert.Equal(t, now, ts.ConnectionReady, "should keep the first connection ready timestamp") +} + +func TestMetricsStages_RecordWGHandshakeSuccess(t *testing.T) { + s := &MetricsStages{} + + connReady := time.Now() + s.RecordConnectionReady(connReady) + + handshake := connReady.Add(500 * time.Millisecond) + s.RecordWGHandshakeSuccess(handshake) + + ts := s.GetTimestamps() + assert.Equal(t, handshake, ts.WgHandshakeSuccess) +} + +func TestMetricsStages_HandshakeBeforeConnectionReady_Normalizes(t *testing.T) { + s := &MetricsStages{} + + connReady := time.Now() + s.RecordConnectionReady(connReady) + + // WG handshake appears before ConnectionReady due to second-precision truncation + handshake := connReady.Add(-100 * time.Millisecond) + s.RecordWGHandshakeSuccess(handshake) + + ts := s.GetTimestamps() + assert.Equal(t, connReady, ts.WgHandshakeSuccess, "should normalize to ConnectionReady when handshake appears earlier") +} + +func TestMetricsStages_HandshakeIgnoredWithoutConnectionReady(t *testing.T) { + s := &MetricsStages{} + + s.RecordWGHandshakeSuccess(time.Now()) + ts := s.GetTimestamps() + assert.True(t, ts.WgHandshakeSuccess.IsZero(), "should not record handshake without connection ready") +} + +func TestMetricsStages_HandshakeRecordedOnce(t *testing.T) { + s := &MetricsStages{} + + connReady := time.Now() + s.RecordConnectionReady(connReady) + + first := connReady.Add(time.Second) + s.RecordWGHandshakeSuccess(first) + + // Second call (rekey) should be ignored + second := connReady.Add(2 * time.Second) + s.RecordWGHandshakeSuccess(second) + + ts := s.GetTimestamps() + assert.Equal(t, first, ts.WgHandshakeSuccess, "should preserve first handshake, ignore rekeys") +} + +func TestMetricsStages_Disconnected(t *testing.T) { + s := &MetricsStages{} + + s.RecordSignalingReceived() + s.RecordConnectionReady(time.Now()) + assert.False(t, s.IsReconnection()) + + s.Disconnected() + + assert.True(t, s.IsReconnection()) + ts := s.GetTimestamps() + assert.True(t, ts.SignalingReceived.IsZero(), "timestamps should be reset after disconnect") + assert.True(t, ts.ConnectionReady.IsZero(), "timestamps should be reset after disconnect") + assert.True(t, ts.WgHandshakeSuccess.IsZero(), "timestamps should be reset after disconnect") +} + +func TestMetricsStages_GetTimestamps(t *testing.T) { + s := &MetricsStages{} + + ts := s.GetTimestamps() + assert.Equal(t, metrics.ConnectionStageTimestamps{}, ts) + + now := time.Now() + s.RecordSignalingReceived() + s.RecordConnectionReady(now) + + ts = s.GetTimestamps() + assert.False(t, ts.SignalingReceived.IsZero()) + assert.Equal(t, now, ts.ConnectionReady) + assert.True(t, ts.WgHandshakeSuccess.IsZero()) +} diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index 799a9375e..805a6f24a 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -48,7 +48,7 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin // EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing. // The watcher runs until ctx is cancelled. Caller is responsible for context lifecycle management. -func (w *WGWatcher) EnableWgWatcher(ctx context.Context, onDisconnectedFn func()) { +func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) { w.muEnabled.Lock() if w.enabled { w.muEnabled.Unlock() @@ -56,7 +56,6 @@ func (w *WGWatcher) EnableWgWatcher(ctx context.Context, onDisconnectedFn func() } w.log.Debugf("enable WireGuard watcher") - enabledTime := time.Now() w.enabled = true w.muEnabled.Unlock() @@ -65,7 +64,7 @@ func (w *WGWatcher) EnableWgWatcher(ctx context.Context, onDisconnectedFn func() w.log.Warnf("failed to read initial wg stats: %v", err) } - w.periodicHandshakeCheck(ctx, onDisconnectedFn, enabledTime, initialHandshake) + w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake) w.muEnabled.Lock() w.enabled = false @@ -89,7 +88,7 @@ func (w *WGWatcher) Reset() { } // wgStateCheck help to check the state of the WireGuard handshake and relay connection -func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), enabledTime time.Time, initialHandshake time.Time) { +func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) { w.log.Infof("WireGuard watcher started") timer := time.NewTimer(wgHandshakeOvertime) @@ -108,6 +107,9 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn if lastHandshake.IsZero() { elapsed := calcElapsed(enabledTime, *handshake) w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake) + if onHandshakeSuccessFn != nil { + onHandshakeSuccessFn(*handshake) + } } lastHandshake = *handshake diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index f79405a01..3ce91cd46 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -35,9 +35,11 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { defer cancel() onDisconnected := make(chan struct{}, 1) - go watcher.EnableWgWatcher(ctx, func() { + go watcher.EnableWgWatcher(ctx, time.Now(), func() { mlog.Infof("onDisconnectedFn") onDisconnected <- struct{}{} + }, func(when time.Time) { + mlog.Infof("onHandshakeSuccess: %v", when) }) // wait for initial reading @@ -64,7 +66,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - watcher.EnableWgWatcher(ctx, func() {}) + watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}) }() cancel() @@ -75,9 +77,9 @@ func TestWGWatcher_ReEnable(t *testing.T) { defer cancel() onDisconnected := make(chan struct{}, 1) - go watcher.EnableWgWatcher(ctx, func() { + go watcher.EnableWgWatcher(ctx, time.Now(), func() { onDisconnected <- struct{}{} - }) + }, func(when time.Time) {}) time.Sleep(2 * time.Second) mocWgIface.disconnect() diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index bad616271..e6ef8b876 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -3,7 +3,9 @@ package client import ( "context" "fmt" + "net" "reflect" + "strconv" "time" log "github.com/sirupsen/logrus" @@ -564,7 +566,7 @@ func HandlerFromRoute(params common.HandlerParams) RouteHandler { return dnsinterceptor.New(params) case handlerTypeDynamic: dns := nbdns.NewServiceViaMemory(params.WgInterface) - dnsAddr := fmt.Sprintf("%s:%d", dns.RuntimeIP(), dns.RuntimePort()) + dnsAddr := net.JoinHostPort(dns.RuntimeIP().String(), strconv.Itoa(dns.RuntimePort())) return dynamic.NewRoute(params, dnsAddr) default: return static.NewRoute(params) diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index 4bf0d5476..64f2a8789 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "net" "net/netip" "runtime" + "strconv" "strings" "sync" "sync/atomic" @@ -249,7 +251,7 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { r.MsgHdr.AuthenticatedData = true } - upstream := fmt.Sprintf("%s:%d", upstreamIP.String(), uint16(d.forwarderPort.Load())) + upstream := net.JoinHostPort(upstreamIP.String(), strconv.FormatUint(uint64(d.forwarderPort.Load()), 10)) ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout) defer cancel() diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index dec0af87c..3d2784ae1 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -31,26 +31,11 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { n.listener = listener } +// SetInitialClientRoutes stores the full initial route set (including fake IP blocks) +// and a separate comparison set (without fake IP blocks) for diff detection. func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) { - // initialRoutes contains fake IP block for interface configuration - filteredInitial := make([]*route.Route, 0) - for _, r := range initialRoutes { - if r.IsDynamic() { - continue - } - filteredInitial = append(filteredInitial, r) - } - n.initialRoutes = filteredInitial - - // routesForComparison excludes fake IP block for comparison with new routes - filteredComparison := make([]*route.Route, 0) - for _, r := range routesForComparison { - if r.IsDynamic() { - continue - } - filteredComparison = append(filteredComparison, r) - } - n.currentRoutes = filteredComparison + n.initialRoutes = filterStatic(initialRoutes) + n.currentRoutes = filterStatic(routesForComparison) } func (n *Notifier) OnNewRoutes(idMap route.HAMap) { @@ -83,13 +68,43 @@ func (n *Notifier) notify() { return } - routeStrings := n.routesToStrings(n.currentRoutes) + allRoutes := slices.Clone(n.currentRoutes) + allRoutes = append(allRoutes, n.extraInitialRoutes()...) + + routeStrings := n.routesToStrings(allRoutes) sort.Strings(routeStrings) go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(routeStrings, n.currentRoutes), ",")) + l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(routeStrings, allRoutes), ",")) }(n.listener) } +// extraInitialRoutes returns initialRoutes whose network prefix is absent +// from currentRoutes (e.g. the fake IP block added at setup time). +func (n *Notifier) extraInitialRoutes() []*route.Route { + currentNets := make(map[netip.Prefix]struct{}, len(n.currentRoutes)) + for _, r := range n.currentRoutes { + currentNets[r.Network] = struct{}{} + } + + var extra []*route.Route + for _, r := range n.initialRoutes { + if _, ok := currentNets[r.Network]; !ok { + extra = append(extra, r) + } + } + return extra +} + +func filterStatic(routes []*route.Route) []*route.Route { + out := make([]*route.Route, 0, len(routes)) + for _, r := range routes { + if !r.IsDynamic() { + out = append(out, r) + } + } + return out +} + func (n *Notifier) routesToStrings(routes []*route.Route) []string { nets := make([]string, 0, len(routes)) for _, r := range routes { diff --git a/client/netbird-entrypoint.sh b/client/netbird-entrypoint.sh index 7c9fa021a..0e330bdac 100755 --- a/client/netbird-entrypoint.sh +++ b/client/netbird-entrypoint.sh @@ -1,12 +1,10 @@ #!/usr/bin/env bash set -eEuo pipefail -: ${NB_ENTRYPOINT_SERVICE_TIMEOUT:="5"} -: ${NB_ENTRYPOINT_LOGIN_TIMEOUT:="5"} +: ${NB_ENTRYPOINT_SERVICE_TIMEOUT:="30"} NETBIRD_BIN="${NETBIRD_BIN:-"netbird"}" export NB_LOG_FILE="${NB_LOG_FILE:-"console,/var/log/netbird/client.log"}" service_pids=() -log_file_path="" _log() { # mimic Go logger's output for easier parsing @@ -33,60 +31,29 @@ on_exit() { fi } -wait_for_message() { - local timeout="${1}" message="${2}" - if test "${timeout}" -eq 0; then - info "not waiting for log line ${message@Q} due to zero timeout." - elif test -n "${log_file_path}"; then - info "waiting for log line ${message@Q} for ${timeout} seconds..." - grep -E -q "${message}" <(timeout "${timeout}" tail -F "${log_file_path}" 2>/dev/null) - else - info "log file unsupported, sleeping for ${timeout} seconds..." - sleep "${timeout}" - fi -} - -locate_log_file() { - local log_files_string="${1}" - - while read -r log_file; do - case "${log_file}" in - console | syslog) ;; - *) - log_file_path="${log_file}" - return - ;; - esac - done < <(sed 's#,#\n#g' <<<"${log_files_string}") - - warn "log files parsing for ${log_files_string@Q} is not supported by debug bundles" - warn "please consider removing the \$NB_LOG_FILE or setting it to real file, before gathering debug bundles." -} - wait_for_daemon_startup() { local timeout="${1}" - - if test -n "${log_file_path}"; then - if ! wait_for_message "${timeout}" "started daemon server"; then - warn "log line containing 'started daemon server' not found after ${timeout} seconds" - warn "daemon failed to start, exiting..." - exit 1 - fi - else - warn "daemon service startup not discovered, sleeping ${timeout} instead" - sleep "${timeout}" + if [[ "${timeout}" -eq 0 ]]; then + info "not waiting for daemon startup due to zero timeout." + return fi + + local deadline=$((SECONDS + timeout)) + while [[ "${SECONDS}" -lt "${deadline}" ]]; do + if "${NETBIRD_BIN}" status --check live 2>/dev/null; then + return + fi + sleep 1 + done + + warn "daemon did not become responsive after ${timeout} seconds, exiting..." + exit 1 } -login_if_needed() { - local timeout="${1}" - - if test -n "${log_file_path}" && wait_for_message "${timeout}" 'peer has been successfully registered|management connection state READY'; then - info "already logged in, skipping 'netbird up'..." - else - info "logging in..." - "${NETBIRD_BIN}" up - fi +connect() { + info "running 'netbird up'..." + "${NETBIRD_BIN}" up + return $? } main() { @@ -95,9 +62,8 @@ main() { service_pids+=("$!") info "registered new service process 'netbird service run', currently running: ${service_pids[@]@Q}" - locate_log_file "${NB_LOG_FILE}" wait_for_daemon_startup "${NB_ENTRYPOINT_SERVICE_TIMEOUT}" - login_if_needed "${NB_ENTRYPOINT_LOGIN_TIMEOUT}" + connect wait "${service_pids[@]}" } diff --git a/client/server/debug.go b/client/server/debug.go index 4c531efba..81708e576 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -26,6 +26,15 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( log.Warnf("failed to get latest sync response: %v", err) } + var clientMetrics debug.MetricsExporter + if s.connectClient != nil { + if engine := s.connectClient.Engine(); engine != nil { + if cm := engine.GetClientMetrics(); cm != nil { + clientMetrics = cm + } + } + } + var cpuProfileData []byte if s.cpuProfileBuf != nil && !s.cpuProfiling { cpuProfileData = s.cpuProfileBuf.Bytes() @@ -54,6 +63,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( LogPath: s.logFile, CPUProfile: cpuProfileData, RefreshStatus: refreshStatus, + ClientMetrics: clientMetrics, }, debug.BundleConfig{ Anonymize: req.GetAnonymize(), diff --git a/client/status/status.go b/client/status/status.go index f13163a41..8c932bbab 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -25,6 +25,38 @@ import ( "github.com/netbirdio/netbird/version" ) +// DaemonStatus represents the current state of the NetBird daemon. +// These values mirror internal.StatusType but are defined here to avoid an import cycle. +type DaemonStatus string + +const ( + DaemonStatusIdle DaemonStatus = "Idle" + DaemonStatusConnecting DaemonStatus = "Connecting" + DaemonStatusConnected DaemonStatus = "Connected" + DaemonStatusNeedsLogin DaemonStatus = "NeedsLogin" + DaemonStatusLoginFailed DaemonStatus = "LoginFailed" + DaemonStatusSessionExpired DaemonStatus = "SessionExpired" +) + +// ParseDaemonStatus converts a raw status string to DaemonStatus. +// Unrecognized values are preserved as-is to remain visible during version skew. +func ParseDaemonStatus(s string) DaemonStatus { + return DaemonStatus(s) +} + +// ConvertOptions holds parameters for ConvertToStatusOutputOverview. +type ConvertOptions struct { + Anonymize bool + DaemonVersion string + DaemonStatus DaemonStatus + StatusFilter string + PrefixNamesFilter []string + PrefixNamesFilterMap map[string]struct{} + IPsFilter map[string]struct{} + ConnectionTypeFilter string + ProfileName string +} + type PeerStateDetailOutput struct { FQDN string `json:"fqdn" yaml:"fqdn"` IP string `json:"netbirdIp" yaml:"netbirdIp"` @@ -102,6 +134,7 @@ type OutputOverview struct { Peers PeersStateOutput `json:"peers" yaml:"peers"` CliVersion string `json:"cliVersion" yaml:"cliVersion"` DaemonVersion string `json:"daemonVersion" yaml:"daemonVersion"` + DaemonStatus DaemonStatus `json:"daemonStatus" yaml:"daemonStatus"` ManagementState ManagementStateOutput `json:"management" yaml:"management"` SignalState SignalStateOutput `json:"signal" yaml:"signal"` Relays RelayStateOutput `json:"relays" yaml:"relays"` @@ -120,7 +153,8 @@ type OutputOverview struct { SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` } -func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, anon bool, daemonVersion string, statusFilter string, prefixNamesFilter []string, prefixNamesFilterMap map[string]struct{}, ipsFilter map[string]struct{}, connectionTypeFilter string, profName string) OutputOverview { +// ConvertToStatusOutputOverview converts protobuf status to the output overview. +func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertOptions) OutputOverview { managementState := pbFullStatus.GetManagementState() managementOverview := ManagementStateOutput{ URL: managementState.GetURL(), @@ -137,12 +171,13 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, anon bool, da relayOverview := mapRelays(pbFullStatus.GetRelays()) sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState()) - peersOverview := mapPeers(pbFullStatus.GetPeers(), statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilter, connectionTypeFilter) + peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter) overview := OutputOverview{ Peers: peersOverview, CliVersion: version.NetbirdVersion(), - DaemonVersion: daemonVersion, + DaemonVersion: opts.DaemonVersion, + DaemonStatus: opts.DaemonStatus, ManagementState: managementOverview, SignalState: signalOverview, Relays: relayOverview, @@ -157,11 +192,11 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, anon bool, da NSServerGroups: mapNSGroups(pbFullStatus.GetDnsServers()), Events: mapEvents(pbFullStatus.GetEvents()), LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(), - ProfileName: profName, + ProfileName: opts.ProfileName, SSHServerState: sshServerOverview, } - if anon { + if opts.Anonymize { anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) anonymizeOverview(anonymizer, &overview) } diff --git a/client/status/status_test.go b/client/status/status_test.go index b02d78d64..7754eebae 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -176,6 +176,7 @@ var overview = OutputOverview{ Events: []SystemEventOutput{}, CliVersion: version.NetbirdVersion(), DaemonVersion: "0.14.1", + DaemonStatus: DaemonStatusConnected, ManagementState: ManagementStateOutput{ URL: "my-awesome-management.com:443", Connected: true, @@ -238,7 +239,10 @@ var overview = OutputOverview{ } func TestConversionFromFullStatusToOutputOverview(t *testing.T) { - convertedResult := ConvertToStatusOutputOverview(resp.GetFullStatus(), false, resp.GetDaemonVersion(), "", nil, nil, nil, "", "") + convertedResult := ConvertToStatusOutputOverview(resp.GetFullStatus(), ConvertOptions{ + DaemonVersion: resp.GetDaemonVersion(), + DaemonStatus: ParseDaemonStatus(resp.GetStatus()), + }) assert.Equal(t, overview, convertedResult) } @@ -329,6 +333,7 @@ func TestParsingToJSON(t *testing.T) { }, "cliVersion": "development", "daemonVersion": "0.14.1", + "daemonStatus": "Connected", "management": { "url": "my-awesome-management.com:443", "connected": true, @@ -452,6 +457,7 @@ func TestParsingToYAML(t *testing.T) { networks: [] cliVersion: development daemonVersion: 0.14.1 +daemonStatus: Connected management: url: my-awesome-management.com:443 connected: true diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 0574e53d0..b1e0aec41 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -324,6 +324,7 @@ type serviceClient struct { exitNodeMu sync.Mutex mExitNodeItems []menuHandler exitNodeRetryCancel context.CancelFunc + mExitNodeSeparator *systray.MenuItem mExitNodeDeselectAll *systray.MenuItem logFile string wLoginURL fyne.Window diff --git a/client/ui/network.go b/client/ui/network.go index ed03f5ada..571e871bb 100644 --- a/client/ui/network.go +++ b/client/ui/network.go @@ -421,6 +421,10 @@ func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { node.Remove() } s.mExitNodeItems = nil + if s.mExitNodeSeparator != nil { + s.mExitNodeSeparator.Remove() + s.mExitNodeSeparator = nil + } if s.mExitNodeDeselectAll != nil { s.mExitNodeDeselectAll.Remove() s.mExitNodeDeselectAll = nil @@ -453,31 +457,37 @@ func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { } if showDeselectAll { - s.mExitNode.AddSeparator() - deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All") - s.mExitNodeDeselectAll = deselectAllItem - go func() { - for { - _, ok := <-deselectAllItem.ClickedCh - if !ok { - // channel closed: exit the goroutine - return - } - exitNodes, err := s.handleExitNodeMenuDeselectAll() - if err != nil { - log.Warnf("failed to handle deselect all exit nodes: %v", err) - } else { - s.exitNodeMu.Lock() - s.recreateExitNodeMenu(exitNodes) - s.exitNodeMu.Unlock() - } - } - - }() + s.addExitNodeDeselectAll() } } +func (s *serviceClient) addExitNodeDeselectAll() { + sep := s.mExitNode.AddSubMenuItem("───────────────", "") + sep.Disable() + s.mExitNodeSeparator = sep + + deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All") + s.mExitNodeDeselectAll = deselectAllItem + + go func() { + for { + _, ok := <-deselectAllItem.ClickedCh + if !ok { + return + } + exitNodes, err := s.handleExitNodeMenuDeselectAll() + if err != nil { + log.Warnf("failed to handle deselect all exit nodes: %v", err) + } else { + s.exitNodeMu.Lock() + s.recreateExitNodeMenu(exitNodes) + s.exitNodeMu.Unlock() + } + } + }() +} + func (s *serviceClient) getExitNodes(conn proto.DaemonServiceClient) ([]*proto.Network, error) { ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout) defer cancel() diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index 26022ffc7..d8e50ab6d 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -18,7 +18,6 @@ import ( "github.com/netbirdio/netbird/client/wasm/internal/rdp" "github.com/netbirdio/netbird/client/wasm/internal/ssh" "github.com/netbirdio/netbird/util" - "github.com/netbirdio/netbird/version" ) const ( @@ -350,7 +349,7 @@ func getStatusOverview(client *netbird.Client) (nbstatus.OutputOverview, error) pbFullStatus := fullStatus.ToProto() - return nbstatus.ConvertToStatusOutputOverview(pbFullStatus, false, version.NetbirdVersion(), "", nil, nil, nil, "", ""), nil + return nbstatus.ConvertToStatusOutputOverview(pbFullStatus, nbstatus.ConvertOptions{}), nil } // createStatusMethod creates the status method that returns JSON diff --git a/go.mod b/go.mod index 4bcdbdc78..89bc06fea 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/netbirdio/netbird -go 1.25 - -toolchain go1.25.5 +go 1.25.5 require ( cunicu.li/go-rosenpass v0.4.0 @@ -19,23 +17,23 @@ require ( github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.9 github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.46.0 - golang.org/x/sys v0.39.0 + golang.org/x/crypto v0.48.0 + golang.org/x/sys v0.41.0 golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 golang.zx2c4.com/wireguard/windows v0.5.3 - google.golang.org/grpc v1.77.0 - google.golang.org/protobuf v1.36.10 + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) require ( fyne.io/fyne/v2 v2.7.0 fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 - github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible github.com/awnumar/memguard v0.23.0 github.com/aws/aws-sdk-go-v2 v1.36.3 github.com/aws/aws-sdk-go-v2/config v1.29.14 + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 github.com/aws/aws-sdk-go-v2/service/s3 v1.79.2 github.com/c-robinson/iplib v1.0.3 github.com/caddyserver/certmagic v0.21.3 @@ -103,21 +101,21 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yusufpapurcu/wmi v1.2.4 github.com/zcalusic/sysinfo v1.1.3 - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 - go.opentelemetry.io/otel v1.38.0 - go.opentelemetry.io/otel/exporters/prometheus v0.48.0 - go.opentelemetry.io/otel/metric v1.38.0 - go.opentelemetry.io/otel/sdk/metric v1.38.0 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 + go.opentelemetry.io/otel v1.42.0 + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 + go.opentelemetry.io/otel/metric v1.42.0 + go.opentelemetry.io/otel/sdk/metric v1.42.0 go.uber.org/mock v0.5.2 go.uber.org/zap v1.27.0 goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 golang.org/x/mobile v0.0.0-20251113184115-a159579294ab - golang.org/x/mod v0.30.0 - golang.org/x/net v0.47.0 + golang.org/x/mod v0.32.0 + golang.org/x/net v0.51.0 golang.org/x/oauth2 v0.34.0 golang.org/x/sync v0.19.0 - golang.org/x/term v0.38.0 + golang.org/x/term v0.40.0 golang.org/x/time v0.14.0 google.golang.org/api v0.257.0 gopkg.in/yaml.v3 v3.0.1 @@ -125,7 +123,7 @@ require ( gorm.io/driver/postgres v1.5.7 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 - gvisor.dev/gvisor v0.0.0-20251031020517-ecfcdd2f171c + gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 ) require ( @@ -146,7 +144,6 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/awnumar/memcall v0.4.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect @@ -252,12 +249,13 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/russellhaering/goxmldsig v1.5.0 // indirect github.com/rymdport/portal v0.4.2 // indirect github.com/shirou/gopsutil/v4 v4.25.1 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/shoenig/go-m1cpu v0.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect @@ -272,15 +270,15 @@ require ( github.com/zeebo/blake3 v0.2.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel/sdk v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/image v0.33.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect ) diff --git a/go.sum b/go.sum index 1bd9396bb..629388ccb 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,6 @@ github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSC github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible h1:hqcTK6ZISdip65SR792lwYJTa/axESA0889D3UlZbLo= -github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible/go.mod h1:6B1nuc1MUs6c62ODZDl7hVE5Pv7O2XGSkgg2olnq34I= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -489,10 +487,12 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= @@ -512,10 +512,12 @@ github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRB github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8= github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= +github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -603,26 +605,26 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/exporters/prometheus v0.48.0 h1:sBQe3VNGUjY9IKWQC6z2lNqa5iGbDSxhs60ABwK4y0s= -go.opentelemetry.io/otel/exporters/prometheus v0.48.0/go.mod h1:DtrbMzoZWwQHyrQmCfLam5DZbnmorsGbOtTbYHycU5o= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -633,8 +635,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= goauthentik.io/api/v3 v3.2023051.3 h1:NebAhD/TeTWNo/9X3/Uj+rM5fG1HaiLOlKTNLQv9Qq4= goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy4+7G8u4U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -648,8 +650,8 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= @@ -666,8 +668,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -686,8 +688,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= @@ -738,8 +740,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -752,8 +754,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -765,8 +767,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -780,8 +782,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -799,12 +801,12 @@ google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3 google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= -google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= -google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -815,8 +817,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -852,5 +854,5 @@ gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -gvisor.dev/gvisor v0.0.0-20251031020517-ecfcdd2f171c h1:pfzmXIkkDgydR4ZRP+e1hXywZfYR21FA0Fbk6ptMkiA= -gvisor.dev/gvisor v0.0.0-20251031020517-ecfcdd2f171c/go.mod h1:/mc6CfwbOm5KKmqoV7Qx20Q+Ja8+vO4g7FuCdlVoAfQ= +gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA= +gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 7cb0f3908..d3f8f44ff 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -154,9 +154,11 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs return err } - eventsToStore = append(eventsToStore, func() { - m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain)) - }) + if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") { + eventsToStore = append(eventsToStore, func() { + m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain)) + }) + } return nil }) diff --git a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go index 619a34684..a7f692569 100644 --- a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go +++ b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go @@ -20,22 +20,23 @@ const ( ) type AccessLogEntry struct { - ID string `gorm:"primaryKey"` - AccountID string `gorm:"index"` - ServiceID string `gorm:"index"` - Timestamp time.Time `gorm:"index"` - GeoLocation peer.Location `gorm:"embedded;embeddedPrefix:location_"` - Method string `gorm:"index"` - Host string `gorm:"index"` - Path string `gorm:"index"` - Duration time.Duration `gorm:"index"` - StatusCode int `gorm:"index"` - Reason string - UserId string `gorm:"index"` - AuthMethodUsed string `gorm:"index"` - BytesUpload int64 `gorm:"index"` - BytesDownload int64 `gorm:"index"` - Protocol AccessLogProtocol `gorm:"index"` + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + ServiceID string `gorm:"index"` + Timestamp time.Time `gorm:"index"` + GeoLocation peer.Location `gorm:"embedded;embeddedPrefix:location_"` + SubdivisionCode string + Method string `gorm:"index"` + Host string `gorm:"index"` + Path string `gorm:"index"` + Duration time.Duration `gorm:"index"` + StatusCode int `gorm:"index"` + Reason string + UserId string `gorm:"index"` + AuthMethodUsed string `gorm:"index"` + BytesUpload int64 `gorm:"index"` + BytesDownload int64 `gorm:"index"` + Protocol AccessLogProtocol `gorm:"index"` } // FromProto creates an AccessLogEntry from a proto.AccessLog @@ -105,6 +106,11 @@ func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog { cityName = &a.GeoLocation.CityName } + var subdivisionCode *string + if a.SubdivisionCode != "" { + subdivisionCode = &a.SubdivisionCode + } + var protocol *string if a.Protocol != "" { p := string(a.Protocol) @@ -112,22 +118,23 @@ func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog { } return &api.ProxyAccessLog{ - Id: a.ID, - ServiceId: a.ServiceID, - Timestamp: a.Timestamp, - Method: a.Method, - Host: a.Host, - Path: a.Path, - DurationMs: int(a.Duration.Milliseconds()), - StatusCode: a.StatusCode, - SourceIp: sourceIP, - Reason: reason, - UserId: userID, - AuthMethodUsed: authMethod, - CountryCode: countryCode, - CityName: cityName, - BytesUpload: a.BytesUpload, - BytesDownload: a.BytesDownload, - Protocol: protocol, + Id: a.ID, + ServiceId: a.ServiceID, + Timestamp: a.Timestamp, + Method: a.Method, + Host: a.Host, + Path: a.Path, + DurationMs: int(a.Duration.Milliseconds()), + StatusCode: a.StatusCode, + SourceIp: sourceIP, + Reason: reason, + UserId: userID, + AuthMethodUsed: authMethod, + CountryCode: countryCode, + CityName: cityName, + SubdivisionCode: subdivisionCode, + BytesUpload: a.BytesUpload, + BytesDownload: a.BytesDownload, + Protocol: protocol, } } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go index e7fba7bed..e8d0ce763 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go @@ -41,6 +41,9 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac logEntry.GeoLocation.CountryCode = location.Country.ISOCode logEntry.GeoLocation.CityName = location.City.Names.En logEntry.GeoLocation.GeoNameID = location.City.GeonameID + if len(location.Subdivisions) > 0 { + logEntry.SubdivisionCode = location.Subdivisions[0].ISOCode + } } } diff --git a/management/internals/modules/reverseproxy/domain/domain.go b/management/internals/modules/reverseproxy/domain/domain.go index 861d026a7..859f1c5b2 100644 --- a/management/internals/modules/reverseproxy/domain/domain.go +++ b/management/internals/modules/reverseproxy/domain/domain.go @@ -17,6 +17,9 @@ type Domain struct { // SupportsCustomPorts is populated at query time for free domains from the // proxy cluster capabilities. Not persisted. SupportsCustomPorts *bool `gorm:"-"` + // RequireSubdomain is populated at query time. When true, the domain + // cannot be used bare and a subdomain label must be prepended. Not persisted. + RequireSubdomain *bool `gorm:"-"` } // EventMeta returns activity event metadata for a domain diff --git a/management/internals/modules/reverseproxy/domain/manager/api.go b/management/internals/modules/reverseproxy/domain/manager/api.go index d26a6a418..640ab28a5 100644 --- a/management/internals/modules/reverseproxy/domain/manager/api.go +++ b/management/internals/modules/reverseproxy/domain/manager/api.go @@ -47,6 +47,7 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain { Type: domainTypeToApi(d.Type), Validated: d.Validated, SupportsCustomPorts: d.SupportsCustomPorts, + RequireSubdomain: d.RequireSubdomain, } if d.TargetCluster != "" { resp.TargetCluster = &d.TargetCluster diff --git a/management/internals/modules/reverseproxy/domain/manager/domain_test.go b/management/internals/modules/reverseproxy/domain/manager/domain_test.go new file mode 100644 index 000000000..523920a99 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/domain_test.go @@ -0,0 +1,172 @@ +package manager + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" +) + +func TestExtractClusterFromFreeDomain(t *testing.T) { + clusters := []string{"eu1.proxy.netbird.io", "us1.proxy.netbird.io"} + + tests := []struct { + name string + domain string + wantOK bool + wantVal string + }{ + { + name: "subdomain of cluster matches", + domain: "myapp.eu1.proxy.netbird.io", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "deep subdomain of cluster matches", + domain: "foo.bar.eu1.proxy.netbird.io", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "bare cluster domain matches", + domain: "eu1.proxy.netbird.io", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "unrelated domain does not match", + domain: "example.com", + wantOK: false, + }, + { + name: "partial suffix does not match", + domain: "fakeu1.proxy.netbird.io", + wantOK: false, + }, + { + name: "second cluster matches", + domain: "app.us1.proxy.netbird.io", + wantOK: true, + wantVal: "us1.proxy.netbird.io", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cluster, ok := ExtractClusterFromFreeDomain(tc.domain, clusters) + assert.Equal(t, tc.wantOK, ok) + if ok { + assert.Equal(t, tc.wantVal, cluster) + } + }) + } +} + +func TestExtractClusterFromCustomDomains(t *testing.T) { + customDomains := []*domain.Domain{ + {Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io"}, + {Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io"}, + } + + tests := []struct { + name string + domain string + wantOK bool + wantVal string + }{ + { + name: "subdomain of custom domain matches", + domain: "app.example.com", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "bare custom domain matches", + domain: "example.com", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "deep subdomain of custom domain matches", + domain: "a.b.example.com", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "subdomain of multi-level custom domain matches", + domain: "app.proxy.corp.io", + wantOK: true, + wantVal: "us1.proxy.netbird.io", + }, + { + name: "bare multi-level custom domain matches", + domain: "proxy.corp.io", + wantOK: true, + wantVal: "us1.proxy.netbird.io", + }, + { + name: "unrelated domain does not match", + domain: "other.com", + wantOK: false, + }, + { + name: "partial suffix does not match custom domain", + domain: "fakeexample.com", + wantOK: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) + assert.Equal(t, tc.wantOK, ok) + if ok { + assert.Equal(t, tc.wantVal, cluster) + } + }) + } +} + +func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) { + customDomains := []*domain.Domain{ + {Domain: "example.com", TargetCluster: "cluster-generic"}, + {Domain: "app.example.com", TargetCluster: "cluster-app"}, + } + + tests := []struct { + name string + domain string + wantVal string + }{ + { + name: "exact match on more specific domain", + domain: "app.example.com", + wantVal: "cluster-app", + }, + { + name: "subdomain of more specific domain", + domain: "api.app.example.com", + wantVal: "cluster-app", + }, + { + name: "subdomain of generic domain", + domain: "other.example.com", + wantVal: "cluster-generic", + }, + { + name: "bare generic domain", + domain: "example.com", + wantVal: "cluster-generic", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) + assert.True(t, ok) + assert.Equal(t, tc.wantVal, cluster) + }) + } +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go index 813027ea2..c6c41bfe5 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -31,18 +31,15 @@ type store interface { type proxyManager interface { GetActiveClusterAddresses(ctx context.Context) ([]string, error) -} - -type clusterCapabilities interface { - ClusterSupportsCustomPorts(clusterAddr string) *bool + ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool } type Manager struct { - store store - validator domain.Validator - proxyManager proxyManager - clusterCapabilities clusterCapabilities - permissionsManager permissions.Manager + store store + validator domain.Validator + proxyManager proxyManager + permissionsManager permissions.Manager accountManager account.Manager } @@ -56,11 +53,6 @@ func NewManager(store store, proxyMgr proxyManager, permissionsManager permissio } } -// SetClusterCapabilities sets the cluster capabilities provider for domain queries. -func (m *Manager) SetClusterCapabilities(caps clusterCapabilities) { - m.clusterCapabilities = caps -} - func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) { ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) if err != nil { @@ -96,9 +88,8 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d Type: domain.TypeFree, Validated: true, } - if m.clusterCapabilities != nil { - d.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(cluster) - } + d.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, cluster) + d.RequireSubdomain = m.proxyManager.ClusterRequireSubdomain(ctx, cluster) ret = append(ret, d) } @@ -112,9 +103,11 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d Type: domain.TypeCustom, Validated: d.Validated, } - if m.clusterCapabilities != nil && d.TargetCluster != "" { - cd.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(d.TargetCluster) + if d.TargetCluster != "" { + cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster) } + // Custom domains never require a subdomain by default since + // the account owns them and should be able to use the bare domain. ret = append(ret, cd) } @@ -302,13 +295,19 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain) } -func extractClusterFromCustomDomains(domain string, customDomains []*domain.Domain) (string, bool) { - for _, customDomain := range customDomains { - if strings.HasSuffix(domain, "."+customDomain.Domain) { - return customDomain.TargetCluster, true +func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) { + bestCluster := "" + bestLen := -1 + for _, cd := range customDomains { + if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) { + continue + } + if l := len(cd.Domain); l > bestLen { + bestLen = l + bestCluster = cd.TargetCluster } } - return "", false + return bestCluster, bestLen >= 0 } // ExtractClusterFromFreeDomain extracts the cluster address from a free domain. diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index 67a8e74fa..0368b84de 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -11,10 +11,13 @@ import ( // Manager defines the interface for proxy operations type Manager interface { - Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string) error + Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string, capabilities *Capabilities) error Disconnect(ctx context.Context, proxyID string) error - Heartbeat(ctx context.Context, proxyID string) error + Heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error GetActiveClusterAddresses(ctx context.Context) ([]string, error) + GetActiveClusters(ctx context.Context) ([]Cluster, error) + ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool CleanupStale(ctx context.Context, inactivityDuration time.Duration) error } @@ -33,5 +36,4 @@ type Controller interface { RegisterProxyToCluster(ctx context.Context, clusterAddr, proxyID string) error UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error GetProxiesForCluster(clusterAddr string) []string - ClusterSupportsCustomPorts(clusterAddr string) *bool } diff --git a/management/internals/modules/reverseproxy/proxy/manager/controller.go b/management/internals/modules/reverseproxy/proxy/manager/controller.go index acb49c45b..e5b3e9886 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/controller.go +++ b/management/internals/modules/reverseproxy/proxy/manager/controller.go @@ -72,11 +72,6 @@ func (c *GRPCController) UnregisterProxyFromCluster(ctx context.Context, cluster return nil } -// ClusterSupportsCustomPorts returns whether any proxy in the cluster supports custom ports. -func (c *GRPCController) ClusterSupportsCustomPorts(clusterAddr string) *bool { - return c.proxyGRPCServer.ClusterSupportsCustomPorts(clusterAddr) -} - // GetProxiesForCluster returns all proxy IDs registered for a specific cluster. func (c *GRPCController) GetProxiesForCluster(clusterAddr string) []string { proxySet, ok := c.clusterProxies.Load(clusterAddr) diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index 4c0964b5c..a92fffab9 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -13,8 +13,11 @@ import ( // store defines the interface for proxy persistence operations type store interface { SaveProxy(ctx context.Context, p *proxy.Proxy) error - UpdateProxyHeartbeat(ctx context.Context, proxyID string) error + UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) + GetActiveProxyClusters(ctx context.Context) ([]proxy.Cluster, error) + GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error } @@ -37,9 +40,14 @@ func NewManager(store store, meter metric.Meter) (*Manager, error) { }, nil } -// Connect registers a new proxy connection in the database -func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { +// Connect registers a new proxy connection in the database. +// capabilities may be nil for old proxies that do not report them. +func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string, capabilities *proxy.Capabilities) error { now := time.Now() + var caps proxy.Capabilities + if capabilities != nil { + caps = *capabilities + } p := &proxy.Proxy{ ID: proxyID, ClusterAddress: clusterAddress, @@ -47,6 +55,7 @@ func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress LastSeen: now, ConnectedAt: &now, Status: "connected", + Capabilities: caps, } if err := m.store.SaveProxy(ctx, p); err != nil { @@ -86,11 +95,13 @@ func (m Manager) Disconnect(ctx context.Context, proxyID string) error { } // Heartbeat updates the proxy's last seen timestamp -func (m Manager) Heartbeat(ctx context.Context, proxyID string) error { - if err := m.store.UpdateProxyHeartbeat(ctx, proxyID); err != nil { +func (m Manager) Heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { + if err := m.store.UpdateProxyHeartbeat(ctx, proxyID, clusterAddress, ipAddress); err != nil { log.WithContext(ctx).Debugf("failed to update proxy %s heartbeat: %v", proxyID, err) return err } + + log.WithContext(ctx).Tracef("updated heartbeat for proxy %s", proxyID) m.metrics.IncrementProxyHeartbeatCount() return nil } @@ -105,6 +116,28 @@ func (m Manager) GetActiveClusterAddresses(ctx context.Context) ([]string, error return addresses, nil } +// GetActiveClusters returns all active proxy clusters with their connected proxy count. +func (m Manager) GetActiveClusters(ctx context.Context) ([]proxy.Cluster, error) { + clusters, err := m.store.GetActiveProxyClusters(ctx) + if err != nil { + log.WithContext(ctx).Errorf("failed to get active proxy clusters: %v", err) + return nil, err + } + return clusters, nil +} + +// ClusterSupportsCustomPorts returns whether any active proxy in the cluster +// supports custom ports. Returns nil when no proxy has reported capabilities. +func (m Manager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { + return m.store.GetClusterSupportsCustomPorts(ctx, clusterAddr) +} + +// ClusterRequireSubdomain returns whether any active proxy in the cluster +// requires a subdomain. Returns nil when no proxy has reported capabilities. +func (m Manager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { + return m.store.GetClusterRequireSubdomain(ctx, clusterAddr) +} + // CleanupStale removes proxies that haven't sent heartbeat in the specified duration func (m Manager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error { if err := m.store.CleanupStaleProxies(ctx, inactivityDuration); err != nil { diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index b07a21122..97466c503 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -50,18 +50,46 @@ func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration) } -// Connect mocks base method. -func (m *MockManager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { +// ClusterSupportsCustomPorts mocks base method. +func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Connect", ctx, proxyID, clusterAddress, ipAddress) + ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts. +func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr) +} + +// ClusterRequireSubdomain mocks base method. +func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterRequireSubdomain", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterRequireSubdomain indicates an expected call of ClusterRequireSubdomain. +func (mr *MockManagerMockRecorder) ClusterRequireSubdomain(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterRequireSubdomain", reflect.TypeOf((*MockManager)(nil).ClusterRequireSubdomain), ctx, clusterAddr) +} + +// Connect mocks base method. +func (m *MockManager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string, capabilities *Capabilities) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Connect", ctx, proxyID, clusterAddress, ipAddress, capabilities) ret0, _ := ret[0].(error) return ret0 } // Connect indicates an expected call of Connect. -func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, clusterAddress, ipAddress interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, clusterAddress, ipAddress, capabilities interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, clusterAddress, ipAddress) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, clusterAddress, ipAddress, capabilities) } // Disconnect mocks base method. @@ -93,18 +121,33 @@ func (mr *MockManagerMockRecorder) GetActiveClusterAddresses(ctx interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddresses", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddresses), ctx) } -// Heartbeat mocks base method. -func (m *MockManager) Heartbeat(ctx context.Context, proxyID string) error { +// GetActiveClusters mocks base method. +func (m *MockManager) GetActiveClusters(ctx context.Context) ([]Cluster, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Heartbeat", ctx, proxyID) + ret := m.ctrl.Call(m, "GetActiveClusters", ctx) + ret0, _ := ret[0].([]Cluster) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveClusters indicates an expected call of GetActiveClusters. +func (mr *MockManagerMockRecorder) GetActiveClusters(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusters", reflect.TypeOf((*MockManager)(nil).GetActiveClusters), ctx) +} + +// Heartbeat mocks base method. +func (m *MockManager) Heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Heartbeat", ctx, proxyID, clusterAddress, ipAddress) ret0, _ := ret[0].(error) return ret0 } // Heartbeat indicates an expected call of Heartbeat. -func (mr *MockManagerMockRecorder) Heartbeat(ctx, proxyID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Heartbeat(ctx, proxyID, clusterAddress, ipAddress interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, proxyID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, proxyID, clusterAddress, ipAddress) } // MockController is a mock of Controller interface. @@ -144,20 +187,6 @@ func (mr *MockControllerMockRecorder) GetOIDCValidationConfig() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOIDCValidationConfig", reflect.TypeOf((*MockController)(nil).GetOIDCValidationConfig)) } -// ClusterSupportsCustomPorts mocks base method. -func (m *MockController) ClusterSupportsCustomPorts(clusterAddr string) *bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", clusterAddr) - ret0, _ := ret[0].(*bool) - return ret0 -} - -// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts. -func (mr *MockControllerMockRecorder) ClusterSupportsCustomPorts(clusterAddr interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockController)(nil).ClusterSupportsCustomPorts), clusterAddr) -} - // GetProxiesForCluster mocks base method. func (m *MockController) GetProxiesForCluster(clusterAddr string) []string { m.ctrl.T.Helper() diff --git a/management/internals/modules/reverseproxy/proxy/proxy.go b/management/internals/modules/reverseproxy/proxy/proxy.go index 699e1ed02..4102e50fe 100644 --- a/management/internals/modules/reverseproxy/proxy/proxy.go +++ b/management/internals/modules/reverseproxy/proxy/proxy.go @@ -2,6 +2,17 @@ package proxy import "time" +// Capabilities describes what a proxy can handle, as reported via gRPC. +// Nil fields mean the proxy never reported this capability. +type Capabilities struct { + // SupportsCustomPorts indicates whether this proxy can bind arbitrary + // ports for TCP/UDP services. TLS uses SNI routing and is not gated. + SupportsCustomPorts *bool + // RequireSubdomain indicates whether a subdomain label is required in + // front of the cluster domain. + RequireSubdomain *bool +} + // Proxy represents a reverse proxy instance type Proxy struct { ID string `gorm:"primaryKey;type:varchar(255)"` @@ -11,6 +22,7 @@ type Proxy struct { ConnectedAt *time.Time DisconnectedAt *time.Time Status string `gorm:"type:varchar(20);not null;index:idx_proxy_cluster_status"` + Capabilities Capabilities `gorm:"embedded"` CreatedAt time.Time UpdatedAt time.Time } @@ -18,3 +30,9 @@ type Proxy struct { func (Proxy) TableName() string { return "proxies" } + +// Cluster represents a group of proxy nodes serving the same address. +type Cluster struct { + Address string + ConnectedProxies int +} diff --git a/management/internals/modules/reverseproxy/service/interface.go b/management/internals/modules/reverseproxy/service/interface.go index 39fd7e3ae..a49cbea35 100644 --- a/management/internals/modules/reverseproxy/service/interface.go +++ b/management/internals/modules/reverseproxy/service/interface.go @@ -4,9 +4,12 @@ package service import ( "context" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" ) type Manager interface { + GetActiveClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) GetAllServices(ctx context.Context, accountID, userID string) ([]*Service, error) GetService(ctx context.Context, accountID, userID, serviceID string) (*Service, error) CreateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) diff --git a/management/internals/modules/reverseproxy/service/interface_mock.go b/management/internals/modules/reverseproxy/service/interface_mock.go index bdc1f3e65..cc5ccbb8e 100644 --- a/management/internals/modules/reverseproxy/service/interface_mock.go +++ b/management/internals/modules/reverseproxy/service/interface_mock.go @@ -9,6 +9,7 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" + proxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" ) // MockManager is a mock of Manager interface. @@ -107,6 +108,21 @@ func (mr *MockManagerMockRecorder) GetAccountServices(ctx, accountID interface{} return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockManager)(nil).GetAccountServices), ctx, accountID) } +// GetActiveClusters mocks base method. +func (m *MockManager) GetActiveClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveClusters", ctx, accountID, userID) + ret0, _ := ret[0].([]proxy.Cluster) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveClusters indicates an expected call of GetActiveClusters. +func (mr *MockManagerMockRecorder) GetActiveClusters(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusters", reflect.TypeOf((*MockManager)(nil).GetActiveClusters), ctx, accountID, userID) +} + // GetAllServices mocks base method. func (m *MockManager) GetAllServices(ctx context.Context, accountID, userID string) ([]*Service, error) { m.ctrl.T.Helper() diff --git a/management/internals/modules/reverseproxy/service/manager/api.go b/management/internals/modules/reverseproxy/service/manager/api.go index c53219d2e..cd81efa88 100644 --- a/management/internals/modules/reverseproxy/service/manager/api.go +++ b/management/internals/modules/reverseproxy/service/manager/api.go @@ -34,6 +34,7 @@ func RegisterEndpoints(manager rpservice.Manager, domainManager domainmanager.Ma accesslogsmanager.RegisterEndpoints(router, accessLogsManager) + router.HandleFunc("/reverse-proxies/clusters", h.getClusters).Methods("GET", "OPTIONS") router.HandleFunc("/reverse-proxies/services", h.getAllServices).Methods("GET", "OPTIONS") router.HandleFunc("/reverse-proxies/services", h.createService).Methods("POST", "OPTIONS") router.HandleFunc("/reverse-proxies/services/{serviceId}", h.getService).Methods("GET", "OPTIONS") @@ -177,3 +178,27 @@ func (h *handler) deleteService(w http.ResponseWriter, r *http.Request) { util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) } + +func (h *handler) getClusters(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + clusters, err := h.manager.GetActiveClusters(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + apiClusters := make([]api.ProxyCluster, 0, len(clusters)) + for _, c := range clusters { + apiClusters = append(apiClusters, api.ProxyCluster{ + Address: c.Address, + ConnectedProxies: c.ConnectedProxies, + }) + } + + util.WriteJSONObject(r.Context(), w, apiClusters) +} diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go index c7a61ddcf..4a7647d90 100644 --- a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go +++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go @@ -75,10 +75,13 @@ func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Stor require.NoError(t, err) mockCtrl := proxy.NewMockController(ctrl) - mockCtrl.EXPECT().ClusterSupportsCustomPorts(gomock.Any()).Return(customPortsSupported).AnyTimes() mockCtrl.EXPECT().SendServiceUpdateToCluster(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() mockCtrl.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes() + mockCaps := proxy.NewMockManager(ctrl) + mockCaps.EXPECT().ClusterSupportsCustomPorts(gomock.Any(), testCluster).Return(customPortsSupported).AnyTimes() + mockCaps.EXPECT().ClusterRequireSubdomain(gomock.Any(), testCluster).Return((*bool)(nil)).AnyTimes() + accountMgr := &mock_server.MockAccountManager{ StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, _ map[string]any) {}, UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, @@ -92,6 +95,7 @@ func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Stor accountManager: accountMgr, permissionsManager: permissions.NewManager(testStore), proxyController: mockCtrl, + capabilities: mockCaps, clusterDeriver: &testClusterDeriver{domains: []string{"test.netbird.io"}}, } mgr.exposeReaper = &exposeReaper{manager: mgr} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index c40961fdc..db393ef38 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/rand/v2" + "net/http" "os" "slices" "strconv" @@ -13,6 +14,8 @@ import ( nbpeer "github.com/netbirdio/netbird/management/server/peer" + resourcetypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" @@ -72,22 +75,30 @@ type ClusterDeriver interface { GetClusterDomains() []string } +// CapabilityProvider queries proxy cluster capabilities from the database. +type CapabilityProvider interface { + ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool +} + type Manager struct { store store.Store accountManager account.Manager permissionsManager permissions.Manager proxyController proxy.Controller + capabilities CapabilityProvider clusterDeriver ClusterDeriver exposeReaper *exposeReaper } // NewManager creates a new service manager. -func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, proxyController proxy.Controller, clusterDeriver ClusterDeriver) *Manager { +func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, proxyController proxy.Controller, capabilities CapabilityProvider, clusterDeriver ClusterDeriver) *Manager { mgr := &Manager{ store: store, accountManager: accountManager, permissionsManager: permissionsManager, proxyController: proxyController, + capabilities: capabilities, clusterDeriver: clusterDeriver, } mgr.exposeReaper = &exposeReaper{manager: mgr} @@ -99,6 +110,19 @@ func (m *Manager) StartExposeReaper(ctx context.Context) { m.exposeReaper.StartExposeReaper(ctx) } +// GetActiveClusters returns all active proxy clusters with their connected proxy count. +func (m *Manager) GetActiveClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + return m.store.GetActiveProxyClusters(ctx) +} + func (m *Manager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) { ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) if err != nil { @@ -220,6 +244,10 @@ func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID stri return status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", service.Domain, err) } service.ProxyCluster = proxyCluster + + if err := m.validateSubdomainRequirement(ctx, service.Domain, proxyCluster); err != nil { + return err + } } service.AccountID = accountID @@ -229,6 +257,12 @@ func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID stri return fmt.Errorf("hash secrets: %w", err) } + for i, h := range service.Auth.HeaderAuths { + if h != nil && h.Enabled && h.Value == "" { + return status.Errorf(status.InvalidArgument, "header_auths[%d]: value is required", i) + } + } + keyPair, err := sessionkey.GenerateKeyPair() if err != nil { return fmt.Errorf("generate session keys: %w", err) @@ -239,6 +273,20 @@ func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID stri return nil } +// validateSubdomainRequirement checks whether the domain can be used bare +// (without a subdomain label) on the given cluster. If the cluster reports +// require_subdomain=true and the domain equals the cluster domain, it rejects. +func (m *Manager) validateSubdomainRequirement(ctx context.Context, domain, cluster string) error { + if domain != cluster { + return nil + } + requireSub := m.capabilities.ClusterRequireSubdomain(ctx, cluster) + if requireSub != nil && *requireSub { + return status.Errorf(status.InvalidArgument, "domain %s requires a subdomain label", domain) + } + return nil +} + func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *service.Service) error { return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if svc.Domain != "" { @@ -272,7 +320,7 @@ func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service if !service.IsL4Protocol(svc.Mode) { return nil } - customPorts := m.proxyController.ClusterSupportsCustomPorts(svc.ProxyCluster) + customPorts := m.capabilities.ClusterSupportsCustomPorts(ctx, svc.ProxyCluster) if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && (customPorts == nil || !*customPorts) { if svc.Source != service.SourceEphemeral { return status.Errorf(status.InvalidArgument, "custom ports not supported on cluster %s", svc.ProxyCluster) @@ -467,50 +515,65 @@ func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, se var updateInfo serviceUpdateInfo err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID) - if err != nil { - return err - } - - if err := validateProtocolChange(existingService.Mode, service.Mode); err != nil { - return err - } - - updateInfo.oldCluster = existingService.ProxyCluster - updateInfo.domainChanged = existingService.Domain != service.Domain - - if updateInfo.domainChanged { - if err := m.handleDomainChange(ctx, transaction, accountID, service); err != nil { - return err - } - } else { - service.ProxyCluster = existingService.ProxyCluster - } - - m.preserveExistingAuthSecrets(service, existingService) - m.preserveServiceMetadata(service, existingService) - m.preserveListenPort(service, existingService) - updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled - - if err := m.ensureL4Port(ctx, transaction, service); err != nil { - return err - } - if err := m.checkPortConflict(ctx, transaction, service); err != nil { - return err - } - if err := validateTargetReferences(ctx, transaction, accountID, service.Targets); err != nil { - return err - } - if err := transaction.UpdateService(ctx, service); err != nil { - return fmt.Errorf("update service: %w", err) - } - - return nil + return m.executeServiceUpdate(ctx, transaction, accountID, service, &updateInfo) }) return &updateInfo, err } +func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo) error { + existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID) + if err != nil { + return err + } + + if existingService.Terminated { + return status.Errorf(status.PermissionDenied, "service is terminated and cannot be updated") + } + + if err := validateProtocolChange(existingService.Mode, service.Mode); err != nil { + return err + } + + updateInfo.oldCluster = existingService.ProxyCluster + updateInfo.domainChanged = existingService.Domain != service.Domain + + if updateInfo.domainChanged { + if err := m.handleDomainChange(ctx, transaction, accountID, service); err != nil { + return err + } + } else { + service.ProxyCluster = existingService.ProxyCluster + } + + if err := m.validateSubdomainRequirement(ctx, service.Domain, service.ProxyCluster); err != nil { + return err + } + + m.preserveExistingAuthSecrets(service, existingService) + if err := validateHeaderAuthValues(service.Auth.HeaderAuths); err != nil { + return err + } + m.preserveServiceMetadata(service, existingService) + m.preserveListenPort(service, existingService) + updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled + + if err := m.ensureL4Port(ctx, transaction, service); err != nil { + return err + } + if err := m.checkPortConflict(ctx, transaction, service); err != nil { + return err + } + if err := validateTargetReferences(ctx, transaction, accountID, service.Targets); err != nil { + return err + } + if err := transaction.UpdateService(ctx, service); err != nil { + return fmt.Errorf("update service: %w", err) + } + + return nil +} + func (m *Manager) handleDomainChange(ctx context.Context, transaction store.Store, accountID string, svc *service.Service) error { if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, svc.ID); err != nil { return err @@ -544,18 +607,52 @@ func isHTTPFamily(mode string) bool { return mode == "" || mode == "http" } -func (m *Manager) preserveExistingAuthSecrets(service, existingService *service.Service) { - if service.Auth.PasswordAuth != nil && service.Auth.PasswordAuth.Enabled && +func (m *Manager) preserveExistingAuthSecrets(svc, existingService *service.Service) { + if svc.Auth.PasswordAuth != nil && svc.Auth.PasswordAuth.Enabled && existingService.Auth.PasswordAuth != nil && existingService.Auth.PasswordAuth.Enabled && - service.Auth.PasswordAuth.Password == "" { - service.Auth.PasswordAuth = existingService.Auth.PasswordAuth + svc.Auth.PasswordAuth.Password == "" { + svc.Auth.PasswordAuth = existingService.Auth.PasswordAuth } - if service.Auth.PinAuth != nil && service.Auth.PinAuth.Enabled && + if svc.Auth.PinAuth != nil && svc.Auth.PinAuth.Enabled && existingService.Auth.PinAuth != nil && existingService.Auth.PinAuth.Enabled && - service.Auth.PinAuth.Pin == "" { - service.Auth.PinAuth = existingService.Auth.PinAuth + svc.Auth.PinAuth.Pin == "" { + svc.Auth.PinAuth = existingService.Auth.PinAuth } + + preserveHeaderAuthHashes(svc.Auth.HeaderAuths, existingService.Auth.HeaderAuths) +} + +// preserveHeaderAuthHashes fills in empty header auth values from the existing +// service so that unchanged secrets are not lost on update. +func preserveHeaderAuthHashes(headers, existing []*service.HeaderAuthConfig) { + if len(headers) == 0 || len(existing) == 0 { + return + } + existingByHeader := make(map[string]string, len(existing)) + for _, h := range existing { + if h != nil && h.Value != "" { + existingByHeader[http.CanonicalHeaderKey(h.Header)] = h.Value + } + } + for _, h := range headers { + if h != nil && h.Enabled && h.Value == "" { + if hash, ok := existingByHeader[http.CanonicalHeaderKey(h.Header)]; ok { + h.Value = hash + } + } + } +} + +// validateHeaderAuthValues checks that all enabled header auths have a value +// (either freshly provided or preserved from the existing service). +func validateHeaderAuthValues(headers []*service.HeaderAuthConfig) error { + for i, h := range headers { + if h != nil && h.Enabled && h.Value == "" { + return status.Errorf(status.InvalidArgument, "header_auths[%d]: value is required", i) + } + } + return nil } func (m *Manager) preserveServiceMetadata(service, existingService *service.Service) { @@ -592,24 +689,53 @@ func validateTargetReferences(ctx context.Context, transaction store.Store, acco for _, target := range targets { switch target.TargetType { case service.TargetTypePeer: - if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthShare, accountID, target.TargetId); err != nil { - if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { - return status.Errorf(status.InvalidArgument, "peer target %q not found in account", target.TargetId) - } - return fmt.Errorf("look up peer target %q: %w", target.TargetId, err) + if err := validatePeerTarget(ctx, transaction, accountID, target); err != nil { + return err } case service.TargetTypeHost, service.TargetTypeSubnet, service.TargetTypeDomain: - if _, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthShare, accountID, target.TargetId); err != nil { - if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { - return status.Errorf(status.InvalidArgument, "resource target %q not found in account", target.TargetId) - } - return fmt.Errorf("look up resource target %q: %w", target.TargetId, err) + if err := validateResourceTarget(ctx, transaction, accountID, target); err != nil { + return err } + default: + return status.Errorf(status.InvalidArgument, "unknown target type %q for target %q", target.TargetType, target.TargetId) } } return nil } +func validatePeerTarget(ctx context.Context, transaction store.Store, accountID string, target *service.Target) error { + if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthShare, accountID, target.TargetId); err != nil { + if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { + return status.Errorf(status.InvalidArgument, "peer target %q not found in account", target.TargetId) + } + return fmt.Errorf("look up peer target %q: %w", target.TargetId, err) + } + return nil +} + +func validateResourceTarget(ctx context.Context, transaction store.Store, accountID string, target *service.Target) error { + resource, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthShare, accountID, target.TargetId) + if err != nil { + if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { + return status.Errorf(status.InvalidArgument, "resource target %q not found in account", target.TargetId) + } + return fmt.Errorf("look up resource target %q: %w", target.TargetId, err) + } + return validateResourceTargetType(target, resource) +} + +// validateResourceTargetType checks that target_type matches the actual network resource type. +func validateResourceTargetType(target *service.Target, resource *resourcetypes.NetworkResource) error { + expected := resourcetypes.NetworkResourceType(target.TargetType) + if resource.Type != expected { + return status.Errorf(status.InvalidArgument, + "target %q has target_type %q but resource is of type %q", + target.TargetId, target.TargetType, resource.Type, + ) + } + return nil +} + func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error { ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) if err != nil { diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index d23c91017..f6e532118 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -19,6 +19,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/mock_server" + resourcetypes "github.com/netbirdio/netbird/management/server/networks/resources/types" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" @@ -1214,3 +1215,126 @@ func TestValidateProtocolChange(t *testing.T) { }) } } + +func TestValidateTargetReferences_ResourceTypeMismatch(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + accountID := "test-account" + + tests := []struct { + name string + targetType rpservice.TargetType + resourceType resourcetypes.NetworkResourceType + wantErr bool + }{ + {"host matches host", rpservice.TargetTypeHost, resourcetypes.Host, false}, + {"domain matches domain", rpservice.TargetTypeDomain, resourcetypes.Domain, false}, + {"subnet matches subnet", rpservice.TargetTypeSubnet, resourcetypes.Subnet, false}, + {"host but resource is domain", rpservice.TargetTypeHost, resourcetypes.Domain, true}, + {"domain but resource is host", rpservice.TargetTypeDomain, resourcetypes.Host, true}, + {"host but resource is subnet", rpservice.TargetTypeHost, resourcetypes.Subnet, true}, + {"subnet but resource is domain", rpservice.TargetTypeSubnet, resourcetypes.Domain, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStore.EXPECT(). + GetNetworkResourceByID(gomock.Any(), store.LockingStrengthShare, accountID, "resource-1"). + Return(&resourcetypes.NetworkResource{Type: tt.resourceType}, nil) + + targets := []*rpservice.Target{ + {TargetId: "resource-1", TargetType: tt.targetType, Host: "10.0.0.1"}, + } + err := validateTargetReferences(ctx, mockStore, accountID, targets) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "target_type") + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateTargetReferences_PeerValid(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + accountID := "test-account" + + mockStore.EXPECT(). + GetPeerByID(gomock.Any(), store.LockingStrengthShare, accountID, "peer-1"). + Return(&nbpeer.Peer{}, nil) + + targets := []*rpservice.Target{ + {TargetId: "peer-1", TargetType: rpservice.TargetTypePeer}, + } + require.NoError(t, validateTargetReferences(ctx, mockStore, accountID, targets)) +} + +func TestValidateSubdomainRequirement(t *testing.T) { + ptrBool := func(b bool) *bool { return &b } + + tests := []struct { + name string + domain string + cluster string + requireSubdomain *bool + wantErr bool + }{ + { + name: "subdomain present, require_subdomain true", + domain: "app.eu1.proxy.netbird.io", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: ptrBool(true), + wantErr: false, + }, + { + name: "bare cluster domain, require_subdomain true", + domain: "eu1.proxy.netbird.io", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: ptrBool(true), + wantErr: true, + }, + { + name: "bare cluster domain, require_subdomain false", + domain: "eu1.proxy.netbird.io", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: ptrBool(false), + wantErr: false, + }, + { + name: "bare cluster domain, require_subdomain nil (default)", + domain: "eu1.proxy.netbird.io", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: nil, + wantErr: false, + }, + { + name: "custom domain apex is not the cluster", + domain: "example.com", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: ptrBool(true), + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + mockCaps := proxy.NewMockManager(ctrl) + mockCaps.EXPECT().ClusterRequireSubdomain(gomock.Any(), tc.cluster).Return(tc.requireSubdomain).AnyTimes() + + mgr := &Manager{capabilities: mockCaps} + err := mgr.validateSubdomainRequirement(context.Background(), tc.domain, tc.cluster) + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a subdomain label") + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go index 623284404..d956013ea 100644 --- a/management/internals/modules/reverseproxy/service/service.go +++ b/management/internals/modules/reverseproxy/service/service.go @@ -7,14 +7,15 @@ import ( "math/big" "net" "net/http" + "net/netip" "net/url" "regexp" + "slices" "strconv" "strings" "time" "github.com/rs/xid" - log "github.com/sirupsen/logrus" "google.golang.org/protobuf/types/known/durationpb" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" @@ -91,10 +92,37 @@ type BearerAuthConfig struct { DistributionGroups []string `json:"distribution_groups,omitempty" gorm:"serializer:json"` } +// HeaderAuthConfig defines a static header-value auth check. +// The proxy compares the incoming header value against the stored hash. +type HeaderAuthConfig struct { + Enabled bool `json:"enabled"` + Header string `json:"header"` + Value string `json:"value"` +} + type AuthConfig struct { PasswordAuth *PasswordAuthConfig `json:"password_auth,omitempty" gorm:"serializer:json"` PinAuth *PINAuthConfig `json:"pin_auth,omitempty" gorm:"serializer:json"` BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty" gorm:"serializer:json"` + HeaderAuths []*HeaderAuthConfig `json:"header_auths,omitempty" gorm:"serializer:json"` +} + +// AccessRestrictions controls who can connect to the service based on IP or geography. +type AccessRestrictions struct { + AllowedCIDRs []string `json:"allowed_cidrs,omitempty" gorm:"serializer:json"` + BlockedCIDRs []string `json:"blocked_cidrs,omitempty" gorm:"serializer:json"` + AllowedCountries []string `json:"allowed_countries,omitempty" gorm:"serializer:json"` + BlockedCountries []string `json:"blocked_countries,omitempty" gorm:"serializer:json"` +} + +// Copy returns a deep copy of the AccessRestrictions. +func (r AccessRestrictions) Copy() AccessRestrictions { + return AccessRestrictions{ + AllowedCIDRs: slices.Clone(r.AllowedCIDRs), + BlockedCIDRs: slices.Clone(r.BlockedCIDRs), + AllowedCountries: slices.Clone(r.AllowedCountries), + BlockedCountries: slices.Clone(r.BlockedCountries), + } } func (a *AuthConfig) HashSecrets() error { @@ -114,6 +142,16 @@ func (a *AuthConfig) HashSecrets() error { a.PinAuth.Pin = hashedPin } + for i, h := range a.HeaderAuths { + if h != nil && h.Enabled && h.Value != "" { + hashedValue, err := argon2id.Hash(h.Value) + if err != nil { + return fmt.Errorf("hash header auth[%d] value: %w", i, err) + } + h.Value = hashedValue + } + } + return nil } @@ -124,6 +162,11 @@ func (a *AuthConfig) ClearSecrets() { if a.PinAuth != nil { a.PinAuth.Pin = "" } + for _, h := range a.HeaderAuths { + if h != nil { + h.Value = "" + } + } } type Meta struct { @@ -141,14 +184,16 @@ type Service struct { ProxyCluster string `gorm:"index"` Targets []*Target `gorm:"foreignKey:ServiceID;constraint:OnDelete:CASCADE"` Enabled bool + Terminated bool PassHostHeader bool RewriteRedirects bool - Auth AuthConfig `gorm:"serializer:json"` - Meta Meta `gorm:"embedded;embeddedPrefix:meta_"` - SessionPrivateKey string `gorm:"column:session_private_key"` - SessionPublicKey string `gorm:"column:session_public_key"` - Source string `gorm:"default:'permanent';index:idx_service_source_peer"` - SourcePeer string `gorm:"index:idx_service_source_peer"` + Auth AuthConfig `gorm:"serializer:json"` + Restrictions AccessRestrictions `gorm:"serializer:json"` + Meta Meta `gorm:"embedded;embeddedPrefix:meta_"` + SessionPrivateKey string `gorm:"column:session_private_key"` + SessionPublicKey string `gorm:"column:session_public_key"` + Source string `gorm:"default:'permanent';index:idx_service_source_peer"` + SourcePeer string `gorm:"index:idx_service_source_peer"` // Mode determines the service type: "http", "tcp", "udp", or "tls". Mode string `gorm:"default:'http'"` ListenPort uint16 @@ -188,6 +233,20 @@ func (s *Service) ToAPIResponse() *api.Service { } } + if len(s.Auth.HeaderAuths) > 0 { + apiHeaders := make([]api.HeaderAuthConfig, 0, len(s.Auth.HeaderAuths)) + for _, h := range s.Auth.HeaderAuths { + if h == nil { + continue + } + apiHeaders = append(apiHeaders, api.HeaderAuthConfig{ + Enabled: h.Enabled, + Header: h.Header, + }) + } + authConfig.HeaderAuths = &apiHeaders + } + // Convert internal targets to API targets apiTargets := make([]api.ServiceTarget, 0, len(s.Targets)) for _, target := range s.Targets { @@ -198,13 +257,15 @@ func (s *Service) ToAPIResponse() *api.Service { Protocol: api.ServiceTargetProtocol(target.Protocol), TargetId: target.TargetId, TargetType: api.ServiceTargetTargetType(target.TargetType), - Enabled: target.Enabled, + Enabled: target.Enabled && !s.Terminated, } opts := targetOptionsToAPI(target.Options) if opts == nil { opts = &api.ServiceTargetOptions{} } - opts.ProxyProtocol = &target.ProxyProtocol + if target.ProxyProtocol { + opts.ProxyProtocol = &target.ProxyProtocol + } st.Options = opts apiTargets = append(apiTargets, st) } @@ -222,18 +283,20 @@ func (s *Service) ToAPIResponse() *api.Service { listenPort := int(s.ListenPort) resp := &api.Service{ - Id: s.ID, - Name: s.Name, - Domain: s.Domain, - Targets: apiTargets, - Enabled: s.Enabled, - PassHostHeader: &s.PassHostHeader, - RewriteRedirects: &s.RewriteRedirects, - Auth: authConfig, - Meta: meta, - Mode: &mode, - ListenPort: &listenPort, - PortAutoAssigned: &s.PortAutoAssigned, + Id: s.ID, + Name: s.Name, + Domain: s.Domain, + Targets: apiTargets, + Enabled: s.Enabled && !s.Terminated, + Terminated: &s.Terminated, + PassHostHeader: &s.PassHostHeader, + RewriteRedirects: &s.RewriteRedirects, + Auth: authConfig, + AccessRestrictions: restrictionsToAPI(s.Restrictions), + Meta: meta, + Mode: &mode, + ListenPort: &listenPort, + PortAutoAssigned: &s.PortAutoAssigned, } if s.ProxyCluster != "" { @@ -263,7 +326,16 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf auth.Oidc = true } - return &proto.ProxyMapping{ + for _, h := range s.Auth.HeaderAuths { + if h != nil && h.Enabled { + auth.HeaderAuths = append(auth.HeaderAuths, &proto.HeaderAuth{ + Header: h.Header, + HashedValue: h.Value, + }) + } + } + + mapping := &proto.ProxyMapping{ Type: operationToProtoType(operation), Id: s.ID, Domain: s.Domain, @@ -276,6 +348,12 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf Mode: s.Mode, ListenPort: int32(s.ListenPort), //nolint:gosec } + + if r := restrictionsToProto(s.Restrictions); r != nil { + mapping.AccessRestrictions = r + } + + return mapping } // buildPathMappings constructs PathMapping entries from targets. @@ -334,8 +412,7 @@ func operationToProtoType(op Operation) proto.ProxyMappingUpdateType { case Delete: return proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED default: - log.Fatalf("unknown operation type: %v", op) - return proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED + panic(fmt.Sprintf("unknown operation type: %v", op)) } } @@ -477,6 +554,10 @@ func (s *Service) FromAPIRequest(req *api.ServiceRequest, accountID string) erro s.Auth = authFromAPI(req.Auth) } + if req.AccessRestrictions != nil { + s.Restrictions = restrictionsFromAPI(req.AccessRestrictions) + } + return nil } @@ -538,9 +619,70 @@ func authFromAPI(reqAuth *api.ServiceAuthConfig) AuthConfig { } auth.BearerAuth = bearerAuth } + if reqAuth.HeaderAuths != nil { + for _, h := range *reqAuth.HeaderAuths { + auth.HeaderAuths = append(auth.HeaderAuths, &HeaderAuthConfig{ + Enabled: h.Enabled, + Header: h.Header, + Value: h.Value, + }) + } + } return auth } +func restrictionsFromAPI(r *api.AccessRestrictions) AccessRestrictions { + if r == nil { + return AccessRestrictions{} + } + var res AccessRestrictions + if r.AllowedCidrs != nil { + res.AllowedCIDRs = *r.AllowedCidrs + } + if r.BlockedCidrs != nil { + res.BlockedCIDRs = *r.BlockedCidrs + } + if r.AllowedCountries != nil { + res.AllowedCountries = *r.AllowedCountries + } + if r.BlockedCountries != nil { + res.BlockedCountries = *r.BlockedCountries + } + return res +} + +func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions { + if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 && len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 { + return nil + } + res := &api.AccessRestrictions{} + if len(r.AllowedCIDRs) > 0 { + res.AllowedCidrs = &r.AllowedCIDRs + } + if len(r.BlockedCIDRs) > 0 { + res.BlockedCidrs = &r.BlockedCIDRs + } + if len(r.AllowedCountries) > 0 { + res.AllowedCountries = &r.AllowedCountries + } + if len(r.BlockedCountries) > 0 { + res.BlockedCountries = &r.BlockedCountries + } + return res +} + +func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions { + if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 && len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 { + return nil + } + return &proto.AccessRestrictions{ + AllowedCidrs: r.AllowedCIDRs, + BlockedCidrs: r.BlockedCIDRs, + AllowedCountries: r.AllowedCountries, + BlockedCountries: r.BlockedCountries, + } +} + func (s *Service) Validate() error { if s.Name == "" { return errors.New("service name is required") @@ -557,6 +699,13 @@ func (s *Service) Validate() error { s.Mode = ModeHTTP } + if err := validateHeaderAuths(s.Auth.HeaderAuths); err != nil { + return err + } + if err := validateAccessRestrictions(&s.Restrictions); err != nil { + return err + } + switch s.Mode { case ModeHTTP: return s.validateHTTPMode() @@ -645,7 +794,7 @@ func (s *Service) validateL4Target(target *Target) error { return errors.New("target_id is required for L4 services") } switch target.TargetType { - case TargetTypePeer, TargetTypeHost: + case TargetTypePeer, TargetTypeHost, TargetTypeDomain: // OK case TargetTypeSubnet: if target.Host == "" { @@ -657,6 +806,21 @@ func (s *Service) validateL4Target(target *Target) error { if target.Path != nil && *target.Path != "" && *target.Path != "/" { return errors.New("path is not supported for L4 services") } + if target.Options.SessionIdleTimeout < 0 { + return errors.New("session_idle_timeout must be positive for L4 services") + } + if target.Options.RequestTimeout < 0 { + return errors.New("request_timeout must be positive for L4 services") + } + if target.Options.SkipTLSVerify { + return errors.New("skip_tls_verify is not supported for L4 services") + } + if target.Options.PathRewrite != "" { + return errors.New("path_rewrite is not supported for L4 services") + } + if len(target.Options.CustomHeaders) > 0 { + return errors.New("custom_headers is not supported for L4 services") + } return nil } @@ -688,11 +852,9 @@ func IsPortBasedProtocol(mode string) bool { } const ( - maxRequestTimeout = 5 * time.Minute - maxSessionIdleTimeout = 10 * time.Minute - maxCustomHeaders = 16 - maxHeaderKeyLen = 128 - maxHeaderValueLen = 4096 + maxCustomHeaders = 16 + maxHeaderKeyLen = 128 + maxHeaderValueLen = 4096 ) // httpHeaderNameRe matches valid HTTP header field names per RFC 7230 token definition. @@ -731,22 +893,12 @@ func validateTargetOptions(idx int, opts *TargetOptions) error { return fmt.Errorf("target %d: unknown path_rewrite mode %q", idx, opts.PathRewrite) } - if opts.RequestTimeout != 0 { - if opts.RequestTimeout <= 0 { - return fmt.Errorf("target %d: request_timeout must be positive", idx) - } - if opts.RequestTimeout > maxRequestTimeout { - return fmt.Errorf("target %d: request_timeout exceeds maximum of %s", idx, maxRequestTimeout) - } + if opts.RequestTimeout < 0 { + return fmt.Errorf("target %d: request_timeout must be positive", idx) } - if opts.SessionIdleTimeout != 0 { - if opts.SessionIdleTimeout <= 0 { - return fmt.Errorf("target %d: session_idle_timeout must be positive", idx) - } - if opts.SessionIdleTimeout > maxSessionIdleTimeout { - return fmt.Errorf("target %d: session_idle_timeout exceeds maximum of %s", idx, maxSessionIdleTimeout) - } + if opts.SessionIdleTimeout < 0 { + return fmt.Errorf("target %d: session_idle_timeout must be positive", idx) } if err := validateCustomHeaders(idx, opts.CustomHeaders); err != nil { @@ -796,6 +948,88 @@ func containsCRLF(s string) bool { return strings.ContainsAny(s, "\r\n") } +func validateHeaderAuths(headers []*HeaderAuthConfig) error { + for i, h := range headers { + if h == nil || !h.Enabled { + continue + } + if h.Header == "" { + return fmt.Errorf("header_auths[%d]: header name is required", i) + } + if !httpHeaderNameRe.MatchString(h.Header) { + return fmt.Errorf("header_auths[%d]: header name %q is not a valid HTTP header name", i, h.Header) + } + canonical := http.CanonicalHeaderKey(h.Header) + if _, ok := hopByHopHeaders[canonical]; ok { + return fmt.Errorf("header_auths[%d]: header %q is a hop-by-hop header and cannot be used for auth", i, h.Header) + } + if _, ok := reservedHeaders[canonical]; ok { + return fmt.Errorf("header_auths[%d]: header %q is managed by the proxy and cannot be used for auth", i, h.Header) + } + if canonical == "Host" { + return fmt.Errorf("header_auths[%d]: Host header cannot be used for auth", i) + } + if len(h.Value) > maxHeaderValueLen { + return fmt.Errorf("header_auths[%d]: value exceeds maximum length of %d", i, maxHeaderValueLen) + } + } + return nil +} + +const ( + maxCIDREntries = 200 + maxCountryEntries = 50 +) + +// validateAccessRestrictions validates and normalizes access restriction +// entries. Country codes are uppercased in place. +func validateAccessRestrictions(r *AccessRestrictions) error { + if len(r.AllowedCIDRs) > maxCIDREntries { + return fmt.Errorf("allowed_cidrs: exceeds maximum of %d entries", maxCIDREntries) + } + if len(r.BlockedCIDRs) > maxCIDREntries { + return fmt.Errorf("blocked_cidrs: exceeds maximum of %d entries", maxCIDREntries) + } + if len(r.AllowedCountries) > maxCountryEntries { + return fmt.Errorf("allowed_countries: exceeds maximum of %d entries", maxCountryEntries) + } + if len(r.BlockedCountries) > maxCountryEntries { + return fmt.Errorf("blocked_countries: exceeds maximum of %d entries", maxCountryEntries) + } + + for i, raw := range r.AllowedCIDRs { + prefix, err := netip.ParsePrefix(raw) + if err != nil { + return fmt.Errorf("allowed_cidrs[%d]: %w", i, err) + } + if prefix != prefix.Masked() { + return fmt.Errorf("allowed_cidrs[%d]: %q has host bits set, use %s instead", i, raw, prefix.Masked()) + } + } + for i, raw := range r.BlockedCIDRs { + prefix, err := netip.ParsePrefix(raw) + if err != nil { + return fmt.Errorf("blocked_cidrs[%d]: %w", i, err) + } + if prefix != prefix.Masked() { + return fmt.Errorf("blocked_cidrs[%d]: %q has host bits set, use %s instead", i, raw, prefix.Masked()) + } + } + for i, code := range r.AllowedCountries { + if len(code) != 2 { + return fmt.Errorf("allowed_countries[%d]: %q must be a 2-letter ISO 3166-1 alpha-2 code", i, code) + } + r.AllowedCountries[i] = strings.ToUpper(code) + } + for i, code := range r.BlockedCountries { + if len(code) != 2 { + return fmt.Errorf("blocked_countries[%d]: %q must be a 2-letter ISO 3166-1 alpha-2 code", i, code) + } + r.BlockedCountries[i] = strings.ToUpper(code) + } + return nil +} + func (s *Service) EventMeta() map[string]any { meta := map[string]any{ "name": s.Name, @@ -827,9 +1061,17 @@ func (s *Service) EventMeta() map[string]any { } func (s *Service) isAuthEnabled() bool { - return (s.Auth.PasswordAuth != nil && s.Auth.PasswordAuth.Enabled) || + if (s.Auth.PasswordAuth != nil && s.Auth.PasswordAuth.Enabled) || (s.Auth.PinAuth != nil && s.Auth.PinAuth.Enabled) || - (s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled) + (s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled) { + return true + } + for _, h := range s.Auth.HeaderAuths { + if h != nil && h.Enabled { + return true + } + } + return false } func (s *Service) Copy() *Service { @@ -866,6 +1108,16 @@ func (s *Service) Copy() *Service { } authCopy.BearerAuth = &ba } + if len(s.Auth.HeaderAuths) > 0 { + authCopy.HeaderAuths = make([]*HeaderAuthConfig, len(s.Auth.HeaderAuths)) + for i, h := range s.Auth.HeaderAuths { + if h == nil { + continue + } + hCopy := *h + authCopy.HeaderAuths[i] = &hCopy + } + } return &Service{ ID: s.ID, @@ -875,9 +1127,11 @@ func (s *Service) Copy() *Service { ProxyCluster: s.ProxyCluster, Targets: targets, Enabled: s.Enabled, + Terminated: s.Terminated, PassHostHeader: s.PassHostHeader, RewriteRedirects: s.RewriteRedirects, Auth: authCopy, + Restrictions: s.Restrictions.Copy(), Meta: s.Meta, SessionPrivateKey: s.SessionPrivateKey, SessionPublicKey: s.SessionPublicKey, diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go index a8a8ae5d6..ff54cb79f 100644 --- a/management/internals/modules/reverseproxy/service/service_test.go +++ b/management/internals/modules/reverseproxy/service/service_test.go @@ -120,9 +120,9 @@ func TestValidateTargetOptions_RequestTimeout(t *testing.T) { }{ {"valid 30s", 30 * time.Second, ""}, {"valid 2m", 2 * time.Minute, ""}, + {"valid 10m", 10 * time.Minute, ""}, {"zero is fine", 0, ""}, {"negative", -1 * time.Second, "must be positive"}, - {"exceeds max", 10 * time.Minute, "exceeds maximum"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -847,6 +847,32 @@ func TestValidate_TLSSubnetValid(t *testing.T) { require.NoError(t, rp.Validate()) } +func TestValidate_L4DomainTargetValid(t *testing.T) { + modes := []struct { + mode string + port uint16 + proto string + }{ + {"tcp", 5432, "tcp"}, + {"tls", 443, "tcp"}, + {"udp", 5432, "udp"}, + } + for _, m := range modes { + t.Run(m.mode, func(t *testing.T) { + rp := &Service{ + Name: m.mode + "-domain", + Mode: m.mode, + Domain: "cluster.test", + ListenPort: m.port, + Targets: []*Target{ + {TargetId: "resource-1", TargetType: TargetTypeDomain, Protocol: m.proto, Port: m.port, Enabled: true}, + }, + } + require.NoError(t, rp.Validate()) + }) + } +} + func TestValidate_HTTPProxyProtocolRejected(t *testing.T) { rp := validProxy() rp.Targets[0].ProxyProtocol = true @@ -909,3 +935,107 @@ func TestExposeServiceRequest_Validate_HTTPAllowsAuth(t *testing.T) { req := ExposeServiceRequest{Port: 8080, Mode: "http", Pin: "123456"} require.NoError(t, req.Validate()) } + +func TestValidate_HeaderAuths(t *testing.T) { + t.Run("single valid header", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "X-API-Key", Value: "secret"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("multiple headers same canonical name allowed", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "Authorization", Value: "Bearer token-1"}, + {Enabled: true, Header: "Authorization", Value: "Bearer token-2"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("multiple headers different case same canonical allowed", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "x-api-key", Value: "key-1"}, + {Enabled: true, Header: "X-Api-Key", Value: "key-2"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("multiple different headers allowed", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "Authorization", Value: "Bearer tok"}, + {Enabled: true, Header: "X-API-Key", Value: "key"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("empty header name rejected", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "", Value: "val"}, + }, + } + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "header name is required") + }) + + t.Run("hop-by-hop header rejected", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "Connection", Value: "val"}, + }, + } + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "hop-by-hop") + }) + + t.Run("host header rejected", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "Host", Value: "val"}, + }, + } + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "Host header cannot be used") + }) + + t.Run("disabled entries skipped", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: false, Header: "", Value: ""}, + {Enabled: true, Header: "X-Key", Value: "val"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("value too long rejected", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "X-Key", Value: strings.Repeat("a", maxHeaderValueLen+1)}, + }, + } + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum length") + }) +} diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index a32cf6046..6064bd5b6 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -195,7 +195,7 @@ func (s *BaseServer) RecordsManager() records.Manager { func (s *BaseServer) ServiceManager() service.Manager { return Create(s, func() service.Manager { - return nbreverseproxy.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.ServiceProxyController(), s.ReverseProxyDomainManager()) + return nbreverseproxy.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.ServiceProxyController(), s.ProxyManager(), s.ReverseProxyDomainManager()) }) } @@ -212,9 +212,6 @@ func (s *BaseServer) ProxyManager() proxy.Manager { func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager { return Create(s, func() *manager.Manager { m := manager.NewManager(s.Store(), s.ProxyManager(), s.PermissionsManager(), s.AccountManager()) - s.AfterInit(func(s *BaseServer) { - m.SetClusterCapabilities(s.ServiceProxyController()) - }) return &m }) } diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 31a0ba0db..07732cea6 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "errors" "fmt" + "net/http" "net/url" "strings" "sync" @@ -122,7 +123,7 @@ func (s *ProxyServiceServer) cleanupStaleProxies(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - if err := s.proxyManager.CleanupStale(ctx, 10*time.Minute); err != nil { + if err := s.proxyManager.CleanupStale(ctx, 1*time.Hour); err != nil { log.WithContext(ctx).Debugf("Failed to cleanup stale proxies: %v", err) } } @@ -181,9 +182,21 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", proxyID, err) } - // Register proxy in database - if err := s.proxyManager.Connect(ctx, proxyID, proxyAddress, peerInfo); err != nil { - log.WithContext(ctx).Warnf("Failed to register proxy %s in database: %v", proxyID, err) + // Register proxy in database with capabilities + var caps *proxy.Capabilities + if c := req.GetCapabilities(); c != nil { + caps = &proxy.Capabilities{ + SupportsCustomPorts: c.SupportsCustomPorts, + RequireSubdomain: c.RequireSubdomain, + } + } + if err := s.proxyManager.Connect(ctx, proxyID, proxyAddress, peerInfo, caps); err != nil { + log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", proxyID, err) + s.connectedProxies.Delete(proxyID) + if unregErr := s.proxyController.UnregisterProxyFromCluster(ctx, conn.address, proxyID); unregErr != nil { + log.WithContext(ctx).Debugf("cleanup after Connect failure for proxy %s: %v", proxyID, unregErr) + } + return status.Errorf(codes.Internal, "register proxy in database: %v", err) } log.WithFields(log.Fields{ @@ -214,7 +227,7 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest go s.sender(conn, errChan) // Start heartbeat goroutine - go s.heartbeat(connCtx, proxyID) + go s.heartbeat(connCtx, proxyID, proxyAddress, peerInfo) select { case err := <-errChan: @@ -225,14 +238,14 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest } // heartbeat updates the proxy's last_seen timestamp every minute -func (s *ProxyServiceServer) heartbeat(ctx context.Context, proxyID string) { +func (s *ProxyServiceServer) heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: - if err := s.proxyManager.Heartbeat(ctx, proxyID); err != nil { + if err := s.proxyManager.Heartbeat(ctx, proxyID, clusterAddress, ipAddress); err != nil { log.WithContext(ctx).Debugf("Failed to update proxy %s heartbeat: %v", proxyID, err) } case <-ctx.Done(): @@ -296,6 +309,9 @@ func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn * } m := service.ToProtoMapping(rpservice.Create, token, s.GetOIDCValidationConfig()) + if !proxyAcceptsMapping(conn, m) { + continue + } mappings = append(mappings, m) } return mappings, nil @@ -444,22 +460,46 @@ func (s *ProxyServiceServer) SendServiceUpdateToCluster(ctx context.Context, upd log.Debugf("Sending service update to cluster %s", clusterAddr) for _, proxyID := range proxyIDs { - if connVal, ok := s.connectedProxies.Load(proxyID); ok { - conn := connVal.(*proxyConnection) - msg := s.perProxyMessage(updateResponse, proxyID) - if msg == nil { - continue - } - select { - case conn.sendChan <- msg: - log.WithContext(ctx).Debugf("Sent service update with id %s to proxy %s in cluster %s", update.Id, proxyID, clusterAddr) - default: - log.WithContext(ctx).Warnf("Failed to send service update to proxy %s in cluster %s (channel full)", proxyID, clusterAddr) - } + connVal, ok := s.connectedProxies.Load(proxyID) + if !ok { + continue + } + conn := connVal.(*proxyConnection) + if !proxyAcceptsMapping(conn, update) { + log.WithContext(ctx).Debugf("Skipping proxy %s: does not support custom ports for mapping %s", proxyID, update.Id) + continue + } + msg := s.perProxyMessage(updateResponse, proxyID) + if msg == nil { + continue + } + select { + case conn.sendChan <- msg: + log.WithContext(ctx).Debugf("Sent service update with id %s to proxy %s in cluster %s", update.Id, proxyID, clusterAddr) + default: + log.WithContext(ctx).Warnf("Failed to send service update to proxy %s in cluster %s (channel full)", proxyID, clusterAddr) } } } +// proxyAcceptsMapping returns whether the proxy should receive this mapping. +// Old proxies that never reported capabilities are skipped for non-TLS L4 +// mappings with a custom listen port, since they don't understand the +// protocol. Proxies that report capabilities (even SupportsCustomPorts=false) +// are new enough to handle the mapping. TLS uses SNI routing and works on +// any proxy. Delete operations are always sent so proxies can clean up. +func proxyAcceptsMapping(conn *proxyConnection, mapping *proto.ProxyMapping) bool { + if mapping.Type == proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED { + return true + } + if mapping.ListenPort == 0 || mapping.Mode == "tls" { + return true + } + // Old proxies that never reported capabilities don't understand + // custom port mappings. + return conn.capabilities != nil && conn.capabilities.SupportsCustomPorts != nil +} + // perProxyMessage returns a copy of update with a fresh one-time token for // create/update operations. For delete operations the original mapping is // used unchanged because proxies do not need to authenticate for removal. @@ -493,48 +533,20 @@ func (s *ProxyServiceServer) perProxyMessage(update *proto.GetMappingUpdateRespo // should be set on the copy. func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping { return &proto.ProxyMapping{ - Type: m.Type, - Id: m.Id, - AccountId: m.AccountId, - Domain: m.Domain, - Path: m.Path, - Auth: m.Auth, - PassHostHeader: m.PassHostHeader, - RewriteRedirects: m.RewriteRedirects, - Mode: m.Mode, - ListenPort: m.ListenPort, + Type: m.Type, + Id: m.Id, + AccountId: m.AccountId, + Domain: m.Domain, + Path: m.Path, + Auth: m.Auth, + PassHostHeader: m.PassHostHeader, + RewriteRedirects: m.RewriteRedirects, + Mode: m.Mode, + ListenPort: m.ListenPort, + AccessRestrictions: m.AccessRestrictions, } } -// ClusterSupportsCustomPorts returns whether any connected proxy in the given -// cluster reports custom port support. Returns nil if no proxy has reported -// capabilities (old proxies that predate the field). -func (s *ProxyServiceServer) ClusterSupportsCustomPorts(clusterAddr string) *bool { - if s.proxyController == nil { - return nil - } - - var hasCapabilities bool - for _, pid := range s.proxyController.GetProxiesForCluster(clusterAddr) { - connVal, ok := s.connectedProxies.Load(pid) - if !ok { - continue - } - conn := connVal.(*proxyConnection) - if conn.capabilities == nil || conn.capabilities.SupportsCustomPorts == nil { - continue - } - if *conn.capabilities.SupportsCustomPorts { - return ptr(true) - } - hasCapabilities = true - } - if hasCapabilities { - return ptr(false) - } - return nil -} - func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId()) if err != nil { @@ -561,6 +573,8 @@ func (s *ProxyServiceServer) authenticateRequest(ctx context.Context, req *proto return s.authenticatePIN(ctx, req.GetId(), v, service.Auth.PinAuth) case *proto.AuthenticateRequest_Password: return s.authenticatePassword(ctx, req.GetId(), v, service.Auth.PasswordAuth) + case *proto.AuthenticateRequest_HeaderAuth: + return s.authenticateHeader(ctx, req.GetId(), v, service.Auth.HeaderAuths) default: return false, "", "" } @@ -594,6 +608,35 @@ func (s *ProxyServiceServer) authenticatePassword(ctx context.Context, serviceID return true, "password-user", proxyauth.MethodPassword } +func (s *ProxyServiceServer) authenticateHeader(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_HeaderAuth, auths []*rpservice.HeaderAuthConfig) (bool, string, proxyauth.Method) { + if len(auths) == 0 { + log.WithContext(ctx).Debugf("header authentication attempted but no header auths configured for service %s", serviceID) + return false, "", "" + } + + headerName := http.CanonicalHeaderKey(req.HeaderAuth.GetHeaderName()) + + var lastErr error + for _, auth := range auths { + if auth == nil || !auth.Enabled { + continue + } + if headerName != "" && http.CanonicalHeaderKey(auth.Header) != headerName { + continue + } + if err := argon2id.Verify(req.HeaderAuth.GetHeaderValue(), auth.Value); err != nil { + lastErr = err + continue + } + return true, "header-user", proxyauth.MethodHeader + } + + if lastErr != nil { + s.logAuthenticationError(ctx, lastErr, "Header") + } + return false, "", "" +} + func (s *ProxyServiceServer) logAuthenticationError(ctx context.Context, err error, authType string) { if errors.Is(err, argon2id.ErrMismatchedHashAndPassword) { log.WithContext(ctx).Tracef("%s authentication failed: invalid credentials", authType) @@ -752,6 +795,9 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU if err != nil { return nil, status.Errorf(codes.InvalidArgument, "parse redirect url: %v", err) } + if redirectURL.Scheme != "https" && redirectURL.Scheme != "http" { + return nil, status.Errorf(codes.InvalidArgument, "redirect URL must use http or https scheme") + } // Validate redirectURL against known service endpoints to avoid abuse of OIDC redirection. services, err := s.serviceManager.GetAccountServices(ctx, req.GetAccountId()) if err != nil { @@ -836,12 +882,9 @@ func (s *ProxyServiceServer) generateHMAC(input string) string { // ValidateState validates the state parameter from an OAuth callback. // Returns the original redirect URL if valid, or an error if invalid. +// The HMAC is verified before consuming the PKCE verifier to prevent +// an attacker from invalidating a legitimate user's auth flow. func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) { - verifier, ok := s.pkceVerifierStore.LoadAndDelete(state) - if !ok { - return "", "", errors.New("no verifier for state") - } - // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) parts := strings.Split(state, "|") if len(parts) != 3 { @@ -865,6 +908,12 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL return "", "", errors.New("invalid state signature") } + // Consume the PKCE verifier only after HMAC validation passes. + verifier, ok := s.pkceVerifierStore.LoadAndDelete(state) + if !ok { + return "", "", errors.New("no verifier for state") + } + return verifier, redirectURL, nil } diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go index 22fe4506b..0fa9a0dc1 100644 --- a/management/internals/shared/grpc/proxy_group_access_test.go +++ b/management/internals/shared/grpc/proxy_group_access_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/types" ) @@ -90,6 +91,10 @@ func (m *mockReverseProxyManager) StopServiceFromPeer(_ context.Context, _, _, _ func (m *mockReverseProxyManager) StartExposeReaper(_ context.Context) {} +func (m *mockReverseProxyManager) GetActiveClusters(_ context.Context, _, _ string) ([]proxy.Cluster, error) { + return nil, nil +} + type mockUsersManager struct { users map[string]*types.User err error diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go index 1a4ea3330..d5aed3dee 100644 --- a/management/internals/shared/grpc/proxy_test.go +++ b/management/internals/shared/grpc/proxy_test.go @@ -53,10 +53,6 @@ func (c *testProxyController) UnregisterProxyFromCluster(_ context.Context, clus return nil } -func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool { - return ptr(true) -} - func (c *testProxyController) GetProxiesForCluster(clusterAddr string) []string { c.mu.Lock() defer c.mu.Unlock() @@ -351,14 +347,14 @@ func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) { const cluster = "proxy.example.com" - // Proxy A supports custom ports. - chA := registerFakeProxyWithCaps(s, "proxy-a", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}) - // Proxy B does NOT support custom ports (shared cloud proxy). - chB := registerFakeProxyWithCaps(s, "proxy-b", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + // Modern proxy reports capabilities. + chModern := registerFakeProxyWithCaps(s, "proxy-modern", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}) + // Legacy proxy never reported capabilities (nil). + chLegacy := registerFakeProxy(s, "proxy-legacy", cluster) ctx := context.Background() - // TLS passthrough works on all proxies regardless of custom port support. + // TLS passthrough with custom port: all proxies receive it (SNI routing). tlsMapping := &proto.ProxyMapping{ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, Id: "service-tls", @@ -371,12 +367,26 @@ func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) { s.SendServiceUpdateToCluster(ctx, tlsMapping, cluster) - msgA := drainMapping(chA) - msgB := drainMapping(chB) - assert.NotNil(t, msgA, "proxy-a should receive TLS mapping") - assert.NotNil(t, msgB, "proxy-b should receive TLS mapping (passthrough works on all proxies)") + assert.NotNil(t, drainMapping(chModern), "modern proxy should receive TLS mapping") + assert.NotNil(t, drainMapping(chLegacy), "legacy proxy should receive TLS mapping (SNI works on all)") - // Send an HTTP mapping: both should receive it. + // TCP mapping with custom port: only modern proxy receives it. + tcpMapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-tcp", + AccountId: "account-1", + Domain: "db.example.com", + Mode: "tcp", + ListenPort: 5432, + Path: []*proto.PathMapping{{Target: "10.0.0.5:5432"}}, + } + + s.SendServiceUpdateToCluster(ctx, tcpMapping, cluster) + + assert.NotNil(t, drainMapping(chModern), "modern proxy should receive TCP custom-port mapping") + assert.Nil(t, drainMapping(chLegacy), "legacy proxy should NOT receive TCP custom-port mapping") + + // HTTP mapping (no listen port): both receive it. httpMapping := &proto.ProxyMapping{ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, Id: "service-http", @@ -387,10 +397,16 @@ func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) { s.SendServiceUpdateToCluster(ctx, httpMapping, cluster) - msgA = drainMapping(chA) - msgB = drainMapping(chB) - assert.NotNil(t, msgA, "proxy-a should receive HTTP mapping") - assert.NotNil(t, msgB, "proxy-b should receive HTTP mapping") + assert.NotNil(t, drainMapping(chModern), "modern proxy should receive HTTP mapping") + assert.NotNil(t, drainMapping(chLegacy), "legacy proxy should receive HTTP mapping") + + // Proxy that reports SupportsCustomPorts=false still receives custom-port + // mappings because it understands the protocol (it's new enough). + chNewNoCustom := registerFakeProxyWithCaps(s, "proxy-new-no-custom", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + + s.SendServiceUpdateToCluster(ctx, tcpMapping, cluster) + + assert.NotNil(t, drainMapping(chNewNoCustom), "new proxy with SupportsCustomPorts=false should still receive mapping") } func TestSendServiceUpdateToCluster_TLSNotFiltered(t *testing.T) { @@ -404,7 +420,8 @@ func TestSendServiceUpdateToCluster_TLSNotFiltered(t *testing.T) { const cluster = "proxy.example.com" - chShared := registerFakeProxyWithCaps(s, "proxy-shared", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + // Legacy proxy (no capabilities) still receives TLS since it uses SNI. + chLegacy := registerFakeProxy(s, "proxy-legacy", cluster) tlsMapping := &proto.ProxyMapping{ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, @@ -417,8 +434,8 @@ func TestSendServiceUpdateToCluster_TLSNotFiltered(t *testing.T) { s.SendServiceUpdateToCluster(context.Background(), tlsMapping, cluster) - msg := drainMapping(chShared) - assert.NotNil(t, msg, "shared proxy should receive TLS mapping even without custom port support") + msg := drainMapping(chLegacy) + assert.NotNil(t, msg, "legacy proxy should receive TLS mapping (SNI works without custom port support)") } // TestServiceModifyNotifications exercises every possible modification @@ -585,7 +602,7 @@ func TestServiceModifyNotifications(t *testing.T) { s.SetProxyController(newTestProxyController()) const cluster = "proxy.example.com" chModern := registerFakeProxyWithCaps(s, "modern", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}) - chLegacy := registerFakeProxyWithCaps(s, "legacy", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + chLegacy := registerFakeProxy(s, "legacy", cluster) // TLS passthrough works on all proxies regardless of custom port support s.SendServiceUpdateToCluster(ctx, tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), cluster) @@ -604,7 +621,7 @@ func TestServiceModifyNotifications(t *testing.T) { } s.SetProxyController(newTestProxyController()) const cluster = "proxy.example.com" - chLegacy := registerFakeProxyWithCaps(s, "legacy", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + chLegacy := registerFakeProxy(s, "legacy", cluster) mapping := tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) mapping.ListenPort = 0 // default port diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index 647e8443b..2f77de86e 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" "github.com/netbirdio/netbird/management/server/store" @@ -320,6 +321,10 @@ func (m *testValidateSessionServiceManager) StopServiceFromPeer(_ context.Contex func (m *testValidateSessionServiceManager) StartExposeReaper(_ context.Context) {} +func (m *testValidateSessionServiceManager) GetActiveClusters(_ context.Context, _, _ string) ([]proxy.Cluster, error) { + return nil, nil +} + type testValidateSessionProxyManager struct{} func (m *testValidateSessionProxyManager) Connect(_ context.Context, _, _, _ string) error { @@ -338,6 +343,10 @@ func (m *testValidateSessionProxyManager) GetActiveClusterAddresses(_ context.Co return nil, nil } +func (m *testValidateSessionProxyManager) GetActiveClusters(_ context.Context) ([]proxy.Cluster, error) { + return nil, nil +} + func (m *testValidateSessionProxyManager) CleanupStale(_ context.Context, _ time.Duration) error { return nil } diff --git a/management/server/account_test.go b/management/server/account_test.go index fdec43617..548cf31d4 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -3138,7 +3138,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU if err != nil { return nil, nil, err } - manager.SetServiceManager(reverseproxymanager.NewManager(store, manager, permissionsManager, proxyController, nil)) + manager.SetServiceManager(reverseproxymanager.NewManager(store, manager, permissionsManager, proxyController, proxyManager, nil)) return manager, updateManager, nil } diff --git a/management/server/geolocation/geolocation.go b/management/server/geolocation/geolocation.go index c0179a1c4..30fd493e8 100644 --- a/management/server/geolocation/geolocation.go +++ b/management/server/geolocation/geolocation.go @@ -44,6 +44,12 @@ type Record struct { GeonameID uint `maxminddb:"geoname_id"` ISOCode string `maxminddb:"iso_code"` } `maxminddb:"country"` + Subdivisions []struct { + ISOCode string `maxminddb:"iso_code"` + Names struct { + En string `maxminddb:"en"` + } `maxminddb:"names"` + } `maxminddb:"subdivisions"` } type City struct { diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index 3bed54e80..922bf4352 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/store" @@ -433,6 +434,10 @@ func (m *testServiceManager) StopServiceFromPeer(_ context.Context, _, _, _ stri func (m *testServiceManager) StartExposeReaper(_ context.Context) {} +func (m *testServiceManager) GetActiveClusters(_ context.Context, _, _ string) ([]nbproxy.Cluster, error) { + return nil, nil +} + func createTestState(t *testing.T, ps *nbgrpc.ProxyServiceServer, redirectURL string) string { t.Helper() diff --git a/management/server/http/testing/integration/accounts_handler_integration_test.go b/management/server/http/testing/integration/accounts_handler_integration_test.go new file mode 100644 index 000000000..511730ee5 --- /dev/null +++ b/management/server/http/testing/integration/accounts_handler_integration_test.go @@ -0,0 +1,238 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Accounts_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, true}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all accounts", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/accounts.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/accounts", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Account{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + account := got[0] + assert.Equal(t, "test.com", account.Domain) + assert.Equal(t, "private", account.DomainCategory) + assert.Equal(t, true, account.Settings.PeerLoginExpirationEnabled) + assert.Equal(t, 86400, account.Settings.PeerLoginExpiration) + assert.Equal(t, false, account.Settings.RegularUsersViewBlocked) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Accounts_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + trueVal := true + falseVal := false + + tt := []struct { + name string + expectedStatus int + requestBody *api.AccountRequest + verifyResponse func(t *testing.T, account *api.Account) + verifyDB func(t *testing.T, account *types.Account) + }{ + { + name: "Disable peer login expiration", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: false, + PeerLoginExpiration: 86400, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.Equal(t, false, account.Settings.PeerLoginExpirationEnabled) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, false, dbAccount.Settings.PeerLoginExpirationEnabled) + }, + }, + { + name: "Update peer login expiration to 48h", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 172800, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.Equal(t, 172800, account.Settings.PeerLoginExpiration) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, 172800*time.Second, dbAccount.Settings.PeerLoginExpiration) + }, + }, + { + name: "Enable regular users view blocked", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400, + RegularUsersViewBlocked: true, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.Equal(t, true, account.Settings.RegularUsersViewBlocked) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, true, dbAccount.Settings.RegularUsersViewBlocked) + }, + }, + { + name: "Enable groups propagation", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400, + GroupsPropagationEnabled: &trueVal, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.NotNil(t, account.Settings.GroupsPropagationEnabled) + assert.Equal(t, true, *account.Settings.GroupsPropagationEnabled) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, true, dbAccount.Settings.GroupsPropagationEnabled) + }, + }, + { + name: "Enable JWT groups", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400, + GroupsPropagationEnabled: &falseVal, + JwtGroupsEnabled: &trueVal, + JwtGroupsClaimName: stringPointer("groups"), + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.NotNil(t, account.Settings.JwtGroupsEnabled) + assert.Equal(t, true, *account.Settings.JwtGroupsEnabled) + assert.NotNil(t, account.Settings.JwtGroupsClaimName) + assert.Equal(t, "groups", *account.Settings.JwtGroupsClaimName) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, true, dbAccount.Settings.JWTGroupsEnabled) + assert.Equal(t, "groups", dbAccount.Settings.JWTGroupsClaimName) + }, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/accounts.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/accounts/{accountId}", "{accountId}", testing_tools.TestAccountId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + got := &api.Account{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, testing_tools.TestAccountId, got.Id) + assert.Equal(t, "test.com", got.Domain) + tc.verifyResponse(t, got) + + db := testing_tools.GetDB(t, am.GetStore()) + dbAccount := testing_tools.VerifyAccountSettings(t, db) + tc.verifyDB(t, dbAccount) + }) + } + } +} + +func stringPointer(s string) *string { + return &s +} diff --git a/management/server/http/testing/integration/dns_handler_integration_test.go b/management/server/http/testing/integration/dns_handler_integration_test.go new file mode 100644 index 000000000..7ada5e462 --- /dev/null +++ b/management/server/http/testing/integration/dns_handler_integration_test.go @@ -0,0 +1,554 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Nameservers_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all nameservers", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/dns/nameservers", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.NameserverGroup{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testNSGroup", got[0].Name) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Nameservers_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + nsGroupId string + expectedStatus int + expectGroup bool + }{ + { + name: "Get existing nameserver group", + nsGroupId: "testNSGroupId", + expectedStatus: http.StatusOK, + expectGroup: true, + }, + { + name: "Get non-existing nameserver group", + nsGroupId: "nonExistingNSGroupId", + expectedStatus: http.StatusNotFound, + expectGroup: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/dns/nameservers/{nsgroupId}", "{nsgroupId}", tc.nsGroupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectGroup { + got := &api.NameserverGroup{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, "testNSGroupId", got.Id) + assert.Equal(t, "testNSGroup", got.Name) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Nameservers_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + requestBody *api.PostApiDnsNameserversJSONRequestBody + expectedStatus int + verifyResponse func(t *testing.T, nsGroup *api.NameserverGroup) + }{ + { + name: "Create nameserver group with single NS", + requestBody: &api.PostApiDnsNameserversJSONRequestBody{ + Name: "newNSGroup", + Description: "a new nameserver group", + Nameservers: []api.Nameserver{ + {Ip: "8.8.8.8", NsType: "udp", Port: 53}, + }, + Groups: []string{testing_tools.TestGroupId}, + Primary: false, + Domains: []string{"test.com"}, + Enabled: true, + SearchDomainsEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, nsGroup *api.NameserverGroup) { + t.Helper() + assert.NotEmpty(t, nsGroup.Id) + assert.Equal(t, "newNSGroup", nsGroup.Name) + assert.Equal(t, 1, len(nsGroup.Nameservers)) + assert.Equal(t, false, nsGroup.Primary) + }, + }, + { + name: "Create primary nameserver group", + requestBody: &api.PostApiDnsNameserversJSONRequestBody{ + Name: "primaryNS", + Description: "primary nameserver", + Nameservers: []api.Nameserver{ + {Ip: "1.1.1.1", NsType: "udp", Port: 53}, + }, + Groups: []string{testing_tools.TestGroupId}, + Primary: true, + Domains: []string{}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, nsGroup *api.NameserverGroup) { + t.Helper() + assert.Equal(t, true, nsGroup.Primary) + }, + }, + { + name: "Create nameserver group with empty groups", + requestBody: &api.PostApiDnsNameserversJSONRequestBody{ + Name: "emptyGroupsNS", + Description: "no groups", + Nameservers: []api.Nameserver{ + {Ip: "8.8.8.8", NsType: "udp", Port: 53}, + }, + Groups: []string{}, + Primary: true, + Domains: []string{}, + Enabled: true, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/dns/nameservers", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NameserverGroup{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify the created NS group directly in the DB + db := testing_tools.GetDB(t, am.GetStore()) + dbNS := testing_tools.VerifyNSGroupInDB(t, db, got.Id) + assert.Equal(t, got.Name, dbNS.Name) + assert.Equal(t, got.Primary, dbNS.Primary) + assert.Equal(t, len(got.Nameservers), len(dbNS.NameServers)) + assert.Equal(t, got.Enabled, dbNS.Enabled) + assert.Equal(t, got.SearchDomainsEnabled, dbNS.SearchDomainsEnabled) + } + }) + } + } +} + +func Test_Nameservers_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + nsGroupId string + requestBody *api.PutApiDnsNameserversNsgroupIdJSONRequestBody + expectedStatus int + verifyResponse func(t *testing.T, nsGroup *api.NameserverGroup) + }{ + { + name: "Update nameserver group name", + nsGroupId: "testNSGroupId", + requestBody: &api.PutApiDnsNameserversNsgroupIdJSONRequestBody{ + Name: "updatedNSGroup", + Description: "updated description", + Nameservers: []api.Nameserver{ + {Ip: "1.1.1.1", NsType: "udp", Port: 53}, + }, + Groups: []string{testing_tools.TestGroupId}, + Primary: false, + Domains: []string{"example.com"}, + Enabled: true, + SearchDomainsEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, nsGroup *api.NameserverGroup) { + t.Helper() + assert.Equal(t, "updatedNSGroup", nsGroup.Name) + assert.Equal(t, "updated description", nsGroup.Description) + }, + }, + { + name: "Update non-existing nameserver group", + nsGroupId: "nonExistingNSGroupId", + requestBody: &api.PutApiDnsNameserversNsgroupIdJSONRequestBody{ + Name: "whatever", + Nameservers: []api.Nameserver{ + {Ip: "1.1.1.1", NsType: "udp", Port: 53}, + }, + Groups: []string{testing_tools.TestGroupId}, + Primary: true, + Domains: []string{}, + Enabled: true, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/dns/nameservers/{nsgroupId}", "{nsgroupId}", tc.nsGroupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NameserverGroup{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify the updated NS group directly in the DB + db := testing_tools.GetDB(t, am.GetStore()) + dbNS := testing_tools.VerifyNSGroupInDB(t, db, tc.nsGroupId) + assert.Equal(t, "updatedNSGroup", dbNS.Name) + assert.Equal(t, "updated description", dbNS.Description) + assert.Equal(t, false, dbNS.Primary) + assert.Equal(t, true, dbNS.Enabled) + assert.Equal(t, 1, len(dbNS.NameServers)) + assert.Equal(t, false, dbNS.SearchDomainsEnabled) + } + }) + } + } +} + +func Test_Nameservers_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + nsGroupId string + expectedStatus int + }{ + { + name: "Delete existing nameserver group", + nsGroupId: "testNSGroupId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing nameserver group", + nsGroupId: "nonExistingNSGroupId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/dns/nameservers/{nsgroupId}", "{nsgroupId}", tc.nsGroupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + + // Verify deletion in DB for successful deletes by privileged users + if tc.expectedStatus == http.StatusOK && user.expectResponse { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyNSGroupNotInDB(t, db, tc.nsGroupId) + } + }) + } + } +} + +func Test_DnsSettings_Get(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get DNS settings", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/dns/settings", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := &api.DNSSettings{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.NotNil(t, got.DisabledManagementGroups) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_DnsSettings_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + requestBody *api.PutApiDnsSettingsJSONRequestBody + expectedStatus int + verifyResponse func(t *testing.T, settings *api.DNSSettings) + expectedDBDisabledMgmtLen int + expectedDBDisabledMgmtItem string + }{ + { + name: "Update disabled management groups", + requestBody: &api.PutApiDnsSettingsJSONRequestBody{ + DisabledManagementGroups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, settings *api.DNSSettings) { + t.Helper() + assert.Equal(t, 1, len(settings.DisabledManagementGroups)) + assert.Equal(t, testing_tools.TestGroupId, settings.DisabledManagementGroups[0]) + }, + expectedDBDisabledMgmtLen: 1, + expectedDBDisabledMgmtItem: testing_tools.TestGroupId, + }, + { + name: "Update with empty disabled management groups", + requestBody: &api.PutApiDnsSettingsJSONRequestBody{ + DisabledManagementGroups: []string{}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, settings *api.DNSSettings) { + t.Helper() + assert.Equal(t, 0, len(settings.DisabledManagementGroups)) + }, + expectedDBDisabledMgmtLen: 0, + }, + { + name: "Update with non-existing group", + requestBody: &api.PutApiDnsSettingsJSONRequestBody{ + DisabledManagementGroups: []string{"nonExistingGroupId"}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, "/api/dns/settings", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.DNSSettings{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify DNS settings directly in the DB + db := testing_tools.GetDB(t, am.GetStore()) + dbAccount := testing_tools.VerifyAccountSettings(t, db) + assert.Equal(t, tc.expectedDBDisabledMgmtLen, len(dbAccount.DNSSettings.DisabledManagementGroups)) + if tc.expectedDBDisabledMgmtItem != "" { + assert.Contains(t, dbAccount.DNSSettings.DisabledManagementGroups, tc.expectedDBDisabledMgmtItem) + } + } + }) + } + } +} diff --git a/management/server/http/testing/integration/events_handler_integration_test.go b/management/server/http/testing/integration/events_handler_integration_test.go new file mode 100644 index 000000000..6611b60ee --- /dev/null +++ b/management/server/http/testing/integration/events_handler_integration_test.go @@ -0,0 +1,105 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Events_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all events", func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/events.sql", nil, false) + + // First, perform a mutation to generate an event (create a group as admin) + groupBody, err := json.Marshal(&api.GroupRequest{Name: "eventTestGroup"}) + if err != nil { + t.Fatalf("Failed to marshal group request: %v", err) + } + createReq := testing_tools.BuildRequest(t, groupBody, http.MethodPost, "/api/groups", testing_tools.TestAdminId) + createRecorder := httptest.NewRecorder() + apiHandler.ServeHTTP(createRecorder, createReq) + assert.Equal(t, http.StatusOK, createRecorder.Code, "Failed to create group to generate event") + + // Now query events + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/events", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Event{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 1, "Expected at least one event after creating a group") + + // Verify the group creation event exists + found := false + for _, event := range got { + if event.ActivityCode == "group.add" { + found = true + assert.Equal(t, testing_tools.TestAdminId, event.InitiatorId) + assert.Equal(t, "Group created", event.Activity) + break + } + } + assert.True(t, found, "Expected to find a group.add event") + }) + } +} + +func Test_Events_GetAll_Empty(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/events.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/events", testing_tools.TestAdminId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, true) + if !expectResponse { + return + } + + got := []api.Event{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 0, len(got), "Expected empty events list when no mutations have been performed") + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } +} diff --git a/management/server/http/testing/integration/groups_handler_integration_test.go b/management/server/http/testing/integration/groups_handler_integration_test.go new file mode 100644 index 000000000..edb43f3f3 --- /dev/null +++ b/management/server/http/testing/integration/groups_handler_integration_test.go @@ -0,0 +1,382 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Groups_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, true}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all groups", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/groups", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Group{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 2) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Groups_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, true}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + groupId string + expectedStatus int + expectGroup bool + }{ + { + name: "Get existing group", + groupId: testing_tools.TestGroupId, + expectedStatus: http.StatusOK, + expectGroup: true, + }, + { + name: "Get non-existing group", + groupId: "nonExistingGroupId", + expectedStatus: http.StatusNotFound, + expectGroup: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/groups/{groupId}", "{groupId}", tc.groupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectGroup { + got := &api.Group{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.groupId, got.Id) + assert.Equal(t, "testGroupName", got.Name) + assert.Equal(t, 1, got.PeersCount) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Groups_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + requestBody *api.GroupRequest + expectedStatus int + verifyResponse func(t *testing.T, group *api.Group) + }{ + { + name: "Create group with valid name", + requestBody: &api.GroupRequest{ + Name: "brandNewGroup", + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, group *api.Group) { + t.Helper() + assert.NotEmpty(t, group.Id) + assert.Equal(t, "brandNewGroup", group.Name) + assert.Equal(t, 0, group.PeersCount) + }, + }, + { + name: "Create group with peers", + requestBody: &api.GroupRequest{ + Name: "groupWithPeers", + Peers: &[]string{testing_tools.TestPeerId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, group *api.Group) { + t.Helper() + assert.NotEmpty(t, group.Id) + assert.Equal(t, "groupWithPeers", group.Name) + assert.Equal(t, 1, group.PeersCount) + }, + }, + { + name: "Create group with empty name", + requestBody: &api.GroupRequest{ + Name: "", + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/groups", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Group{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify group exists in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbGroup := testing_tools.VerifyGroupInDB(t, db, got.Id) + assert.Equal(t, tc.requestBody.Name, dbGroup.Name) + } + }) + } + } +} + +func Test_Groups_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + groupId string + requestBody *api.GroupRequest + expectedStatus int + verifyResponse func(t *testing.T, group *api.Group) + }{ + { + name: "Update group name", + groupId: testing_tools.TestGroupId, + requestBody: &api.GroupRequest{ + Name: "updatedGroupName", + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, group *api.Group) { + t.Helper() + assert.Equal(t, testing_tools.TestGroupId, group.Id) + assert.Equal(t, "updatedGroupName", group.Name) + }, + }, + { + name: "Update group peers", + groupId: testing_tools.TestGroupId, + requestBody: &api.GroupRequest{ + Name: "testGroupName", + Peers: &[]string{}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, group *api.Group) { + t.Helper() + assert.Equal(t, 0, group.PeersCount) + }, + }, + { + name: "Update with empty name", + groupId: testing_tools.TestGroupId, + requestBody: &api.GroupRequest{ + Name: "", + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Update non-existing group", + groupId: "nonExistingGroupId", + requestBody: &api.GroupRequest{ + Name: "someName", + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/groups/{groupId}", "{groupId}", tc.groupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Group{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated group in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbGroup := testing_tools.VerifyGroupInDB(t, db, tc.groupId) + assert.Equal(t, tc.requestBody.Name, dbGroup.Name) + } + }) + } + } +} + +func Test_Groups_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + groupId string + expectedStatus int + }{ + { + name: "Delete existing group not in use", + groupId: testing_tools.NewGroupId, + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing group", + groupId: "nonExistingGroupId", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/groups/{groupId}", "{groupId}", tc.groupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if expectResponse && tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyGroupNotInDB(t, db, tc.groupId) + } + }) + } + } +} diff --git a/management/server/http/testing/integration/networks_handler_integration_test.go b/management/server/http/testing/integration/networks_handler_integration_test.go new file mode 100644 index 000000000..4cb6b268b --- /dev/null +++ b/management/server/http/testing/integration/networks_handler_integration_test.go @@ -0,0 +1,1434 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Networks_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all networks", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.Network{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testNetworkId", got[0].Id) + assert.Equal(t, "testNetwork", got[0].Name) + assert.Equal(t, "test network description", *got[0].Description) + assert.GreaterOrEqual(t, len(got[0].Routers), 1) + assert.GreaterOrEqual(t, len(got[0].Resources), 1) + assert.GreaterOrEqual(t, got[0].RoutingPeersCount, 1) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Networks_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + expectedStatus int + expectNetwork bool + }{ + { + name: "Get existing network", + networkId: "testNetworkId", + expectedStatus: http.StatusOK, + expectNetwork: true, + }, + { + name: "Get non-existing network", + networkId: "nonExistingNetworkId", + expectedStatus: http.StatusNotFound, + expectNetwork: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/networks/{networkId}", "{networkId}", tc.networkId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectNetwork { + got := &api.Network{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.networkId, got.Id) + assert.Equal(t, "testNetwork", got.Name) + assert.Equal(t, "test network description", *got.Description) + assert.GreaterOrEqual(t, len(got.Routers), 1) + assert.GreaterOrEqual(t, len(got.Resources), 1) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Networks_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + desc := "new network description" + + tt := []struct { + name string + requestBody *api.NetworkRequest + expectedStatus int + verifyResponse func(t *testing.T, network *api.Network) + }{ + { + name: "Create network with name and description", + requestBody: &api.NetworkRequest{ + Name: "newNetwork", + Description: &desc, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, network *api.Network) { + t.Helper() + assert.NotEmpty(t, network.Id) + assert.Equal(t, "newNetwork", network.Name) + assert.Equal(t, "new network description", *network.Description) + assert.Empty(t, network.Routers) + assert.Empty(t, network.Resources) + assert.Equal(t, 0, network.RoutingPeersCount) + }, + }, + { + name: "Create network with name only", + requestBody: &api.NetworkRequest{ + Name: "simpleNetwork", + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, network *api.Network) { + t.Helper() + assert.NotEmpty(t, network.Id) + assert.Equal(t, "simpleNetwork", network.Name) + }, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/networks", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Network{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_Networks_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + updatedDesc := "updated description" + + tt := []struct { + name string + networkId string + requestBody *api.NetworkRequest + expectedStatus int + verifyResponse func(t *testing.T, network *api.Network) + }{ + { + name: "Update network name", + networkId: "testNetworkId", + requestBody: &api.NetworkRequest{ + Name: "updatedNetwork", + Description: &updatedDesc, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, network *api.Network) { + t.Helper() + assert.Equal(t, "testNetworkId", network.Id) + assert.Equal(t, "updatedNetwork", network.Name) + assert.Equal(t, "updated description", *network.Description) + }, + }, + { + name: "Update non-existing network", + networkId: "nonExistingNetworkId", + requestBody: &api.NetworkRequest{ + Name: "whatever", + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/networks/{networkId}", "{networkId}", tc.networkId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Network{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_Networks_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + expectedStatus int + }{ + { + name: "Delete existing network", + networkId: "testNetworkId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing network", + networkId: "nonExistingNetworkId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/networks/{networkId}", "{networkId}", tc.networkId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + }) + } + } +} + +func Test_Networks_Delete_Cascades(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + // Delete the network + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, "/api/networks/testNetworkId", testing_tools.TestAdminId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + testing_tools.ReadResponse(t, recorder, http.StatusOK, true) + + // Verify network is gone + req = testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId", testing_tools.TestAdminId) + recorder = httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + testing_tools.ReadResponse(t, recorder, http.StatusNotFound, true) + + // Verify routers in that network are gone + req = testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId/routers", testing_tools.TestAdminId) + recorder = httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + content, _ := testing_tools.ReadResponse(t, recorder, http.StatusOK, true) + var routers []*api.NetworkRouter + require.NoError(t, json.Unmarshal(content, &routers)) + assert.Empty(t, routers) + + // Verify resources in that network are gone + req = testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId/resources", testing_tools.TestAdminId) + recorder = httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + content, _ = testing_tools.ReadResponse(t, recorder, http.StatusOK, true) + var resources []*api.NetworkResource + require.NoError(t, json.Unmarshal(content, &resources)) + assert.Empty(t, resources) +} + +func Test_NetworkResources_GetAllInNetwork(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all resources in network", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId/resources", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.NetworkResource{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testResourceId", got[0].Id) + assert.Equal(t, "testResource", got[0].Name) + assert.Equal(t, api.NetworkResourceType("host"), got[0].Type) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_NetworkResources_GetAllInAccount(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all resources in account", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/resources", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.NetworkResource{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 1) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_NetworkResources_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + resourceId string + expectedStatus int + expectResource bool + }{ + { + name: "Get existing resource", + networkId: "testNetworkId", + resourceId: "testResourceId", + expectedStatus: http.StatusOK, + expectResource: true, + }, + { + name: "Get non-existing resource", + networkId: "testNetworkId", + resourceId: "nonExistingResourceId", + expectedStatus: http.StatusNotFound, + expectResource: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + path := fmt.Sprintf("/api/networks/%s/resources/%s", tc.networkId, tc.resourceId) + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectResource { + got := &api.NetworkResource{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.resourceId, got.Id) + assert.Equal(t, "testResource", got.Name) + assert.Equal(t, api.NetworkResourceType("host"), got.Type) + assert.Equal(t, "3.3.3.3/32", got.Address) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_NetworkResources_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + desc := "new resource" + + tt := []struct { + name string + networkId string + requestBody *api.NetworkResourceRequest + expectedStatus int + verifyResponse func(t *testing.T, resource *api.NetworkResource) + }{ + { + name: "Create host resource with IP", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "hostResource", + Description: &desc, + Address: "1.1.1.1", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.NotEmpty(t, resource.Id) + assert.Equal(t, "hostResource", resource.Name) + assert.Equal(t, api.NetworkResourceType("host"), resource.Type) + assert.Equal(t, "1.1.1.1/32", resource.Address) + assert.True(t, resource.Enabled) + }, + }, + { + name: "Create host resource with CIDR /32", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "hostCIDR", + Address: "10.0.0.1/32", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("host"), resource.Type) + assert.Equal(t, "10.0.0.1/32", resource.Address) + }, + }, + { + name: "Create subnet resource", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "subnetResource", + Address: "192.168.0.0/24", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("subnet"), resource.Type) + assert.Equal(t, "192.168.0.0/24", resource.Address) + }, + }, + { + name: "Create domain resource", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "domainResource", + Address: "example.com", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("domain"), resource.Type) + assert.Equal(t, "example.com", resource.Address) + }, + }, + { + name: "Create wildcard domain resource", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "wildcardDomain", + Address: "*.example.com", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("domain"), resource.Type) + assert.Equal(t, "*.example.com", resource.Address) + }, + }, + { + name: "Create disabled resource", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "disabledResource", + Address: "5.5.5.5", + Groups: []string{testing_tools.TestGroupId}, + Enabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.False(t, resource.Enabled) + }, + }, + { + name: "Create resource with invalid address", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "invalidResource", + Address: "not-a-valid-address!!!", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "Create resource with empty groups", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "noGroupsResource", + Address: "7.7.7.7", + Groups: []string{}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.NotEmpty(t, resource.Id) + }, + }, + { + name: "Create resource with duplicate name", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "testResource", + Address: "8.8.8.8", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + path := fmt.Sprintf("/api/networks/%s/resources", tc.networkId) + req := testing_tools.BuildRequest(t, body, http.MethodPost, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NetworkResource{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_NetworkResources_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + updatedDesc := "updated resource" + + tt := []struct { + name string + networkId string + resourceId string + requestBody *api.NetworkResourceRequest + expectedStatus int + verifyResponse func(t *testing.T, resource *api.NetworkResource) + }{ + { + name: "Update resource name and address", + networkId: "testNetworkId", + resourceId: "testResourceId", + requestBody: &api.NetworkResourceRequest{ + Name: "updatedResource", + Description: &updatedDesc, + Address: "4.4.4.4", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, "testResourceId", resource.Id) + assert.Equal(t, "updatedResource", resource.Name) + assert.Equal(t, "updated resource", *resource.Description) + assert.Equal(t, "4.4.4.4/32", resource.Address) + }, + }, + { + name: "Update resource to subnet type", + networkId: "testNetworkId", + resourceId: "testResourceId", + requestBody: &api.NetworkResourceRequest{ + Name: "testResource", + Address: "10.0.0.0/16", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("subnet"), resource.Type) + assert.Equal(t, "10.0.0.0/16", resource.Address) + }, + }, + { + name: "Update resource to domain type", + networkId: "testNetworkId", + resourceId: "testResourceId", + requestBody: &api.NetworkResourceRequest{ + Name: "testResource", + Address: "myservice.example.com", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("domain"), resource.Type) + assert.Equal(t, "myservice.example.com", resource.Address) + }, + }, + { + name: "Update non-existing resource", + networkId: "testNetworkId", + resourceId: "nonExistingResourceId", + requestBody: &api.NetworkResourceRequest{ + Name: "whatever", + Address: "1.2.3.4", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + path := fmt.Sprintf("/api/networks/%s/resources/%s", tc.networkId, tc.resourceId) + req := testing_tools.BuildRequest(t, body, http.MethodPut, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NetworkResource{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_NetworkResources_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + resourceId string + expectedStatus int + }{ + { + name: "Delete existing resource", + networkId: "testNetworkId", + resourceId: "testResourceId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing resource", + networkId: "testNetworkId", + resourceId: "nonExistingResourceId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + path := fmt.Sprintf("/api/networks/%s/resources/%s", tc.networkId, tc.resourceId) + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + }) + } + } +} + +func Test_NetworkRouters_GetAllInNetwork(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all routers in network", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId/routers", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.NetworkRouter{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testRouterId", got[0].Id) + assert.Equal(t, "testPeerId", *got[0].Peer) + assert.True(t, got[0].Masquerade) + assert.Equal(t, 100, got[0].Metric) + assert.True(t, got[0].Enabled) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_NetworkRouters_GetAllInAccount(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all routers in account", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/routers", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.NetworkRouter{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 1) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_NetworkRouters_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + routerId string + expectedStatus int + expectRouter bool + }{ + { + name: "Get existing router", + networkId: "testNetworkId", + routerId: "testRouterId", + expectedStatus: http.StatusOK, + expectRouter: true, + }, + { + name: "Get non-existing router", + networkId: "testNetworkId", + routerId: "nonExistingRouterId", + expectedStatus: http.StatusNotFound, + expectRouter: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + path := fmt.Sprintf("/api/networks/%s/routers/%s", tc.networkId, tc.routerId) + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectRouter { + got := &api.NetworkRouter{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.routerId, got.Id) + assert.Equal(t, "testPeerId", *got.Peer) + assert.True(t, got.Masquerade) + assert.Equal(t, 100, got.Metric) + assert.True(t, got.Enabled) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_NetworkRouters_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + peerID := "testPeerId" + peerGroups := []string{testing_tools.TestGroupId} + + tt := []struct { + name string + networkId string + requestBody *api.NetworkRouterRequest + expectedStatus int + verifyResponse func(t *testing.T, router *api.NetworkRouter) + }{ + { + name: "Create router with peer", + networkId: "testNetworkId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: true, + Metric: 200, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.NotEmpty(t, router.Id) + assert.Equal(t, peerID, *router.Peer) + assert.True(t, router.Masquerade) + assert.Equal(t, 200, router.Metric) + assert.True(t, router.Enabled) + }, + }, + { + name: "Create router with peer groups", + networkId: "testNetworkId", + requestBody: &api.NetworkRouterRequest{ + PeerGroups: &peerGroups, + Masquerade: false, + Metric: 300, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.NotEmpty(t, router.Id) + assert.NotNil(t, router.PeerGroups) + assert.Equal(t, 1, len(*router.PeerGroups)) + assert.False(t, router.Masquerade) + assert.Equal(t, 300, router.Metric) + assert.True(t, router.Enabled) // always true on creation + }, + }, + { + name: "Create router with both peer and peer_groups", + networkId: "testNetworkId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + PeerGroups: &peerGroups, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.NotEmpty(t, router.Id) + assert.Equal(t, peerID, *router.Peer) + assert.Equal(t, 1, len(*router.PeerGroups)) + }, + }, + { + name: "Create router in non-existing network", + networkId: "nonExistingNetworkId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "Create router enabled is always true", + networkId: "testNetworkId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: false, + Metric: 50, + Enabled: false, // handler sets to true + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.True(t, router.Enabled) // always true on creation + }, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + path := fmt.Sprintf("/api/networks/%s/routers", tc.networkId) + req := testing_tools.BuildRequest(t, body, http.MethodPost, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NetworkRouter{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_NetworkRouters_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + peerID := "testPeerId" + peerGroups := []string{testing_tools.TestGroupId} + + tt := []struct { + name string + networkId string + routerId string + requestBody *api.NetworkRouterRequest + expectedStatus int + verifyResponse func(t *testing.T, router *api.NetworkRouter) + }{ + { + name: "Update router metric and masquerade", + networkId: "testNetworkId", + routerId: "testRouterId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: false, + Metric: 500, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.Equal(t, "testRouterId", router.Id) + assert.False(t, router.Masquerade) + assert.Equal(t, 500, router.Metric) + }, + }, + { + name: "Update router to use peer groups", + networkId: "testNetworkId", + routerId: "testRouterId", + requestBody: &api.NetworkRouterRequest{ + PeerGroups: &peerGroups, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.NotNil(t, router.PeerGroups) + assert.Equal(t, 1, len(*router.PeerGroups)) + }, + }, + { + name: "Update router disabled", + networkId: "testNetworkId", + routerId: "testRouterId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: true, + Metric: 100, + Enabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.False(t, router.Enabled) + }, + }, + { + name: "Update non-existing router creates it", + networkId: "testNetworkId", + routerId: "nonExistingRouterId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.Equal(t, "nonExistingRouterId", router.Id) + }, + }, + { + name: "Update router with both peer and peer_groups", + networkId: "testNetworkId", + routerId: "testRouterId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + PeerGroups: &peerGroups, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.Equal(t, "testRouterId", router.Id) + assert.Equal(t, peerID, *router.Peer) + assert.Equal(t, 1, len(*router.PeerGroups)) + }, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + path := fmt.Sprintf("/api/networks/%s/routers/%s", tc.networkId, tc.routerId) + req := testing_tools.BuildRequest(t, body, http.MethodPut, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NetworkRouter{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_NetworkRouters_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + routerId string + expectedStatus int + }{ + { + name: "Delete existing router", + networkId: "testNetworkId", + routerId: "testRouterId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing router", + networkId: "testNetworkId", + routerId: "nonExistingRouterId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + path := fmt.Sprintf("/api/networks/%s/routers/%s", tc.networkId, tc.routerId) + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + }) + } + } +} diff --git a/management/server/http/testing/integration/peers_handler_integration_test.go b/management/server/http/testing/integration/peers_handler_integration_test.go new file mode 100644 index 000000000..17a9e94a6 --- /dev/null +++ b/management/server/http/testing/integration/peers_handler_integration_test.go @@ -0,0 +1,605 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +const ( + testPeerId2 = "testPeerId2" +) + +func Test_Peers_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: true, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + for _, user := range users { + t.Run(user.name+" - Get all peers", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/peers", user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + var got []api.PeerBatch + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 2, "Expected at least 2 peers") + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Peers_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: true, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + requestId string + verifyResponse func(t *testing.T, peer *api.Peer) + }{ + { + name: "Get existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}", + requestId: testing_tools.TestPeerId, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testing_tools.TestPeerId, peer.Id) + assert.Equal(t, "test-peer-1", peer.Name) + assert.Equal(t, "test-host-1", peer.Hostname) + assert.Equal(t, "Debian GNU/Linux ", peer.Os) + assert.Equal(t, "0.12.0", peer.Version) + assert.Equal(t, false, peer.SshEnabled) + assert.Equal(t, true, peer.LoginExpirationEnabled) + }, + }, + { + name: "Get second existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}", + requestId: testPeerId2, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testPeerId2, peer.Id) + assert.Equal(t, "test-peer-2", peer.Name) + assert.Equal(t, "test-host-2", peer.Hostname) + assert.Equal(t, "Ubuntu ", peer.Os) + assert.Equal(t, true, peer.SshEnabled) + assert.Equal(t, false, peer.LoginExpirationEnabled) + assert.Equal(t, true, peer.Connected) + }, + }, + { + name: "Get non-existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}", + requestId: "nonExistingPeerId", + expectedStatus: http.StatusNotFound, + verifyResponse: nil, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, tc.requestType, strings.Replace(tc.requestPath, "{peerId}", tc.requestId, 1), user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Peer{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Peers_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: false, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + tt := []struct { + name string + expectedStatus int + requestBody *api.PeerRequest + requestType string + requestPath string + requestId string + verifyResponse func(t *testing.T, peer *api.Peer) + }{ + { + name: "Update peer name", + requestType: http.MethodPut, + requestPath: "/api/peers/{peerId}", + requestId: testing_tools.TestPeerId, + requestBody: &api.PeerRequest{ + Name: "updated-peer-name", + SshEnabled: false, + LoginExpirationEnabled: true, + InactivityExpirationEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testing_tools.TestPeerId, peer.Id) + assert.Equal(t, "updated-peer-name", peer.Name) + assert.Equal(t, false, peer.SshEnabled) + assert.Equal(t, true, peer.LoginExpirationEnabled) + }, + }, + { + name: "Enable SSH on peer", + requestType: http.MethodPut, + requestPath: "/api/peers/{peerId}", + requestId: testing_tools.TestPeerId, + requestBody: &api.PeerRequest{ + Name: "test-peer-1", + SshEnabled: true, + LoginExpirationEnabled: true, + InactivityExpirationEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testing_tools.TestPeerId, peer.Id) + assert.Equal(t, "test-peer-1", peer.Name) + assert.Equal(t, true, peer.SshEnabled) + assert.Equal(t, true, peer.LoginExpirationEnabled) + }, + }, + { + name: "Disable login expiration on peer", + requestType: http.MethodPut, + requestPath: "/api/peers/{peerId}", + requestId: testing_tools.TestPeerId, + requestBody: &api.PeerRequest{ + Name: "test-peer-1", + SshEnabled: false, + LoginExpirationEnabled: false, + InactivityExpirationEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testing_tools.TestPeerId, peer.Id) + assert.Equal(t, false, peer.LoginExpirationEnabled) + }, + }, + { + name: "Update non-existing peer", + requestType: http.MethodPut, + requestPath: "/api/peers/{peerId}", + requestId: "nonExistingPeerId", + requestBody: &api.PeerRequest{ + Name: "updated-name", + SshEnabled: false, + LoginExpirationEnabled: false, + InactivityExpirationEnabled: false, + }, + expectedStatus: http.StatusNotFound, + verifyResponse: nil, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, tc.requestType, strings.Replace(tc.requestPath, "{peerId}", tc.requestId, 1), user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Peer{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated peer in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbPeer := testing_tools.VerifyPeerInDB(t, db, tc.requestId) + assert.Equal(t, tc.requestBody.Name, dbPeer.Name) + assert.Equal(t, tc.requestBody.SshEnabled, dbPeer.SSHEnabled) + assert.Equal(t, tc.requestBody.LoginExpirationEnabled, dbPeer.LoginExpirationEnabled) + assert.Equal(t, tc.requestBody.InactivityExpirationEnabled, dbPeer.InactivityExpirationEnabled) + } + }) + } + } +} + +func Test_Peers_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: false, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + requestId string + }{ + { + name: "Delete existing peer", + requestType: http.MethodDelete, + requestPath: "/api/peers/{peerId}", + requestId: testPeerId2, + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing peer", + requestType: http.MethodDelete, + requestPath: "/api/peers/{peerId}", + requestId: "nonExistingPeerId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, tc.requestType, strings.Replace(tc.requestPath, "{peerId}", tc.requestId, 1), user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + // Verify peer is actually deleted in DB + if tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyPeerNotInDB(t, db, tc.requestId) + } + }) + } + } +} + +func Test_Peers_GetAccessiblePeers(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: false, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + requestId string + }{ + { + name: "Get accessible peers for existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}/accessible-peers", + requestId: testing_tools.TestPeerId, + expectedStatus: http.StatusOK, + }, + { + name: "Get accessible peers for non-existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}/accessible-peers", + requestId: "nonExistingPeerId", + expectedStatus: http.StatusOK, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, tc.requestType, strings.Replace(tc.requestPath, "{peerId}", tc.requestId, 1), user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectedStatus == http.StatusOK { + var got []api.AccessiblePeer + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + // The accessible peers list should be a valid array (may be empty if no policies connect peers) + assert.NotNil(t, got, "Expected accessible peers to be a valid array") + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} diff --git a/management/server/http/testing/integration/policies_handler_integration_test.go b/management/server/http/testing/integration/policies_handler_integration_test.go new file mode 100644 index 000000000..6f3624fb5 --- /dev/null +++ b/management/server/http/testing/integration/policies_handler_integration_test.go @@ -0,0 +1,488 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Policies_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all policies", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/policies", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Policy{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testPolicy", got[0].Name) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Policies_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + policyId string + expectedStatus int + expectPolicy bool + }{ + { + name: "Get existing policy", + policyId: "testPolicyId", + expectedStatus: http.StatusOK, + expectPolicy: true, + }, + { + name: "Get non-existing policy", + policyId: "nonExistingPolicyId", + expectedStatus: http.StatusNotFound, + expectPolicy: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/policies/{policyId}", "{policyId}", tc.policyId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectPolicy { + got := &api.Policy{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.NotNil(t, got.Id) + assert.Equal(t, tc.policyId, *got.Id) + assert.Equal(t, "testPolicy", got.Name) + assert.Equal(t, true, got.Enabled) + assert.GreaterOrEqual(t, len(got.Rules), 1) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Policies_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + srcGroups := []string{testing_tools.TestGroupId} + dstGroups := []string{testing_tools.TestGroupId} + + tt := []struct { + name string + requestBody *api.PolicyCreate + expectedStatus int + verifyResponse func(t *testing.T, policy *api.Policy) + }{ + { + name: "Create policy with accept rule", + requestBody: &api.PolicyCreate{ + Name: "newPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "allowAll", + Enabled: true, + Action: "accept", + Protocol: "all", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.NotNil(t, policy.Id) + assert.Equal(t, "newPolicy", policy.Name) + assert.Equal(t, true, policy.Enabled) + assert.Equal(t, 1, len(policy.Rules)) + assert.Equal(t, "allowAll", policy.Rules[0].Name) + }, + }, + { + name: "Create policy with drop rule", + requestBody: &api.PolicyCreate{ + Name: "dropPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "dropAll", + Enabled: true, + Action: "drop", + Protocol: "all", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.Equal(t, "dropPolicy", policy.Name) + }, + }, + { + name: "Create policy with TCP rule and ports", + requestBody: &api.PolicyCreate{ + Name: "tcpPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "tcpRule", + Enabled: true, + Action: "accept", + Protocol: "tcp", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + Ports: &[]string{"80", "443"}, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.Equal(t, "tcpPolicy", policy.Name) + assert.NotNil(t, policy.Rules[0].Ports) + assert.Equal(t, 2, len(*policy.Rules[0].Ports)) + }, + }, + { + name: "Create policy with empty name", + requestBody: &api.PolicyCreate{ + Name: "", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "rule", + Enabled: true, + Action: "accept", + Protocol: "all", + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create policy with no rules", + requestBody: &api.PolicyCreate{ + Name: "noRulesPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/policies", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Policy{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify policy exists in DB with correct fields + db := testing_tools.GetDB(t, am.GetStore()) + dbPolicy := testing_tools.VerifyPolicyInDB(t, db, *got.Id) + assert.Equal(t, tc.requestBody.Name, dbPolicy.Name) + assert.Equal(t, tc.requestBody.Enabled, dbPolicy.Enabled) + assert.Equal(t, len(tc.requestBody.Rules), len(dbPolicy.Rules)) + } + }) + } + } +} + +func Test_Policies_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + srcGroups := []string{testing_tools.TestGroupId} + dstGroups := []string{testing_tools.TestGroupId} + + tt := []struct { + name string + policyId string + requestBody *api.PolicyCreate + expectedStatus int + verifyResponse func(t *testing.T, policy *api.Policy) + }{ + { + name: "Update policy name", + policyId: "testPolicyId", + requestBody: &api.PolicyCreate{ + Name: "updatedPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "testRule", + Enabled: true, + Action: "accept", + Protocol: "all", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.Equal(t, "updatedPolicy", policy.Name) + }, + }, + { + name: "Update policy enabled state", + policyId: "testPolicyId", + requestBody: &api.PolicyCreate{ + Name: "testPolicy", + Enabled: false, + Rules: []api.PolicyRuleUpdate{ + { + Name: "testRule", + Enabled: true, + Action: "accept", + Protocol: "all", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.Equal(t, false, policy.Enabled) + }, + }, + { + name: "Update non-existing policy", + policyId: "nonExistingPolicyId", + requestBody: &api.PolicyCreate{ + Name: "whatever", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "rule", + Enabled: true, + Action: "accept", + Protocol: "all", + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/policies/{policyId}", "{policyId}", tc.policyId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Policy{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated policy in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbPolicy := testing_tools.VerifyPolicyInDB(t, db, tc.policyId) + assert.Equal(t, tc.requestBody.Name, dbPolicy.Name) + assert.Equal(t, tc.requestBody.Enabled, dbPolicy.Enabled) + } + }) + } + } +} + +func Test_Policies_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + policyId string + expectedStatus int + }{ + { + name: "Delete existing policy", + policyId: "testPolicyId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing policy", + policyId: "nonExistingPolicyId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/policies/{policyId}", "{policyId}", tc.policyId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if expectResponse && tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyPolicyNotInDB(t, db, tc.policyId) + } + }) + } + } +} diff --git a/management/server/http/testing/integration/routes_handler_integration_test.go b/management/server/http/testing/integration/routes_handler_integration_test.go new file mode 100644 index 000000000..eeb0c3025 --- /dev/null +++ b/management/server/http/testing/integration/routes_handler_integration_test.go @@ -0,0 +1,455 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Routes_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all routes", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/routes", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Route{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 2, len(got)) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Routes_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + routeId string + expectedStatus int + expectRoute bool + }{ + { + name: "Get existing route", + routeId: "testRouteId", + expectedStatus: http.StatusOK, + expectRoute: true, + }, + { + name: "Get non-existing route", + routeId: "nonExistingRouteId", + expectedStatus: http.StatusNotFound, + expectRoute: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/routes/{routeId}", "{routeId}", tc.routeId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectRoute { + got := &api.Route{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.routeId, got.Id) + assert.Equal(t, "Test Network Route", got.Description) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Routes_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + networkCIDR := "10.10.0.0/24" + peerID := testing_tools.TestPeerId + peerGroups := []string{"peerGroupId"} + + tt := []struct { + name string + requestBody *api.RouteRequest + expectedStatus int + verifyResponse func(t *testing.T, route *api.Route) + }{ + { + name: "Create network route with peer", + requestBody: &api.RouteRequest{ + Description: "New network route", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "newNet", + Metric: 100, + Masquerade: true, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, route *api.Route) { + t.Helper() + assert.NotEmpty(t, route.Id) + assert.Equal(t, "New network route", route.Description) + assert.Equal(t, 100, route.Metric) + assert.Equal(t, true, route.Masquerade) + assert.Equal(t, true, route.Enabled) + }, + }, + { + name: "Create network route with peer groups", + requestBody: &api.RouteRequest{ + Description: "Route with peer groups", + Network: &networkCIDR, + PeerGroups: &peerGroups, + NetworkId: "peerGroupNet", + Metric: 150, + Masquerade: false, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, route *api.Route) { + t.Helper() + assert.NotEmpty(t, route.Id) + assert.Equal(t, "Route with peer groups", route.Description) + }, + }, + { + name: "Create route with empty network_id", + requestBody: &api.RouteRequest{ + Description: "Empty net id", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "", + Metric: 100, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create route with metric 0", + requestBody: &api.RouteRequest{ + Description: "Zero metric", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "zeroMetric", + Metric: 0, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create route with metric 10000", + requestBody: &api.RouteRequest{ + Description: "High metric", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "highMetric", + Metric: 10000, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/routes", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Route{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify route exists in DB with correct fields + db := testing_tools.GetDB(t, am.GetStore()) + dbRoute := testing_tools.VerifyRouteInDB(t, db, route.ID(got.Id)) + assert.Equal(t, tc.requestBody.Description, dbRoute.Description) + assert.Equal(t, tc.requestBody.Metric, dbRoute.Metric) + assert.Equal(t, tc.requestBody.Masquerade, dbRoute.Masquerade) + assert.Equal(t, tc.requestBody.Enabled, dbRoute.Enabled) + assert.Equal(t, route.NetID(tc.requestBody.NetworkId), dbRoute.NetID) + } + }) + } + } +} + +func Test_Routes_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + networkCIDR := "10.0.0.0/24" + peerID := testing_tools.TestPeerId + + tt := []struct { + name string + routeId string + requestBody *api.RouteRequest + expectedStatus int + verifyResponse func(t *testing.T, route *api.Route) + }{ + { + name: "Update route description", + routeId: "testRouteId", + requestBody: &api.RouteRequest{ + Description: "Updated description", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "testNet", + Metric: 100, + Masquerade: true, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, route *api.Route) { + t.Helper() + assert.Equal(t, "testRouteId", route.Id) + assert.Equal(t, "Updated description", route.Description) + }, + }, + { + name: "Update route metric", + routeId: "testRouteId", + requestBody: &api.RouteRequest{ + Description: "Test Network Route", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "testNet", + Metric: 500, + Masquerade: true, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, route *api.Route) { + t.Helper() + assert.Equal(t, 500, route.Metric) + }, + }, + { + name: "Update non-existing route", + routeId: "nonExistingRouteId", + requestBody: &api.RouteRequest{ + Description: "whatever", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "testNet", + Metric: 100, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/routes/{routeId}", "{routeId}", tc.routeId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Route{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated route in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbRoute := testing_tools.VerifyRouteInDB(t, db, route.ID(got.Id)) + assert.Equal(t, tc.requestBody.Description, dbRoute.Description) + assert.Equal(t, tc.requestBody.Metric, dbRoute.Metric) + assert.Equal(t, tc.requestBody.Masquerade, dbRoute.Masquerade) + assert.Equal(t, tc.requestBody.Enabled, dbRoute.Enabled) + } + }) + } + } +} + +func Test_Routes_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + routeId string + expectedStatus int + }{ + { + name: "Delete existing route", + routeId: "testRouteId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing route", + routeId: "nonExistingRouteId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/routes/{routeId}", "{routeId}", tc.routeId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + + // Verify route was deleted from DB for successful deletes + if tc.expectedStatus == http.StatusOK && user.expectResponse { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyRouteNotInDB(t, db, route.ID(tc.routeId)) + } + }) + } + } +} diff --git a/management/server/http/testing/integration/setupkeys_handler_integration_test.go b/management/server/http/testing/integration/setupkeys_handler_integration_test.go index c1a9829da..0d3aaac82 100644 --- a/management/server/http/testing/integration/setupkeys_handler_integration_test.go +++ b/management/server/http/testing/integration/setupkeys_handler_integration_test.go @@ -3,7 +3,6 @@ package integration import ( - "context" "encoding/json" "net/http" "net/http/httptest" @@ -14,7 +13,6 @@ import ( "github.com/stretchr/testify/assert" - "github.com/netbirdio/netbird/management/server/http/handlers/setup_keys" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" "github.com/netbirdio/netbird/shared/management/http/api" @@ -254,7 +252,7 @@ func Test_SetupKeys_Create(t *testing.T) { expectedResponse: nil, }, { - name: "Create Setup Key", + name: "Create Setup Key with nil AutoGroups", requestType: http.MethodPost, requestPath: "/api/setup-keys", requestBody: &api.CreateSetupKeyRequest{ @@ -308,14 +306,15 @@ func Test_SetupKeys_Create(t *testing.T) { t.Fatalf("Sent content is not in correct json format; %v", err) } + gotID := got.Id validateCreatedKey(t, tc.expectedResponse, got) - key, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got.Id) - if err != nil { - return - } - - validateCreatedKey(t, tc.expectedResponse, setup_keys.ToResponseBody(key)) + // Verify setup key exists in DB via gorm + db := testing_tools.GetDB(t, am.GetStore()) + dbKey := testing_tools.VerifySetupKeyInDB(t, db, gotID) + assert.Equal(t, tc.expectedResponse.Name, dbKey.Name) + assert.Equal(t, tc.expectedResponse.Revoked, dbKey.Revoked) + assert.Equal(t, tc.expectedResponse.UsageLimit, dbKey.UsageLimit) select { case <-done: @@ -571,7 +570,7 @@ func Test_SetupKeys_Update(t *testing.T) { for _, tc := range tt { for _, user := range users { - t.Run(tc.name, func(t *testing.T) { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/setup_keys.sql", nil, true) body, err := json.Marshal(tc.requestBody) @@ -594,14 +593,16 @@ func Test_SetupKeys_Update(t *testing.T) { t.Fatalf("Sent content is not in correct json format; %v", err) } + gotID := got.Id + gotRevoked := got.Revoked + gotUsageLimit := got.UsageLimit validateCreatedKey(t, tc.expectedResponse, got) - key, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got.Id) - if err != nil { - return - } - - validateCreatedKey(t, tc.expectedResponse, setup_keys.ToResponseBody(key)) + // Verify updated setup key in DB via gorm + db := testing_tools.GetDB(t, am.GetStore()) + dbKey := testing_tools.VerifySetupKeyInDB(t, db, gotID) + assert.Equal(t, gotRevoked, dbKey.Revoked) + assert.Equal(t, gotUsageLimit, dbKey.UsageLimit) select { case <-done: @@ -759,8 +760,8 @@ func Test_SetupKeys_Get(t *testing.T) { apiHandler.ServeHTTP(recorder, req) - content, expectRespnose := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) - if !expectRespnose { + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { return } got := &api.SetupKey{} @@ -768,14 +769,16 @@ func Test_SetupKeys_Get(t *testing.T) { t.Fatalf("Sent content is not in correct json format; %v", err) } + gotID := got.Id + gotName := got.Name + gotRevoked := got.Revoked validateCreatedKey(t, tc.expectedResponse, got) - key, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got.Id) - if err != nil { - return - } - - validateCreatedKey(t, tc.expectedResponse, setup_keys.ToResponseBody(key)) + // Verify setup key in DB via gorm + db := testing_tools.GetDB(t, am.GetStore()) + dbKey := testing_tools.VerifySetupKeyInDB(t, db, gotID) + assert.Equal(t, gotName, dbKey.Name) + assert.Equal(t, gotRevoked, dbKey.Revoked) select { case <-done: @@ -928,15 +931,17 @@ func Test_SetupKeys_GetAll(t *testing.T) { return tc.expectedResponse[i].UsageLimit < tc.expectedResponse[j].UsageLimit }) + db := testing_tools.GetDB(t, am.GetStore()) for i := range tc.expectedResponse { + gotID := got[i].Id + gotName := got[i].Name + gotRevoked := got[i].Revoked validateCreatedKey(t, tc.expectedResponse[i], &got[i]) - key, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got[i].Id) - if err != nil { - return - } - - validateCreatedKey(t, tc.expectedResponse[i], setup_keys.ToResponseBody(key)) + // Verify each setup key in DB via gorm + dbKey := testing_tools.VerifySetupKeyInDB(t, db, gotID) + assert.Equal(t, gotName, dbKey.Name) + assert.Equal(t, gotRevoked, dbKey.Revoked) } select { @@ -1104,8 +1109,9 @@ func Test_SetupKeys_Delete(t *testing.T) { t.Fatalf("Sent content is not in correct json format; %v", err) } - _, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got.Id) - assert.Errorf(t, err, "Expected error when trying to get deleted key") + // Verify setup key deleted from DB via gorm + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifySetupKeyNotInDB(t, db, got.Id) select { case <-done: @@ -1120,7 +1126,7 @@ func Test_SetupKeys_Delete(t *testing.T) { func validateCreatedKey(t *testing.T, expectedKey *api.SetupKey, got *api.SetupKey) { t.Helper() - if got.Expires.After(time.Now().Add(-1*time.Minute)) && got.Expires.Before(time.Now().Add(testing_tools.ExpiresIn*time.Second)) || + if (got.Expires.After(time.Now().Add(-1*time.Minute)) && got.Expires.Before(time.Now().Add(testing_tools.ExpiresIn*time.Second))) || got.Expires.After(time.Date(2300, 01, 01, 0, 0, 0, 0, time.Local)) || got.Expires.Before(time.Date(1950, 01, 01, 0, 0, 0, 0, time.Local)) { got.Expires = time.Time{} diff --git a/management/server/http/testing/integration/users_handler_integration_test.go b/management/server/http/testing/integration/users_handler_integration_test.go new file mode 100644 index 000000000..eae3b4ad5 --- /dev/null +++ b/management/server/http/testing/integration/users_handler_integration_test.go @@ -0,0 +1,701 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Users_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, true}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, true}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all users", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/users", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.User{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 1) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Users_GetAll_ServiceUsers(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all service users", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/users?service_user=true", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.User{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + for _, u := range got { + assert.NotNil(t, u.IsServiceUser) + assert.Equal(t, true, *u.IsServiceUser) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Users_Create_ServiceUser(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + requestBody *api.UserCreateRequest + expectedStatus int + verifyResponse func(t *testing.T, user *api.User) + }{ + { + name: "Create service user with admin role", + requestBody: &api.UserCreateRequest{ + Role: "admin", + IsServiceUser: true, + AutoGroups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.NotEmpty(t, user.Id) + assert.Equal(t, "admin", user.Role) + assert.NotNil(t, user.IsServiceUser) + assert.Equal(t, true, *user.IsServiceUser) + }, + }, + { + name: "Create service user with user role", + requestBody: &api.UserCreateRequest{ + Role: "user", + IsServiceUser: true, + AutoGroups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.NotEmpty(t, user.Id) + assert.Equal(t, "user", user.Role) + }, + }, + { + name: "Create service user with empty auto_groups", + requestBody: &api.UserCreateRequest{ + Role: "admin", + IsServiceUser: true, + AutoGroups: []string{}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.NotEmpty(t, user.Id) + }, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/users", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.User{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify user in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbUser := testing_tools.VerifyUserInDB(t, db, got.Id) + assert.True(t, dbUser.IsServiceUser) + assert.Equal(t, string(dbUser.Role), string(tc.requestBody.Role)) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Users_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + targetUserId string + requestBody *api.UserRequest + expectedStatus int + verifyResponse func(t *testing.T, user *api.User) + }{ + { + name: "Update user role to admin", + targetUserId: testing_tools.TestUserId, + requestBody: &api.UserRequest{ + Role: "admin", + AutoGroups: []string{}, + IsBlocked: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.Equal(t, "admin", user.Role) + }, + }, + { + name: "Update user auto_groups", + targetUserId: testing_tools.TestUserId, + requestBody: &api.UserRequest{ + Role: "user", + AutoGroups: []string{testing_tools.TestGroupId}, + IsBlocked: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.Equal(t, 1, len(user.AutoGroups)) + }, + }, + { + name: "Block user", + targetUserId: testing_tools.TestUserId, + requestBody: &api.UserRequest{ + Role: "user", + AutoGroups: []string{}, + IsBlocked: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.Equal(t, true, user.IsBlocked) + }, + }, + { + name: "Update non-existing user", + targetUserId: "nonExistingUserId", + requestBody: &api.UserRequest{ + Role: "user", + AutoGroups: []string{}, + IsBlocked: false, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/users/{userId}", "{userId}", tc.targetUserId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.User{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated fields in DB + if tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + dbUser := testing_tools.VerifyUserInDB(t, db, tc.targetUserId) + assert.Equal(t, string(dbUser.Role), string(tc.requestBody.Role)) + assert.Equal(t, dbUser.Blocked, tc.requestBody.IsBlocked) + assert.ElementsMatch(t, dbUser.AutoGroups, tc.requestBody.AutoGroups) + } + } + }) + } + } +} + +func Test_Users_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + targetUserId string + expectedStatus int + }{ + { + name: "Delete existing service user", + targetUserId: "deletableServiceUserId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing user", + targetUserId: "nonExistingUserId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/users/{userId}", "{userId}", tc.targetUserId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + + // Verify user deleted from DB for successful deletes + if expectResponse && tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyUserNotInDB(t, db, tc.targetUserId) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_PATs_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all PATs for service user", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/users/{userId}/tokens", "{userId}", testing_tools.TestServiceUserId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.PersonalAccessToken{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "serviceToken", got[0].Name) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_PATs_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + tokenId string + expectedStatus int + expectToken bool + }{ + { + name: "Get existing PAT", + tokenId: "serviceTokenId", + expectedStatus: http.StatusOK, + expectToken: true, + }, + { + name: "Get non-existing PAT", + tokenId: "nonExistingTokenId", + expectedStatus: http.StatusNotFound, + expectToken: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + path := strings.Replace("/api/users/{userId}/tokens/{tokenId}", "{userId}", testing_tools.TestServiceUserId, 1) + path = strings.Replace(path, "{tokenId}", tc.tokenId, 1) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectToken { + got := &api.PersonalAccessToken{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, "serviceTokenId", got.Id) + assert.Equal(t, "serviceToken", got.Name) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_PATs_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + targetUserId string + requestBody *api.PersonalAccessTokenRequest + expectedStatus int + verifyResponse func(t *testing.T, pat *api.PersonalAccessTokenGenerated) + }{ + { + name: "Create PAT with 30 day expiry", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "newPAT", + ExpiresIn: 30, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, pat *api.PersonalAccessTokenGenerated) { + t.Helper() + assert.NotEmpty(t, pat.PlainToken) + assert.Equal(t, "newPAT", pat.PersonalAccessToken.Name) + }, + }, + { + name: "Create PAT with 365 day expiry", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "longPAT", + ExpiresIn: 365, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, pat *api.PersonalAccessTokenGenerated) { + t.Helper() + assert.NotEmpty(t, pat.PlainToken) + assert.Equal(t, "longPAT", pat.PersonalAccessToken.Name) + }, + }, + { + name: "Create PAT with empty name", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "", + ExpiresIn: 30, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create PAT with 0 day expiry", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "zeroPAT", + ExpiresIn: 0, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create PAT with expiry over 365 days", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "tooLongPAT", + ExpiresIn: 400, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, strings.Replace("/api/users/{userId}/tokens", "{userId}", tc.targetUserId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.PersonalAccessTokenGenerated{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify PAT in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbPAT := testing_tools.VerifyPATInDB(t, db, got.PersonalAccessToken.Id) + assert.Equal(t, tc.requestBody.Name, dbPAT.Name) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_PATs_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + tokenId string + expectedStatus int + }{ + { + name: "Delete existing PAT", + tokenId: "serviceTokenId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing PAT", + tokenId: "nonExistingTokenId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + path := strings.Replace("/api/users/{userId}/tokens/{tokenId}", "{userId}", testing_tools.TestServiceUserId, 1) + path = strings.Replace(path, "{tokenId}", tc.tokenId, 1) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + + // Verify PAT deleted from DB for successful deletes + if expectResponse && tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyPATNotInDB(t, db, tc.tokenId) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} diff --git a/management/server/http/testing/testdata/accounts.sql b/management/server/http/testing/testdata/accounts.sql new file mode 100644 index 000000000..35f00d419 --- /dev/null +++ b/management/server/http/testing/testdata/accounts.sql @@ -0,0 +1,18 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','[]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); diff --git a/management/server/http/testing/testdata/dns.sql b/management/server/http/testing/testdata/dns.sql new file mode 100644 index 000000000..9ed4daf7e --- /dev/null +++ b/management/server/http/testing/testdata/dns.sql @@ -0,0 +1,21 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `name_server_groups` (`id` text,`account_id` text,`name` text,`description` text,`name_servers` text,`groups` text,`primary` numeric,`domains` text,`enabled` numeric,`search_domains_enabled` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_name_server_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO name_server_groups VALUES('testNSGroupId','testAccountId','testNSGroup','test nameserver group','[{"IP":"1.1.1.1","NSType":1,"Port":53}]','["testGroupId"]',0,'["example.com"]',1,0); \ No newline at end of file diff --git a/management/server/http/testing/testdata/events.sql b/management/server/http/testing/testdata/events.sql new file mode 100644 index 000000000..27fd01aea --- /dev/null +++ b/management/server/http/testing/testdata/events.sql @@ -0,0 +1,18 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','[]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); \ No newline at end of file diff --git a/management/server/http/testing/testdata/groups.sql b/management/server/http/testing/testdata/groups.sql new file mode 100644 index 000000000..eb874f036 --- /dev/null +++ b/management/server/http/testing/testdata/groups.sql @@ -0,0 +1,19 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO "groups" VALUES('allGroupId','testAccountId','All','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); \ No newline at end of file diff --git a/management/server/http/testing/testdata/networks.sql b/management/server/http/testing/testdata/networks.sql new file mode 100644 index 000000000..39ec8e646 --- /dev/null +++ b/management/server/http/testing/testdata/networks.sql @@ -0,0 +1,25 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `networks` (`id` text,`account_id` text,`name` text,`description` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_networks` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `network_routers` (`id` text,`network_id` text,`account_id` text,`peer` text,`peer_groups` text,`masquerade` numeric,`metric` integer,`enabled` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_routers` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `network_resources` (`id` text,`network_id` text,`account_id` text,`name` text,`description` text,`type` text,`domain` text,`prefix` text,`enabled` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_resources` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'testServiceUser','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'testServiceAdmin','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:00',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO networks VALUES('testNetworkId','testAccountId','testNetwork','test network description'); +INSERT INTO network_routers VALUES('testRouterId','testNetworkId','testAccountId','testPeerId','[]',1,100,1); +INSERT INTO network_resources VALUES('testResourceId','testNetworkId','testAccountId','testResource','test resource description','host','','"3.3.3.3/32"',1); \ No newline at end of file diff --git a/management/server/http/testing/testdata/peers_integration.sql b/management/server/http/testing/testdata/peers_integration.sql new file mode 100644 index 000000000..62a7760e7 --- /dev/null +++ b/management/server/http/testing/testdata/peers_integration.sql @@ -0,0 +1,20 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId","testPeerId2"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); + +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','test-host-1','linux','Linux','','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'test-peer-1','test-peer-1','2023-03-02 09:21:02.189035775+01:00',0,0,0,'testUserId','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); +INSERT INTO peers VALUES('testPeerId2','testAccountId','6rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYBg=','82546A29-6BC8-4311-BCFC-9CDBF33F1A49','"100.64.114.32"','test-host-2','linux','Linux','','unknown','Ubuntu','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'test-peer-2','test-peer-2','2023-03-02 09:21:02.189035775+01:00',1,0,0,'testAdminId','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',1,0,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); \ No newline at end of file diff --git a/management/server/http/testing/testdata/policies.sql b/management/server/http/testing/testdata/policies.sql new file mode 100644 index 000000000..7e6cc883b --- /dev/null +++ b/management/server/http/testing/testdata/policies.sql @@ -0,0 +1,23 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `policies` (`id` text,`account_id` text,`name` text,`description` text,`enabled` numeric,`source_posture_checks` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_policies_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `policy_rules` (`id` text,`policy_id` text,`name` text,`description` text,`enabled` numeric,`action` text,`protocol` text,`bidirectional` numeric,`sources` text,`destinations` text,`source_resource` text,`destination_resource` text,`ports` text,`port_ranges` text,`authorized_groups` text,`authorized_user` text,PRIMARY KEY (`id`),CONSTRAINT `fk_policies_rules_g` FOREIGN KEY (`policy_id`) REFERENCES `policies`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO policies VALUES('testPolicyId','testAccountId','testPolicy','test policy description',1,NULL); +INSERT INTO policy_rules VALUES('testRuleId','testPolicyId','testRule','test rule',1,'accept','all',1,'["testGroupId"]','["testGroupId"]',NULL,NULL,NULL,NULL,NULL,''); \ No newline at end of file diff --git a/management/server/http/testing/testdata/routes.sql b/management/server/http/testing/testdata/routes.sql new file mode 100644 index 000000000..48aa02052 --- /dev/null +++ b/management/server/http/testing/testdata/routes.sql @@ -0,0 +1,23 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `routes` (`id` text,`account_id` text,`network` text,`domains` text,`keep_route` numeric,`net_id` text,`description` text,`peer` text,`peer_groups` text,`network_type` integer,`masquerade` numeric,`metric` integer,`enabled` numeric,`groups` text,`access_control_groups` text,`skip_auto_apply` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_routes_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO "groups" VALUES('peerGroupId','testAccountId','peerGroupName','api','["testPeerId"]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO routes VALUES('testRouteId','testAccountId','"10.0.0.0/24"',NULL,0,'testNet','Test Network Route','testPeerId',NULL,1,1,100,1,'["testGroupId"]',NULL,0); +INSERT INTO routes VALUES('testDomainRouteId','testAccountId','"0.0.0.0/0"','["example.com"]',0,'testDomainNet','Test Domain Route','','["peerGroupId"]',3,1,200,1,'["testGroupId"]',NULL,0); diff --git a/management/server/http/testing/testdata/users_integration.sql b/management/server/http/testing/testdata/users_integration.sql new file mode 100644 index 000000000..57df73e8c --- /dev/null +++ b/management/server/http/testing/testdata/users_integration.sql @@ -0,0 +1,24 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `personal_access_tokens` (`id` text,`user_id` text,`name` text,`hashed_token` text,`expiration_date` datetime,`created_by` text,`created_at` datetime,`last_used` datetime DEFAULT NULL,PRIMARY KEY (`id`),CONSTRAINT `fk_users_pa_ts_g` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)); +CREATE INDEX `idx_personal_access_tokens_user_id` ON `personal_access_tokens`(`user_id`); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'testServiceUser','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'testServiceAdmin','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('deletableServiceUserId','testAccountId','user',1,0,'deletableServiceUser','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO personal_access_tokens VALUES('testTokenId','testUserId','testToken','hashedTokenValue123','2325-10-02 16:01:38.000000000+00:00','testUserId','2024-10-02 16:01:38.000000000+00:00',NULL); +INSERT INTO personal_access_tokens VALUES('serviceTokenId','testServiceUserId','serviceToken','hashedServiceTokenValue123','2325-10-02 16:01:38.000000000+00:00','testAdminId','2024-10-02 16:01:38.000000000+00:00',NULL); \ No newline at end of file diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 6bd269a2c..c6e57b1be 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -114,8 +114,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee if err != nil { t.Fatalf("Failed to create proxy controller: %v", err) } - domainManager.SetClusterCapabilities(serviceProxyController) - serviceManager := reverseproxymanager.NewManager(store, am, permissionsManager, serviceProxyController, domainManager) + serviceManager := reverseproxymanager.NewManager(store, am, permissionsManager, serviceProxyController, proxyMgr, domainManager) proxyServiceServer.SetServiceManager(serviceManager) am.SetServiceManager(serviceManager) @@ -128,14 +127,14 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee GetPATInfoFunc: authManager.GetPATInfo, } - networksManagerMock := networks.NewManagerMock() - resourcesManagerMock := resources.NewManagerMock() - routersManagerMock := routers.NewManagerMock() - groupsManagerMock := groups.NewManagerMock() + groupsManager := groups.NewManager(store, permissionsManager, am) + routersManager := routers.NewManager(store, permissionsManager, am) + resourcesManager := resources.NewManager(store, permissionsManager, groupsManager, am, serviceManager) + networksManager := networks.NewManager(store, permissionsManager, resourcesManager, routersManager, am) customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "") zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) - apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil) + apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } @@ -167,6 +166,111 @@ func peerShouldReceiveUpdate(t testing_tools.TB, updateMessage <-chan *network_m } } +// PeerShouldReceiveAnyUpdate waits for a peer update message and returns it. +// Fails the test if no update is received within timeout. +func PeerShouldReceiveAnyUpdate(t testing_tools.TB, updateMessage <-chan *network_map.UpdateMessage) *network_map.UpdateMessage { + t.Helper() + select { + case msg := <-updateMessage: + if msg == nil { + t.Errorf("Received nil update message, expected valid message") + } + return msg + case <-time.After(500 * time.Millisecond): + t.Errorf("Timed out waiting for update message") + return nil + } +} + +// PeerShouldNotReceiveAnyUpdate verifies no peer update message is received. +func PeerShouldNotReceiveAnyUpdate(t testing_tools.TB, updateMessage <-chan *network_map.UpdateMessage) { + t.Helper() + peerShouldNotReceiveUpdate(t, updateMessage) +} + +// BuildApiBlackBoxWithDBStateAndPeerChannel creates the API handler and returns +// the peer update channel directly so tests can verify updates inline. +func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile string) (http.Handler, account.Manager, <-chan *network_map.UpdateMessage) { + store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) + if err != nil { + t.Fatalf("Failed to create test store: %v", err) + } + t.Cleanup(cleanup) + + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + if err != nil { + t.Fatalf("Failed to create metrics: %v", err) + } + + peersUpdateManager := update_channel.NewPeersUpdateManager(nil) + updMsg := peersUpdateManager.CreateChannel(context.Background(), testing_tools.TestPeerId) + + geoMock := &geolocation.Mock{} + validatorMock := server.MockIntegratedValidator{} + proxyController := integrations.NewController(store) + userManager := users.NewManager(store) + permissionsManager := permissions.NewManager(store) + settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) + peersManager := peers.NewManager(store, permissionsManager) + + jobManager := job.NewJobManager(nil, store, peersManager) + + ctx := context.Background() + requestBuffer := server.NewAccountRequestBuffer(ctx, store) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}) + am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil) + proxyTokenStore, err := nbgrpc.NewOneTimeTokenStore(ctx, 5*time.Minute, 10*time.Minute, 100) + if err != nil { + t.Fatalf("Failed to create proxy token store: %v", err) + } + pkceverifierStore, err := nbgrpc.NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + if err != nil { + t.Fatalf("Failed to create PKCE verifier store: %v", err) + } + noopMeter := noop.NewMeterProvider().Meter("") + proxyMgr, err := proxymanager.NewManager(store, noopMeter) + if err != nil { + t.Fatalf("Failed to create proxy manager: %v", err) + } + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr) + domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am) + serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter) + if err != nil { + t.Fatalf("Failed to create proxy controller: %v", err) + } + serviceManager := reverseproxymanager.NewManager(store, am, permissionsManager, serviceProxyController, proxyMgr, domainManager) + proxyServiceServer.SetServiceManager(serviceManager) + am.SetServiceManager(serviceManager) + + // @note this is required so that PAT's validate from store, but JWT's are mocked + authManager := serverauth.NewManager(store, "", "", "", "", []string{}, false) + authManagerMock := &serverauth.MockManager{ + ValidateAndParseTokenFunc: mockValidateAndParseToken, + EnsureUserAccessByJWTGroupsFunc: authManager.EnsureUserAccessByJWTGroups, + MarkPATUsedFunc: authManager.MarkPATUsed, + GetPATInfoFunc: authManager.GetPATInfo, + } + + groupsManager := groups.NewManager(store, permissionsManager, am) + routersManager := routers.NewManager(store, permissionsManager, am) + resourcesManager := resources.NewManager(store, permissionsManager, groupsManager, am, serviceManager) + networksManager := networks.NewManager(store, permissionsManager, resourcesManager, routersManager, am) + customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "") + zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) + + apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil) + if err != nil { + t.Fatalf("Failed to create API handler: %v", err) + } + + return apiHandler, am, updMsg +} + func mockValidateAndParseToken(_ context.Context, token string) (auth.UserAuth, *jwt.Token, error) { userAuth := auth.UserAuth{} diff --git a/management/server/http/testing/testing_tools/db_verify.go b/management/server/http/testing/testing_tools/db_verify.go new file mode 100644 index 000000000..f8af6a41f --- /dev/null +++ b/management/server/http/testing/testing_tools/db_verify.go @@ -0,0 +1,222 @@ +package testing_tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +// GetDB extracts the *gorm.DB from a store.Store (must be *SqlStore). +func GetDB(t *testing.T, s store.Store) *gorm.DB { + t.Helper() + sqlStore, ok := s.(*store.SqlStore) + require.True(t, ok, "Store is not a *SqlStore, cannot get gorm.DB") + return sqlStore.GetDB() +} + +// VerifyGroupInDB reads a group directly from the DB and returns it. +func VerifyGroupInDB(t *testing.T, db *gorm.DB, groupID string) *types.Group { + t.Helper() + var group types.Group + err := db.Where("id = ? AND account_id = ?", groupID, TestAccountId).First(&group).Error + require.NoError(t, err, "Expected group %s to exist in DB", groupID) + return &group +} + +// VerifyGroupNotInDB verifies that a group does not exist in the DB. +func VerifyGroupNotInDB(t *testing.T, db *gorm.DB, groupID string) { + t.Helper() + var count int64 + db.Model(&types.Group{}).Where("id = ? AND account_id = ?", groupID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected group %s to NOT exist in DB", groupID) +} + +// VerifyPolicyInDB reads a policy directly from the DB and returns it. +func VerifyPolicyInDB(t *testing.T, db *gorm.DB, policyID string) *types.Policy { + t.Helper() + var policy types.Policy + err := db.Preload("Rules").Where("id = ? AND account_id = ?", policyID, TestAccountId).First(&policy).Error + require.NoError(t, err, "Expected policy %s to exist in DB", policyID) + return &policy +} + +// VerifyPolicyNotInDB verifies that a policy does not exist in the DB. +func VerifyPolicyNotInDB(t *testing.T, db *gorm.DB, policyID string) { + t.Helper() + var count int64 + db.Model(&types.Policy{}).Where("id = ? AND account_id = ?", policyID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected policy %s to NOT exist in DB", policyID) +} + +// VerifyRouteInDB reads a route directly from the DB and returns it. +func VerifyRouteInDB(t *testing.T, db *gorm.DB, routeID route.ID) *route.Route { + t.Helper() + var r route.Route + err := db.Where("id = ? AND account_id = ?", routeID, TestAccountId).First(&r).Error + require.NoError(t, err, "Expected route %s to exist in DB", routeID) + return &r +} + +// VerifyRouteNotInDB verifies that a route does not exist in the DB. +func VerifyRouteNotInDB(t *testing.T, db *gorm.DB, routeID route.ID) { + t.Helper() + var count int64 + db.Model(&route.Route{}).Where("id = ? AND account_id = ?", routeID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected route %s to NOT exist in DB", routeID) +} + +// VerifyNSGroupInDB reads a nameserver group directly from the DB and returns it. +func VerifyNSGroupInDB(t *testing.T, db *gorm.DB, nsGroupID string) *nbdns.NameServerGroup { + t.Helper() + var nsGroup nbdns.NameServerGroup + err := db.Where("id = ? AND account_id = ?", nsGroupID, TestAccountId).First(&nsGroup).Error + require.NoError(t, err, "Expected NS group %s to exist in DB", nsGroupID) + return &nsGroup +} + +// VerifyNSGroupNotInDB verifies that a nameserver group does not exist in the DB. +func VerifyNSGroupNotInDB(t *testing.T, db *gorm.DB, nsGroupID string) { + t.Helper() + var count int64 + db.Model(&nbdns.NameServerGroup{}).Where("id = ? AND account_id = ?", nsGroupID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected NS group %s to NOT exist in DB", nsGroupID) +} + +// VerifyPeerInDB reads a peer directly from the DB and returns it. +func VerifyPeerInDB(t *testing.T, db *gorm.DB, peerID string) *nbpeer.Peer { + t.Helper() + var peer nbpeer.Peer + err := db.Where("id = ? AND account_id = ?", peerID, TestAccountId).First(&peer).Error + require.NoError(t, err, "Expected peer %s to exist in DB", peerID) + return &peer +} + +// VerifyPeerNotInDB verifies that a peer does not exist in the DB. +func VerifyPeerNotInDB(t *testing.T, db *gorm.DB, peerID string) { + t.Helper() + var count int64 + db.Model(&nbpeer.Peer{}).Where("id = ? AND account_id = ?", peerID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected peer %s to NOT exist in DB", peerID) +} + +// VerifySetupKeyInDB reads a setup key directly from the DB and returns it. +func VerifySetupKeyInDB(t *testing.T, db *gorm.DB, keyID string) *types.SetupKey { + t.Helper() + var key types.SetupKey + err := db.Where("id = ? AND account_id = ?", keyID, TestAccountId).First(&key).Error + require.NoError(t, err, "Expected setup key %s to exist in DB", keyID) + return &key +} + +// VerifySetupKeyNotInDB verifies that a setup key does not exist in the DB. +func VerifySetupKeyNotInDB(t *testing.T, db *gorm.DB, keyID string) { + t.Helper() + var count int64 + db.Model(&types.SetupKey{}).Where("id = ? AND account_id = ?", keyID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected setup key %s to NOT exist in DB", keyID) +} + +// VerifyUserInDB reads a user directly from the DB and returns it. +func VerifyUserInDB(t *testing.T, db *gorm.DB, userID string) *types.User { + t.Helper() + var user types.User + err := db.Where("id = ? AND account_id = ?", userID, TestAccountId).First(&user).Error + require.NoError(t, err, "Expected user %s to exist in DB", userID) + return &user +} + +// VerifyUserNotInDB verifies that a user does not exist in the DB. +func VerifyUserNotInDB(t *testing.T, db *gorm.DB, userID string) { + t.Helper() + var count int64 + db.Model(&types.User{}).Where("id = ? AND account_id = ?", userID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected user %s to NOT exist in DB", userID) +} + +// VerifyPATInDB reads a PAT directly from the DB and returns it. +func VerifyPATInDB(t *testing.T, db *gorm.DB, tokenID string) *types.PersonalAccessToken { + t.Helper() + var pat types.PersonalAccessToken + err := db.Where("id = ?", tokenID).First(&pat).Error + require.NoError(t, err, "Expected PAT %s to exist in DB", tokenID) + return &pat +} + +// VerifyPATNotInDB verifies that a PAT does not exist in the DB. +func VerifyPATNotInDB(t *testing.T, db *gorm.DB, tokenID string) { + t.Helper() + var count int64 + db.Model(&types.PersonalAccessToken{}).Where("id = ?", tokenID).Count(&count) + assert.Equal(t, int64(0), count, "Expected PAT %s to NOT exist in DB", tokenID) +} + +// VerifyAccountSettings reads the account and returns its settings from the DB. +func VerifyAccountSettings(t *testing.T, db *gorm.DB) *types.Account { + t.Helper() + var account types.Account + err := db.Where("id = ?", TestAccountId).First(&account).Error + require.NoError(t, err, "Expected account %s to exist in DB", TestAccountId) + return &account +} + +// VerifyNetworkInDB reads a network directly from the store and returns it. +func VerifyNetworkInDB(t *testing.T, db *gorm.DB, networkID string) *networkTypes.Network { + t.Helper() + var network networkTypes.Network + err := db.Where("id = ? AND account_id = ?", networkID, TestAccountId).First(&network).Error + require.NoError(t, err, "Expected network %s to exist in DB", networkID) + return &network +} + +// VerifyNetworkNotInDB verifies that a network does not exist in the DB. +func VerifyNetworkNotInDB(t *testing.T, db *gorm.DB, networkID string) { + t.Helper() + var count int64 + db.Model(&networkTypes.Network{}).Where("id = ? AND account_id = ?", networkID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected network %s to NOT exist in DB", networkID) +} + +// VerifyNetworkResourceInDB reads a network resource directly from the DB and returns it. +func VerifyNetworkResourceInDB(t *testing.T, db *gorm.DB, resourceID string) *resourceTypes.NetworkResource { + t.Helper() + var resource resourceTypes.NetworkResource + err := db.Where("id = ? AND account_id = ?", resourceID, TestAccountId).First(&resource).Error + require.NoError(t, err, "Expected network resource %s to exist in DB", resourceID) + return &resource +} + +// VerifyNetworkResourceNotInDB verifies that a network resource does not exist in the DB. +func VerifyNetworkResourceNotInDB(t *testing.T, db *gorm.DB, resourceID string) { + t.Helper() + var count int64 + db.Model(&resourceTypes.NetworkResource{}).Where("id = ? AND account_id = ?", resourceID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected network resource %s to NOT exist in DB", resourceID) +} + +// VerifyNetworkRouterInDB reads a network router directly from the DB and returns it. +func VerifyNetworkRouterInDB(t *testing.T, db *gorm.DB, routerID string) *routerTypes.NetworkRouter { + t.Helper() + var router routerTypes.NetworkRouter + err := db.Where("id = ? AND account_id = ?", routerID, TestAccountId).First(&router).Error + require.NoError(t, err, "Expected network router %s to exist in DB", routerID) + return &router +} + +// VerifyNetworkRouterNotInDB verifies that a network router does not exist in the DB. +func VerifyNetworkRouterNotInDB(t *testing.T, db *gorm.DB, routerID string) { + t.Helper() + var count int64 + db.Model(&routerTypes.NetworkRouter{}).Where("id = ? AND account_id = ?", routerID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected network router %s to NOT exist in DB", routerID) +} diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go index 28e3d81f9..20d6cacd5 100644 --- a/management/server/idp/idp.go +++ b/management/server/idp/idp.go @@ -197,6 +197,7 @@ func NewManager(ctx context.Context, config Config, appMetrics telemetry.AppMetr case "jumpcloud": return NewJumpCloudManager(JumpCloudClientConfig{ APIToken: config.ExtraConfig["ApiToken"], + ApiUrl: config.ExtraConfig["ApiUrl"], }, appMetrics) case "pocketid": return NewPocketIdManager(PocketIdClientConfig{ diff --git a/management/server/idp/jumpcloud.go b/management/server/idp/jumpcloud.go index 8c4a9d089..f0dec3a9b 100644 --- a/management/server/idp/jumpcloud.go +++ b/management/server/idp/jumpcloud.go @@ -1,24 +1,40 @@ package idp import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "net/http" "strings" - v1 "github.com/TheJumpCloud/jcapi-go/v1" - "github.com/netbirdio/netbird/management/server/telemetry" ) const ( - contentType = "application/json" - accept = "application/json" + jumpCloudDefaultApiUrl = "https://console.jumpcloud.com" + jumpCloudSearchPageSize = 100 ) +// jumpCloudUser represents a JumpCloud V1 API system user. +type jumpCloudUser struct { + ID string `json:"_id"` + Email string `json:"email"` + Firstname string `json:"firstname"` + Middlename string `json:"middlename"` + Lastname string `json:"lastname"` +} + +// jumpCloudUserList represents the response from the JumpCloud search endpoint. +type jumpCloudUserList struct { + Results []jumpCloudUser `json:"results"` + TotalCount int `json:"totalCount"` +} + // JumpCloudManager JumpCloud manager client instance. type JumpCloudManager struct { - client *v1.APIClient + apiBase string apiToken string httpClient ManagerHTTPClient credentials ManagerCredentials @@ -29,6 +45,7 @@ type JumpCloudManager struct { // JumpCloudClientConfig JumpCloud manager client configurations. type JumpCloudClientConfig struct { APIToken string + ApiUrl string } // JumpCloudCredentials JumpCloud authentication information. @@ -55,7 +72,15 @@ func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppM return nil, fmt.Errorf("jumpCloud IdP configuration is incomplete, ApiToken is missing") } - client := v1.NewAPIClient(v1.NewConfiguration()) + apiBase := config.ApiUrl + if apiBase == "" { + apiBase = jumpCloudDefaultApiUrl + } + apiBase = strings.TrimSuffix(apiBase, "/") + if !strings.HasSuffix(apiBase, "/api") { + apiBase += "/api" + } + credentials := &JumpCloudCredentials{ clientConfig: config, httpClient: httpClient, @@ -64,7 +89,7 @@ func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppM } return &JumpCloudManager{ - client: client, + apiBase: apiBase, apiToken: config.APIToken, httpClient: httpClient, credentials: credentials, @@ -78,37 +103,58 @@ func (jc *JumpCloudCredentials) Authenticate(_ context.Context) (JWTToken, error return JWTToken{}, nil } -func (jm *JumpCloudManager) authenticationContext() context.Context { - return context.WithValue(context.Background(), v1.ContextAPIKey, v1.APIKey{ - Key: jm.apiToken, - }) -} - -// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. -func (jm *JumpCloudManager) UpdateUserAppMetadata(_ context.Context, _ string, _ AppMetadata) error { - return nil -} - -// GetUserDataByID requests user data from JumpCloud via ID. -func (jm *JumpCloudManager) GetUserDataByID(_ context.Context, userID string, appMetadata AppMetadata) (*UserData, error) { - authCtx := jm.authenticationContext() - user, resp, err := jm.client.SystemusersApi.SystemusersGet(authCtx, userID, contentType, accept, nil) +// doRequest executes an HTTP request against the JumpCloud V1 API. +func (jm *JumpCloudManager) doRequest(ctx context.Context, method, path string, body io.Reader) ([]byte, error) { + reqURL := jm.apiBase + path + req, err := http.NewRequestWithContext(ctx, method, reqURL, body) if err != nil { return nil, err } + + req.Header.Set("x-api-key", jm.apiToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := jm.httpClient.Do(req) + if err != nil { + if jm.appMetrics != nil { + jm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, err + } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountRequestStatusError() } - return nil, fmt.Errorf("unable to get user %s, statusCode %d", userID, resp.StatusCode) + return nil, fmt.Errorf("JumpCloud API request %s %s failed with status %d", method, path, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. +func (jm *JumpCloudManager) UpdateUserAppMetadata(_ context.Context, _ string, _ AppMetadata) error { + return nil +} + +// GetUserDataByID requests user data from JumpCloud via ID. +func (jm *JumpCloudManager) GetUserDataByID(ctx context.Context, userID string, appMetadata AppMetadata) (*UserData, error) { + body, err := jm.doRequest(ctx, http.MethodGet, "/systemusers/"+userID, nil) + if err != nil { + return nil, err } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetUserDataByID() } + var user jumpCloudUser + if err = jm.helper.Unmarshal(body, &user); err != nil { + return nil, err + } + userData := parseJumpCloudUser(user) userData.AppMetadata = appMetadata @@ -116,30 +162,20 @@ func (jm *JumpCloudManager) GetUserDataByID(_ context.Context, userID string, ap } // GetAccount returns all the users for a given profile. -func (jm *JumpCloudManager) GetAccount(_ context.Context, accountID string) ([]*UserData, error) { - authCtx := jm.authenticationContext() - userList, resp, err := jm.client.SearchApi.SearchSystemusersPost(authCtx, contentType, accept, nil) +func (jm *JumpCloudManager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) { + allUsers, err := jm.searchAllUsers(ctx) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get account %s users, statusCode %d", accountID, resp.StatusCode) - } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetAccount() } - users := make([]*UserData, 0) - for _, user := range userList.Results { + users := make([]*UserData, 0, len(allUsers)) + for _, user := range allUsers { userData := parseJumpCloudUser(user) userData.AppMetadata.WTAccountID = accountID - users = append(users, userData) } @@ -148,27 +184,18 @@ func (jm *JumpCloudManager) GetAccount(_ context.Context, accountID string) ([]* // GetAllAccounts gets all registered accounts with corresponding user data. // It returns a list of users indexed by accountID. -func (jm *JumpCloudManager) GetAllAccounts(_ context.Context) (map[string][]*UserData, error) { - authCtx := jm.authenticationContext() - userList, resp, err := jm.client.SearchApi.SearchSystemusersPost(authCtx, contentType, accept, nil) +func (jm *JumpCloudManager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) { + allUsers, err := jm.searchAllUsers(ctx) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get all accounts, statusCode %d", resp.StatusCode) - } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetAllAccounts() } indexedUsers := make(map[string][]*UserData) - for _, user := range userList.Results { + for _, user := range allUsers { userData := parseJumpCloudUser(user) indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData) } @@ -176,6 +203,41 @@ func (jm *JumpCloudManager) GetAllAccounts(_ context.Context) (map[string][]*Use return indexedUsers, nil } +// searchAllUsers paginates through all system users using limit/skip. +func (jm *JumpCloudManager) searchAllUsers(ctx context.Context) ([]jumpCloudUser, error) { + var allUsers []jumpCloudUser + + for skip := 0; ; skip += jumpCloudSearchPageSize { + searchReq := map[string]int{ + "limit": jumpCloudSearchPageSize, + "skip": skip, + } + + payload, err := json.Marshal(searchReq) + if err != nil { + return nil, err + } + + body, err := jm.doRequest(ctx, http.MethodPost, "/search/systemusers", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + var userList jumpCloudUserList + if err = jm.helper.Unmarshal(body, &userList); err != nil { + return nil, err + } + + allUsers = append(allUsers, userList.Results...) + + if skip+len(userList.Results) >= userList.TotalCount { + break + } + } + + return allUsers, nil +} + // CreateUser creates a new user in JumpCloud Idp and sends an invitation. func (jm *JumpCloudManager) CreateUser(_ context.Context, _, _, _, _ string) (*UserData, error) { return nil, fmt.Errorf("method CreateUser not implemented") @@ -183,7 +245,7 @@ func (jm *JumpCloudManager) CreateUser(_ context.Context, _, _, _, _ string) (*U // GetUserByEmail searches users with a given email. // If no users have been found, this function returns an empty list. -func (jm *JumpCloudManager) GetUserByEmail(_ context.Context, email string) ([]*UserData, error) { +func (jm *JumpCloudManager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) { searchFilter := map[string]interface{}{ "searchFilter": map[string]interface{}{ "filter": []string{email}, @@ -191,25 +253,26 @@ func (jm *JumpCloudManager) GetUserByEmail(_ context.Context, email string) ([]* }, } - authCtx := jm.authenticationContext() - userList, resp, err := jm.client.SearchApi.SearchSystemusersPost(authCtx, contentType, accept, searchFilter) + payload, err := json.Marshal(searchFilter) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get user %s, statusCode %d", email, resp.StatusCode) + body, err := jm.doRequest(ctx, http.MethodPost, "/search/systemusers", bytes.NewReader(payload)) + if err != nil { + return nil, err } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetUserByEmail() } - usersData := make([]*UserData, 0) + var userList jumpCloudUserList + if err = jm.helper.Unmarshal(body, &userList); err != nil { + return nil, err + } + + usersData := make([]*UserData, 0, len(userList.Results)) for _, user := range userList.Results { usersData = append(usersData, parseJumpCloudUser(user)) } @@ -224,20 +287,11 @@ func (jm *JumpCloudManager) InviteUserByID(_ context.Context, _ string) error { } // DeleteUser from jumpCloud directory -func (jm *JumpCloudManager) DeleteUser(_ context.Context, userID string) error { - authCtx := jm.authenticationContext() - _, resp, err := jm.client.SystemusersApi.SystemusersDelete(authCtx, userID, contentType, accept, nil) +func (jm *JumpCloudManager) DeleteUser(ctx context.Context, userID string) error { + _, err := jm.doRequest(ctx, http.MethodDelete, "/systemusers/"+userID, nil) if err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return fmt.Errorf("unable to delete user, statusCode %d", resp.StatusCode) - } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountDeleteUser() @@ -247,11 +301,11 @@ func (jm *JumpCloudManager) DeleteUser(_ context.Context, userID string) error { } // parseJumpCloudUser parse JumpCloud system user returned from API V1 to UserData. -func parseJumpCloudUser(user v1.Systemuserreturn) *UserData { +func parseJumpCloudUser(user jumpCloudUser) *UserData { names := []string{user.Firstname, user.Middlename, user.Lastname} return &UserData{ Email: user.Email, Name: strings.Join(names, " "), - ID: user.Id, + ID: user.ID, } } diff --git a/management/server/idp/jumpcloud_test.go b/management/server/idp/jumpcloud_test.go index 1bfdcefcc..dc7a9cb6c 100644 --- a/management/server/idp/jumpcloud_test.go +++ b/management/server/idp/jumpcloud_test.go @@ -1,8 +1,15 @@ package idp import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/server/telemetry" @@ -44,3 +51,212 @@ func TestNewJumpCloudManager(t *testing.T) { }) } } + +func TestJumpCloudGetUserDataByID(t *testing.T) { + userResponse := jumpCloudUser{ + ID: "user123", + Email: "test@example.com", + Firstname: "John", + Middlename: "", + Lastname: "Doe", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/systemusers/user123", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "test-api-key", r.Header.Get("x-api-key")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(userResponse) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + userData, err := manager.GetUserDataByID(context.Background(), "user123", AppMetadata{WTAccountID: "acc1"}) + require.NoError(t, err) + + assert.Equal(t, "user123", userData.ID) + assert.Equal(t, "test@example.com", userData.Email) + assert.Equal(t, "John Doe", userData.Name) + assert.Equal(t, "acc1", userData.AppMetadata.WTAccountID) +} + +func TestJumpCloudGetAccount(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/search/systemusers", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var reqBody map[string]any + assert.NoError(t, json.NewDecoder(r.Body).Decode(&reqBody)) + assert.Contains(t, reqBody, "limit") + assert.Contains(t, reqBody, "skip") + + resp := jumpCloudUserList{ + Results: []jumpCloudUser{ + {ID: "u1", Email: "a@test.com", Firstname: "Alice", Lastname: "Smith"}, + {ID: "u2", Email: "b@test.com", Firstname: "Bob", Lastname: "Jones"}, + }, + TotalCount: 2, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + users, err := manager.GetAccount(context.Background(), "testAccount") + require.NoError(t, err) + assert.Len(t, users, 2) + assert.Equal(t, "testAccount", users[0].AppMetadata.WTAccountID) + assert.Equal(t, "testAccount", users[1].AppMetadata.WTAccountID) +} + +func TestJumpCloudGetAllAccounts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jumpCloudUserList{ + Results: []jumpCloudUser{ + {ID: "u1", Email: "a@test.com", Firstname: "Alice"}, + {ID: "u2", Email: "b@test.com", Firstname: "Bob"}, + }, + TotalCount: 2, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + indexedUsers, err := manager.GetAllAccounts(context.Background()) + require.NoError(t, err) + assert.Len(t, indexedUsers[UnsetAccountID], 2) +} + +func TestJumpCloudGetAllAccountsPagination(t *testing.T) { + totalUsers := 250 + allUsers := make([]jumpCloudUser, totalUsers) + for i := range allUsers { + allUsers[i] = jumpCloudUser{ + ID: fmt.Sprintf("u%d", i), + Email: fmt.Sprintf("user%d@test.com", i), + Firstname: fmt.Sprintf("User%d", i), + } + } + + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]int + assert.NoError(t, json.NewDecoder(r.Body).Decode(&reqBody)) + + limit := reqBody["limit"] + skip := reqBody["skip"] + requestCount++ + + end := skip + limit + if end > totalUsers { + end = totalUsers + } + + resp := jumpCloudUserList{ + Results: allUsers[skip:end], + TotalCount: totalUsers, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + indexedUsers, err := manager.GetAllAccounts(context.Background()) + require.NoError(t, err) + assert.Len(t, indexedUsers[UnsetAccountID], totalUsers) + assert.Equal(t, 3, requestCount, "should require 3 pages for 250 users at page size 100") +} + +func TestJumpCloudGetUserByEmail(t *testing.T) { + searchResponse := jumpCloudUserList{ + Results: []jumpCloudUser{ + {ID: "u1", Email: "alice@test.com", Firstname: "Alice", Lastname: "Smith"}, + }, + TotalCount: 1, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/search/systemusers", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Contains(t, string(body), "alice@test.com") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(searchResponse) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + users, err := manager.GetUserByEmail(context.Background(), "alice@test.com") + require.NoError(t, err) + assert.Len(t, users, 1) + assert.Equal(t, "alice@test.com", users[0].Email) +} + +func TestJumpCloudDeleteUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/systemusers/user123", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "test-api-key", r.Header.Get("x-api-key")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"_id": "user123"}) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + err := manager.DeleteUser(context.Background(), "user123") + require.NoError(t, err) +} + +func TestJumpCloudAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + _, err := manager.GetUserDataByID(context.Background(), "user123", AppMetadata{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "401") +} + +func TestParseJumpCloudUser(t *testing.T) { + user := jumpCloudUser{ + ID: "abc123", + Email: "test@example.com", + Firstname: "John", + Middlename: "M", + Lastname: "Doe", + } + + userData := parseJumpCloudUser(user) + assert.Equal(t, "abc123", userData.ID) + assert.Equal(t, "test@example.com", userData.Email) + assert.Equal(t, "John M Doe", userData.Name) +} + +func newTestJumpCloudManager(t *testing.T, apiBase string) *JumpCloudManager { + t.Helper() + return &JumpCloudManager{ + apiBase: apiBase, + apiToken: "test-api-key", + httpClient: http.DefaultClient, + helper: JsonParser{}, + appMetrics: nil, + } +} diff --git a/management/server/peer.go b/management/server/peer.go index 78ecbfcae..a02e34e0d 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -249,7 +249,7 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user if err != nil { newLabel = "" } else { - _, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, update.Name) + _, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, newLabel) if err == nil { newLabel = "" } @@ -859,7 +859,9 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe opEvent.Meta["setup_key_name"] = peerAddConfig.SetupKeyName } - am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta) + if !temporary { + am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta) + } if err := am.networkMapController.OnPeersAdded(ctx, accountID, []string{newPeer.ID}); err != nil { log.WithContext(ctx).Errorf("failed to update network map cache for peer %s: %v", newPeer.ID, err) @@ -1480,9 +1482,11 @@ func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction sto if err = transaction.DeletePeer(ctx, accountID, peer.ID); err != nil { return nil, err } - peerDeletedEvents = append(peerDeletedEvents, func() { - am.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain)) - }) + if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") { + peerDeletedEvents = append(peerDeletedEvents, func() { + am.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain)) + }) + } } return peerDeletedEvents, nil diff --git a/management/server/peer_test.go b/management/server/peer_test.go index b17757ffd..51c16d730 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -37,6 +37,7 @@ import ( "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/util" @@ -2738,3 +2739,70 @@ func TestProcessPeerAddAuth(t *testing.T) { assert.Empty(t, config.GroupsToAdd) }) } + +func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + // Add first peer with hostname that produces DNS label "netbird1" + key1, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: key1.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "netbird1.netbird.cloud"}, + }, false) + require.NoError(t, err, "unable to add first peer") + assert.Equal(t, "netbird1", peer1.DNSLabel) + + // Add second peer with a different hostname + key2, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: key2.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "ip-10-29-5-130"}, + }, false) + require.NoError(t, err) + + update := peer2.Copy() + update.Name = "netbird1.demo.netbird.cloud" + updated, err := manager.UpdatePeer(context.Background(), accountID, userID, update) + require.NoError(t, err, "renaming peer should not fail with duplicate DNS label error") + assert.Equal(t, "netbird1.demo.netbird.cloud", updated.Name) + assert.NotEqual(t, "netbird1", updated.DNSLabel, "DNS label should not collide with existing peer") + assert.Contains(t, updated.DNSLabel, "netbird1-", "DNS label should be IP-based fallback") +} + +func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key1, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: key1.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "web-server"}, + }, false) + require.NoError(t, err) + assert.Equal(t, "web-server", peer1.DNSLabel) + + // Add second peer and rename it to a unique FQDN whose first label doesn't collide + key2, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: key2.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "old-name"}, + }, false) + require.NoError(t, err) + + update := peer2.Copy() + update.Name = "api-server.example.com" + updated, err := manager.UpdatePeer(context.Background(), accountID, userID, update) + require.NoError(t, err, "renaming to unique FQDN should succeed") + assert.Equal(t, "api-server", updated.DNSLabel, "DNS label should be first label of FQDN") +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index b3fbfe141..ee1947b18 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -4997,7 +4997,6 @@ func (s *SqlStore) GetServiceByDomain(ctx context.Context, domain string) (*rpse return service, nil } - func (s *SqlStore) GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error) { tx := s.db.Preload("Targets") if lockStrength != LockingStrengthNone { @@ -5408,17 +5407,35 @@ func (s *SqlStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error { return nil } -// UpdateProxyHeartbeat updates the last_seen timestamp for a proxy -func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, proxyID string) error { +// UpdateProxyHeartbeat updates the last_seen timestamp for a proxy or creates a new entry if it doesn't exist +func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { + now := time.Now() + result := s.db.WithContext(ctx). Model(&proxy.Proxy{}). Where("id = ? AND status = ?", proxyID, "connected"). - Update("last_seen", time.Now()) + Update("last_seen", now) if result.Error != nil { log.WithContext(ctx).Errorf("failed to update proxy heartbeat: %v", result.Error) return status.Errorf(status.Internal, "failed to update proxy heartbeat") } + + if result.RowsAffected == 0 { + p := &proxy.Proxy{ + ID: proxyID, + ClusterAddress: clusterAddress, + IPAddress: ipAddress, + LastSeen: now, + ConnectedAt: &now, + Status: "connected", + } + if err := s.db.WithContext(ctx).Save(p).Error; err != nil { + log.WithContext(ctx).Errorf("failed to create proxy on heartbeat: %v", err) + return status.Errorf(status.Internal, "failed to create proxy on heartbeat") + } + } + return nil } @@ -5428,7 +5445,7 @@ func (s *SqlStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]string result := s.db.WithContext(ctx). Model(&proxy.Proxy{}). - Where("status = ? AND last_seen > ?", "connected", time.Now().Add(-2*time.Minute)). + Where("status = ? AND last_seen > ?", "connected", time.Now().Add(-proxyActiveThreshold)). Distinct("cluster_address"). Pluck("cluster_address", &addresses) @@ -5440,6 +5457,81 @@ func (s *SqlStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]string return addresses, nil } +// GetActiveProxyClusters returns all active proxy clusters with their connected proxy count. +func (s *SqlStore) GetActiveProxyClusters(ctx context.Context) ([]proxy.Cluster, error) { + var clusters []proxy.Cluster + + result := s.db.Model(&proxy.Proxy{}). + Select("cluster_address as address, COUNT(*) as connected_proxies"). + Where("status = ? AND last_seen > ?", "connected", time.Now().Add(-proxyActiveThreshold)). + Group("cluster_address"). + Scan(&clusters) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get active proxy clusters: %v", result.Error) + return nil, status.Errorf(status.Internal, "get active proxy clusters") + } + + return clusters, nil +} + +// proxyActiveThreshold is the maximum age of a heartbeat for a proxy to be +// considered active. Must be at least 2x the heartbeat interval (1 min). +const proxyActiveThreshold = 2 * time.Minute + +var validCapabilityColumns = map[string]struct{}{ + "supports_custom_ports": {}, + "require_subdomain": {}, +} + +// GetClusterSupportsCustomPorts returns whether any active proxy in the cluster +// supports custom ports. Returns nil when no proxy reported the capability. +func (s *SqlStore) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { + return s.getClusterCapability(ctx, clusterAddr, "supports_custom_ports") +} + +// GetClusterRequireSubdomain returns whether any active proxy in the cluster +// requires a subdomain. Returns nil when no proxy reported the capability. +func (s *SqlStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { + return s.getClusterCapability(ctx, clusterAddr, "require_subdomain") +} + +// getClusterCapability returns an aggregated boolean capability for the given +// cluster. It checks active (connected, recently seen) proxies and returns: +// - *true if any proxy in the cluster has the capability set to true, +// - *false if at least one proxy reported but none set it to true, +// - nil if no proxy reported the capability at all. +func (s *SqlStore) getClusterCapability(ctx context.Context, clusterAddr, column string) *bool { + if _, ok := validCapabilityColumns[column]; !ok { + log.WithContext(ctx).Errorf("invalid capability column: %s", column) + return nil + } + + var result struct { + HasCapability bool + AnyTrue bool + } + + err := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Select("COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) > 0 AS has_capability, "+ + "COALESCE(MAX(CASE WHEN "+column+" = true THEN 1 ELSE 0 END), 0) = 1 AS any_true"). + Where("cluster_address = ? AND status = ? AND last_seen > ?", + clusterAddr, "connected", time.Now().Add(-proxyActiveThreshold)). + Scan(&result).Error + + if err != nil { + log.WithContext(ctx).Errorf("query cluster capability %s for %s: %v", column, clusterAddr, err) + return nil + } + + if !result.HasCapability { + return nil + } + + return &result.AnyTrue +} + // CleanupStaleProxies deletes proxies that haven't sent heartbeat in the specified duration func (s *SqlStore) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error { cutoffTime := time.Now().Add(-inactivityDuration) @@ -5459,3 +5551,61 @@ func (s *SqlStore) CleanupStaleProxies(ctx context.Context, inactivityDuration t return nil } + +// GetRoutingPeerNetworks returns the distinct network names where the peer is assigned as a routing peer +// in an enabled network router, either directly or via peer groups. +func (s *SqlStore) GetRoutingPeerNetworks(_ context.Context, accountID, peerID string) ([]string, error) { + var routers []*routerTypes.NetworkRouter + if err := s.db.Select("peer, peer_groups, network_id").Where("account_id = ? AND enabled = true", accountID).Find(&routers).Error; err != nil { + return nil, status.Errorf(status.Internal, "failed to get enabled routers: %v", err) + } + + if len(routers) == 0 { + return nil, nil + } + + var groupPeers []types.GroupPeer + if err := s.db.Select("group_id").Where("account_id = ? AND peer_id = ?", accountID, peerID).Find(&groupPeers).Error; err != nil { + return nil, status.Errorf(status.Internal, "failed to get peer group memberships: %v", err) + } + + groupSet := make(map[string]struct{}, len(groupPeers)) + for _, gp := range groupPeers { + groupSet[gp.GroupID] = struct{}{} + } + + networkIDs := make(map[string]struct{}) + for _, r := range routers { + if r.Peer == peerID { + networkIDs[r.NetworkID] = struct{}{} + } else if r.Peer == "" { + for _, pg := range r.PeerGroups { + if _, ok := groupSet[pg]; ok { + networkIDs[r.NetworkID] = struct{}{} + break + } + } + } + } + + if len(networkIDs) == 0 { + return nil, nil + } + + ids := make([]string, 0, len(networkIDs)) + for id := range networkIDs { + ids = append(ids, id) + } + + var networks []*networkTypes.Network + if err := s.db.Select("name").Where("account_id = ? AND id IN ?", accountID, ids).Find(&networks).Error; err != nil { + return nil, status.Errorf(status.Internal, "failed to get networks: %v", err) + } + + names := make([]string, 0, len(networks)) + for _, n := range networks { + names = append(names, n.Name) + } + + return names, nil +} diff --git a/management/server/store/store.go b/management/server/store/store.go index 8bb52f38a..e24a1efef 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -284,11 +284,16 @@ type Store interface { DeleteServiceTargets(ctx context.Context, accountID string, serviceID string) error SaveProxy(ctx context.Context, proxy *proxy.Proxy) error - UpdateProxyHeartbeat(ctx context.Context, proxyID string) error + UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) + GetActiveProxyClusters(ctx context.Context) ([]proxy.Cluster, error) + GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error) + + GetRoutingPeerNetworks(ctx context.Context, accountID, peerID string) ([]string, error) } const ( diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index e75e35b94..a8648aed7 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -165,6 +165,34 @@ func (mr *MockStoreMockRecorder) CleanupStaleProxies(ctx, inactivityDuration int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStaleProxies", reflect.TypeOf((*MockStore)(nil).CleanupStaleProxies), ctx, inactivityDuration) } +// GetClusterSupportsCustomPorts mocks base method. +func (m *MockStore) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterSupportsCustomPorts", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// GetClusterSupportsCustomPorts indicates an expected call of GetClusterSupportsCustomPorts. +func (mr *MockStoreMockRecorder) GetClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCustomPorts", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCustomPorts), ctx, clusterAddr) +} + +// GetClusterRequireSubdomain mocks base method. +func (m *MockStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterRequireSubdomain", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// GetClusterRequireSubdomain indicates an expected call of GetClusterRequireSubdomain. +func (mr *MockStoreMockRecorder) GetClusterRequireSubdomain(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterRequireSubdomain", reflect.TypeOf((*MockStore)(nil).GetClusterRequireSubdomain), ctx, clusterAddr) +} + // Close mocks base method. func (m *MockStore) Close(ctx context.Context) error { m.ctrl.T.Helper() @@ -1287,6 +1315,21 @@ func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddresses(ctx interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddresses", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddresses), ctx) } +// GetActiveProxyClusters mocks base method. +func (m *MockStore) GetActiveProxyClusters(ctx context.Context) ([]proxy.Cluster, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveProxyClusters", ctx) + ret0, _ := ret[0].([]proxy.Cluster) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveProxyClusters indicates an expected call of GetActiveProxyClusters. +func (mr *MockStoreMockRecorder) GetActiveProxyClusters(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusters", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusters), ctx) +} + // GetAllAccounts mocks base method. func (m *MockStore) GetAllAccounts(ctx context.Context) []*types2.Account { m.ctrl.T.Helper() @@ -2318,6 +2361,21 @@ func (mr *MockStoreMockRecorder) IncrementSetupKeyUsage(ctx, setupKeyID interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementSetupKeyUsage", reflect.TypeOf((*MockStore)(nil).IncrementSetupKeyUsage), ctx, setupKeyID) } +// GetRoutingPeerNetworks mocks base method. +func (m *MockStore) GetRoutingPeerNetworks(ctx context.Context, accountID, peerID string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRoutingPeerNetworks", ctx, accountID, peerID) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRoutingPeerNetworks indicates an expected call of GetRoutingPeerNetworks. +func (mr *MockStoreMockRecorder) GetRoutingPeerNetworks(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoutingPeerNetworks", reflect.TypeOf((*MockStore)(nil).GetRoutingPeerNetworks), ctx, accountID, peerID) +} + // IsPrimaryAccount mocks base method. func (m *MockStore) IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error) { m.ctrl.T.Helper() @@ -2924,17 +2982,17 @@ func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{} } // UpdateProxyHeartbeat mocks base method. -func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, proxyID string) error { +func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateProxyHeartbeat", ctx, proxyID) + ret := m.ctrl.Call(m, "UpdateProxyHeartbeat", ctx, proxyID, clusterAddress, ipAddress) ret0, _ := ret[0].(error) return ret0 } // UpdateProxyHeartbeat indicates an expected call of UpdateProxyHeartbeat. -func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, proxyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, proxyID, clusterAddress, ipAddress interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProxyHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateProxyHeartbeat), ctx, proxyID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProxyHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateProxyHeartbeat), ctx, proxyID, clusterAddress, ipAddress) } // UpdateService mocks base method. diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index af2896216..00ba29b7f 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -84,6 +84,12 @@ func setupTestAccount() *Account { }, }, Groups: map[string]*Group{ + "groupAll": { + ID: "groupAll", + Name: "All", + Peers: []string{"peer1", "peer2", "peer3", "peer11", "peer12", "peer21", "peer31", "peer32", "peer41", "peer51", "peer61"}, + Issued: GroupIssuedAPI, + }, "group1": { ID: "group1", Peers: []string{"peer11", "peer12"}, diff --git a/management/server/types/testdata/comparison/components.json b/management/server/types/testdata/comparison/components.json new file mode 100644 index 000000000..838d57b75 --- /dev/null +++ b/management/server/types/testdata/comparison/components.json @@ -0,0 +1,7224 @@ +{ + "PeerID": "peer-60", + "Network": { + "id": "net-comparison-test", + "Net": { + "IP": "100.64.0.0", + "Mask": "//8AAA==" + }, + "Dns": "", + "Serial": 1 + }, + "AccountSettings": { + "PeerLoginExpirationEnabled": true, + "PeerLoginExpiration": 3600000000000, + "PeerInactivityExpirationEnabled": false, + "PeerInactivityExpiration": 0 + }, + "DNSSettings": { + "DisabledManagementGroups": [ + "group-ops" + ] + }, + "CustomZoneDomain": "", + "Peers": { + "peer-0": { + "ID": "peer-0", + "Key": "key-peer-0", + "IP": "100.64.0.1", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer1", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345176+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-1": { + "ID": "peer-1", + "Key": "key-peer-1", + "IP": "100.64.0.2", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer2", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345176+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-10": { + "ID": "peer-10", + "Key": "key-peer-10", + "IP": "100.64.0.11", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer11", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-11": { + "ID": "peer-11", + "Key": "key-peer-11", + "IP": "100.64.0.12", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer12", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-12": { + "ID": "peer-12", + "Key": "key-peer-12", + "IP": "100.64.0.13", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer13", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-13": { + "ID": "peer-13", + "Key": "key-peer-13", + "IP": "100.64.0.14", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer14", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-14": { + "ID": "peer-14", + "Key": "key-peer-14", + "IP": "100.64.0.15", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer15", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34518+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-15": { + "ID": "peer-15", + "Key": "key-peer-15", + "IP": "100.64.0.16", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer16", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34518+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-16": { + "ID": "peer-16", + "Key": "key-peer-16", + "IP": "100.64.0.17", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer17", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34518+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-17": { + "ID": "peer-17", + "Key": "key-peer-17", + "IP": "100.64.0.18", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer18", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-18": { + "ID": "peer-18", + "Key": "key-peer-18", + "IP": "100.64.0.19", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer19", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-19": { + "ID": "peer-19", + "Key": "key-peer-19", + "IP": "100.64.0.20", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer20", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-2": { + "ID": "peer-2", + "Key": "key-peer-2", + "IP": "100.64.0.3", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer3", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-20": { + "ID": "peer-20", + "Key": "key-peer-20", + "IP": "100.64.0.21", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer21", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-21": { + "ID": "peer-21", + "Key": "key-peer-21", + "IP": "100.64.0.22", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer22", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-22": { + "ID": "peer-22", + "Key": "key-peer-22", + "IP": "100.64.0.23", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer23", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-23": { + "ID": "peer-23", + "Key": "key-peer-23", + "IP": "100.64.0.24", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer24", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-24": { + "ID": "peer-24", + "Key": "key-peer-24", + "IP": "100.64.0.25", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer25", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-25": { + "ID": "peer-25", + "Key": "key-peer-25", + "IP": "100.64.0.26", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer26", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-26": { + "ID": "peer-26", + "Key": "key-peer-26", + "IP": "100.64.0.27", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer27", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-27": { + "ID": "peer-27", + "Key": "key-peer-27", + "IP": "100.64.0.28", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer28", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345184+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-28": { + "ID": "peer-28", + "Key": "key-peer-28", + "IP": "100.64.0.29", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer29", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345184+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-29": { + "ID": "peer-29", + "Key": "key-peer-29", + "IP": "100.64.0.30", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer30", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345184+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-3": { + "ID": "peer-3", + "Key": "key-peer-3", + "IP": "100.64.0.4", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer4", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-30": { + "ID": "peer-30", + "Key": "key-peer-30", + "IP": "100.64.0.31", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer31", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-31": { + "ID": "peer-31", + "Key": "key-peer-31", + "IP": "100.64.0.32", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer32", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-32": { + "ID": "peer-32", + "Key": "key-peer-32", + "IP": "100.64.0.33", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer33", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-33": { + "ID": "peer-33", + "Key": "key-peer-33", + "IP": "100.64.0.34", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer34", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-34": { + "ID": "peer-34", + "Key": "key-peer-34", + "IP": "100.64.0.35", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer35", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-35": { + "ID": "peer-35", + "Key": "key-peer-35", + "IP": "100.64.0.36", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer36", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-36": { + "ID": "peer-36", + "Key": "key-peer-36", + "IP": "100.64.0.37", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer37", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-37": { + "ID": "peer-37", + "Key": "key-peer-37", + "IP": "100.64.0.38", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer38", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-38": { + "ID": "peer-38", + "Key": "key-peer-38", + "IP": "100.64.0.39", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer39", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-39": { + "ID": "peer-39", + "Key": "key-peer-39", + "IP": "100.64.0.40", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer40", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-4": { + "ID": "peer-4", + "Key": "key-peer-4", + "IP": "100.64.0.5", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer5", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-40": { + "ID": "peer-40", + "Key": "key-peer-40", + "IP": "100.64.0.41", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer41", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-41": { + "ID": "peer-41", + "Key": "key-peer-41", + "IP": "100.64.0.42", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer42", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345187+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-42": { + "ID": "peer-42", + "Key": "key-peer-42", + "IP": "100.64.0.43", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer43", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345187+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-43": { + "ID": "peer-43", + "Key": "key-peer-43", + "IP": "100.64.0.44", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer44", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-44": { + "ID": "peer-44", + "Key": "key-peer-44", + "IP": "100.64.0.45", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer45", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-45": { + "ID": "peer-45", + "Key": "key-peer-45", + "IP": "100.64.0.46", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer46", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-46": { + "ID": "peer-46", + "Key": "key-peer-46", + "IP": "100.64.0.47", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer47", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-47": { + "ID": "peer-47", + "Key": "key-peer-47", + "IP": "100.64.0.48", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer48", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-48": { + "ID": "peer-48", + "Key": "key-peer-48", + "IP": "100.64.0.49", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer49", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-49": { + "ID": "peer-49", + "Key": "key-peer-49", + "IP": "100.64.0.50", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer50", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-5": { + "ID": "peer-5", + "Key": "key-peer-5", + "IP": "100.64.0.6", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer6", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-50": { + "ID": "peer-50", + "Key": "key-peer-50", + "IP": "100.64.0.51", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer51", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-51": { + "ID": "peer-51", + "Key": "key-peer-51", + "IP": "100.64.0.52", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer52", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-52": { + "ID": "peer-52", + "Key": "key-peer-52", + "IP": "100.64.0.53", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer53", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-53": { + "ID": "peer-53", + "Key": "key-peer-53", + "IP": "100.64.0.54", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer54", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-54": { + "ID": "peer-54", + "Key": "key-peer-54", + "IP": "100.64.0.55", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer55", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-55": { + "ID": "peer-55", + "Key": "key-peer-55", + "IP": "100.64.0.56", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer56", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-56": { + "ID": "peer-56", + "Key": "key-peer-56", + "IP": "100.64.0.57", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer57", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-57": { + "ID": "peer-57", + "Key": "key-peer-57", + "IP": "100.64.0.58", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer58", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345191+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-58": { + "ID": "peer-58", + "Key": "key-peer-58", + "IP": "100.64.0.59", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer59", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345191+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-59": { + "ID": "peer-59", + "Key": "key-peer-59", + "IP": "100.64.0.60", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer60", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-6": { + "ID": "peer-6", + "Key": "key-peer-6", + "IP": "100.64.0.7", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer7", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345178+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-60": { + "ID": "peer-60", + "Key": "key-peer-60", + "IP": "100.64.0.61", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer61", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-61": { + "ID": "peer-61", + "Key": "key-peer-61", + "IP": "100.64.0.62", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer62", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-62": { + "ID": "peer-62", + "Key": "key-peer-62", + "IP": "100.64.0.63", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer63", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-63": { + "ID": "peer-63", + "Key": "key-peer-63", + "IP": "100.64.0.64", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer64", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-64": { + "ID": "peer-64", + "Key": "key-peer-64", + "IP": "100.64.0.65", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer65", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-65": { + "ID": "peer-65", + "Key": "key-peer-65", + "IP": "100.64.0.66", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer66", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-66": { + "ID": "peer-66", + "Key": "key-peer-66", + "IP": "100.64.0.67", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer67", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-67": { + "ID": "peer-67", + "Key": "key-peer-67", + "IP": "100.64.0.68", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer68", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-68": { + "ID": "peer-68", + "Key": "key-peer-68", + "IP": "100.64.0.69", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer69", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-69": { + "ID": "peer-69", + "Key": "key-peer-69", + "IP": "100.64.0.70", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer70", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-7": { + "ID": "peer-7", + "Key": "key-peer-7", + "IP": "100.64.0.8", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer8", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345178+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-70": { + "ID": "peer-70", + "Key": "key-peer-70", + "IP": "100.64.0.71", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer71", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-71": { + "ID": "peer-71", + "Key": "key-peer-71", + "IP": "100.64.0.72", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer72", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-72": { + "ID": "peer-72", + "Key": "key-peer-72", + "IP": "100.64.0.73", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer73", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-73": { + "ID": "peer-73", + "Key": "key-peer-73", + "IP": "100.64.0.74", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer74", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-74": { + "ID": "peer-74", + "Key": "key-peer-74", + "IP": "100.64.0.75", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer75", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-75": { + "ID": "peer-75", + "Key": "key-peer-75", + "IP": "100.64.0.76", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer76", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-76": { + "ID": "peer-76", + "Key": "key-peer-76", + "IP": "100.64.0.77", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer77", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-77": { + "ID": "peer-77", + "Key": "key-peer-77", + "IP": "100.64.0.78", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer78", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-78": { + "ID": "peer-78", + "Key": "key-peer-78", + "IP": "100.64.0.79", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer79", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-79": { + "ID": "peer-79", + "Key": "key-peer-79", + "IP": "100.64.0.80", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer80", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-8": { + "ID": "peer-8", + "Key": "key-peer-8", + "IP": "100.64.0.9", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer9", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345178+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-80": { + "ID": "peer-80", + "Key": "key-peer-80", + "IP": "100.64.0.81", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer81", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-81": { + "ID": "peer-81", + "Key": "key-peer-81", + "IP": "100.64.0.82", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer82", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-82": { + "ID": "peer-82", + "Key": "key-peer-82", + "IP": "100.64.0.83", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer83", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345196+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-83": { + "ID": "peer-83", + "Key": "key-peer-83", + "IP": "100.64.0.84", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer84", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345197+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-84": { + "ID": "peer-84", + "Key": "key-peer-84", + "IP": "100.64.0.85", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer85", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345197+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-85": { + "ID": "peer-85", + "Key": "key-peer-85", + "IP": "100.64.0.86", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer86", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345197+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-86": { + "ID": "peer-86", + "Key": "key-peer-86", + "IP": "100.64.0.87", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer87", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-87": { + "ID": "peer-87", + "Key": "key-peer-87", + "IP": "100.64.0.88", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer88", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-88": { + "ID": "peer-88", + "Key": "key-peer-88", + "IP": "100.64.0.89", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer89", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-89": { + "ID": "peer-89", + "Key": "key-peer-89", + "IP": "100.64.0.90", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer90", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-9": { + "ID": "peer-9", + "Key": "key-peer-9", + "IP": "100.64.0.10", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer10", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-90": { + "ID": "peer-90", + "Key": "key-peer-90", + "IP": "100.64.0.91", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer91", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-91": { + "ID": "peer-91", + "Key": "key-peer-91", + "IP": "100.64.0.92", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer92", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-92": { + "ID": "peer-92", + "Key": "key-peer-92", + "IP": "100.64.0.93", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer93", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-93": { + "ID": "peer-93", + "Key": "key-peer-93", + "IP": "100.64.0.94", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer94", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-94": { + "ID": "peer-94", + "Key": "key-peer-94", + "IP": "100.64.0.95", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer95", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-95": { + "ID": "peer-95", + "Key": "key-peer-95", + "IP": "100.64.0.96", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer96", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-96": { + "ID": "peer-96", + "Key": "key-peer-96", + "IP": "100.64.0.97", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer97", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-97": { + "ID": "peer-97", + "Key": "key-peer-97", + "IP": "100.64.0.98", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer98", + "Status": { + "LastSeen": "2026-02-24T17:47:26.3452+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + "peer-98": { + "ID": "peer-98", + "Key": "key-peer-98", + "IP": "100.64.0.99", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer99", + "Status": { + "LastSeen": "2026-02-24T17:47:26.3452+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": true, + "InactivityExpirationEnabled": false, + "LastLogin": "2026-02-24T15:47:26.3452+01:00", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + } + }, + "Groups": { + "group-all": { + "ID": "group-all", + "Name": "All", + "Issued": "", + "Peers": [ + "peer-0", + "peer-1", + "peer-2", + "peer-3", + "peer-4", + "peer-5", + "peer-6", + "peer-7", + "peer-8", + "peer-9", + "peer-10", + "peer-11", + "peer-12", + "peer-13", + "peer-14", + "peer-15", + "peer-16", + "peer-17", + "peer-18", + "peer-19", + "peer-20", + "peer-21", + "peer-22", + "peer-23", + "peer-24", + "peer-25", + "peer-26", + "peer-27", + "peer-28", + "peer-29", + "peer-30", + "peer-31", + "peer-32", + "peer-33", + "peer-34", + "peer-35", + "peer-36", + "peer-37", + "peer-38", + "peer-39", + "peer-40", + "peer-41", + "peer-42", + "peer-43", + "peer-44", + "peer-45", + "peer-46", + "peer-47", + "peer-48", + "peer-49", + "peer-50", + "peer-51", + "peer-52", + "peer-53", + "peer-54", + "peer-55", + "peer-56", + "peer-57", + "peer-58", + "peer-59", + "peer-60", + "peer-61", + "peer-62", + "peer-63", + "peer-64", + "peer-65", + "peer-66", + "peer-67", + "peer-68", + "peer-69", + "peer-70", + "peer-71", + "peer-72", + "peer-73", + "peer-74", + "peer-75", + "peer-76", + "peer-77", + "peer-78", + "peer-79", + "peer-80", + "peer-81", + "peer-82", + "peer-83", + "peer-84", + "peer-85", + "peer-86", + "peer-87", + "peer-88", + "peer-89", + "peer-90", + "peer-91", + "peer-92", + "peer-93", + "peer-94", + "peer-95", + "peer-96", + "peer-97", + "peer-98" + ], + "GroupPeers": [], + "Resources": [], + "IntegrationReference": { + "ID": 0, + "IntegrationType": "" + } + }, + "group-dev": { + "ID": "group-dev", + "Name": "Developers", + "Issued": "", + "Peers": [ + "peer-0", + "peer-1", + "peer-2", + "peer-3", + "peer-4", + "peer-5", + "peer-6", + "peer-7", + "peer-8", + "peer-9", + "peer-10", + "peer-11", + "peer-12", + "peer-13", + "peer-14", + "peer-15", + "peer-16", + "peer-17", + "peer-18", + "peer-19", + "peer-20", + "peer-21", + "peer-22", + "peer-23", + "peer-24", + "peer-25", + "peer-26", + "peer-27", + "peer-28", + "peer-29", + "peer-30", + "peer-31", + "peer-32", + "peer-33", + "peer-34", + "peer-35", + "peer-36", + "peer-37", + "peer-38", + "peer-39", + "peer-40", + "peer-41", + "peer-42", + "peer-43", + "peer-44", + "peer-45", + "peer-46", + "peer-47", + "peer-48", + "peer-49" + ], + "GroupPeers": null, + "Resources": null, + "IntegrationReference": { + "ID": 0, + "IntegrationType": "" + } + }, + "group-ops": { + "ID": "group-ops", + "Name": "Operations", + "Issued": "", + "Peers": [ + "peer-50", + "peer-51", + "peer-52", + "peer-53", + "peer-54", + "peer-55", + "peer-56", + "peer-57", + "peer-58", + "peer-59", + "peer-60", + "peer-61", + "peer-62", + "peer-63", + "peer-64", + "peer-65", + "peer-66", + "peer-67", + "peer-68", + "peer-69", + "peer-70", + "peer-71", + "peer-72", + "peer-73", + "peer-74", + "peer-75", + "peer-76", + "peer-77", + "peer-78", + "peer-79", + "peer-80", + "peer-81", + "peer-82", + "peer-83", + "peer-84", + "peer-85", + "peer-86", + "peer-87", + "peer-88", + "peer-89", + "peer-90", + "peer-91", + "peer-92", + "peer-93", + "peer-94", + "peer-95", + "peer-96", + "peer-97", + "peer-98" + ], + "GroupPeers": [], + "Resources": [], + "IntegrationReference": { + "ID": 0, + "IntegrationType": "" + } + } + }, + "Policies": [ + { + "ID": "policy-all", + "Name": "Default-Allow", + "Description": "", + "Enabled": true, + "Rules": [ + { + "ID": "policy-all", + "Name": "Allow All", + "Description": "", + "Enabled": true, + "Action": "accept", + "Destinations": [ + "group-all" + ], + "DestinationResource": { + "ID": "", + "Type": "" + }, + "Sources": [ + "group-all" + ], + "SourceResource": { + "ID": "", + "Type": "" + }, + "Bidirectional": true, + "Protocol": "all", + "Ports": null, + "PortRanges": null, + "AuthorizedGroups": null, + "AuthorizedUser": "" + } + ], + "SourcePostureChecks": null + }, + { + "ID": "policy-dev-ops", + "Name": "Dev to Ops Web Access", + "Description": "", + "Enabled": true, + "Rules": [ + { + "ID": "policy-dev-ops", + "Name": "Dev -\u003e Ops (HTTP Range)", + "Description": "", + "Enabled": true, + "Action": "accept", + "Destinations": [ + "group-ops" + ], + "DestinationResource": { + "ID": "", + "Type": "" + }, + "Sources": [ + "group-dev" + ], + "SourceResource": { + "ID": "", + "Type": "" + }, + "Bidirectional": false, + "Protocol": "tcp", + "Ports": null, + "PortRanges": [ + { + "Start": 8080, + "End": 8090 + } + ], + "AuthorizedGroups": null, + "AuthorizedUser": "" + } + ], + "SourcePostureChecks": null + }, + { + "ID": "policy-drop", + "Name": "Drop DB traffic", + "Description": "", + "Enabled": true, + "Rules": [ + { + "ID": "policy-drop", + "Name": "Drop DB", + "Description": "", + "Enabled": true, + "Action": "drop", + "Destinations": [ + "group-ops" + ], + "DestinationResource": { + "ID": "", + "Type": "" + }, + "Sources": [ + "group-dev" + ], + "SourceResource": { + "ID": "", + "Type": "" + }, + "Bidirectional": true, + "Protocol": "tcp", + "Ports": [ + "5432" + ], + "PortRanges": null, + "AuthorizedGroups": null, + "AuthorizedUser": "" + } + ], + "SourcePostureChecks": null + }, + { + "ID": "policy-posture", + "Name": "Posture Check for DB Resource", + "Description": "", + "Enabled": true, + "Rules": [ + { + "ID": "policy-posture", + "Name": "Allow DB Access", + "Description": "", + "Enabled": true, + "Action": "accept", + "Destinations": null, + "DestinationResource": { + "ID": "res-database", + "Type": "" + }, + "Sources": [ + "group-ops" + ], + "SourceResource": { + "ID": "", + "Type": "" + }, + "Bidirectional": true, + "Protocol": "all", + "Ports": null, + "PortRanges": null, + "AuthorizedGroups": null, + "AuthorizedUser": "" + } + ], + "SourcePostureChecks": [ + "posture-check-ver" + ] + } + ], + "Routes": [ + { + "ID": "route-ha-1", + "AccountID": "account-comparison-test", + "Network": "10.10.0.0/16", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "HA Route 1", + "Peer": "key-peer-80", + "PeerID": "peer-80", + "PeerGroups": [ + "group-all" + ], + "NetworkType": 0, + "Masquerade": false, + "Metric": 1000, + "Enabled": true, + "Groups": [ + "group-all" + ], + "AccessControlGroups": [ + "group-all" + ], + "SkipAutoApply": false + }, + { + "ID": "route-ha-2", + "AccountID": "account-comparison-test", + "Network": "10.10.0.0/16", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "HA Route 2", + "Peer": "key-peer-90", + "PeerID": "peer-90", + "PeerGroups": [ + "group-dev", + "group-ops" + ], + "NetworkType": 0, + "Masquerade": false, + "Metric": 900, + "Enabled": true, + "Groups": [ + "group-dev", + "group-ops" + ], + "AccessControlGroups": [ + "group-all" + ], + "SkipAutoApply": false + }, + { + "ID": "route-main", + "AccountID": "account-comparison-test", + "Network": "192.168.10.0/24", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "Route to internal resource", + "Peer": "key-peer-75", + "PeerID": "peer-75", + "PeerGroups": [ + "group-dev", + "group-ops" + ], + "NetworkType": 0, + "Masquerade": false, + "Metric": 0, + "Enabled": true, + "Groups": [ + "group-dev", + "group-ops" + ], + "AccessControlGroups": [ + "group-dev" + ], + "SkipAutoApply": false + } + ], + "NameServerGroups": [ + { + "ID": "ns-group-main", + "AccountID": "", + "Name": "Main NS", + "Description": "", + "NameServers": [ + { + "IP": "8.8.8.8", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "group-dev" + ], + "Primary": false, + "Domains": null, + "Enabled": true, + "SearchDomainsEnabled": false + } + ], + "AllDNSRecords": null, + "AccountZones": null, + "ResourcePoliciesMap": { + "res-database": [ + { + "ID": "policy-posture", + "Name": "Posture Check for DB Resource", + "Description": "", + "Enabled": true, + "Rules": [ + { + "ID": "policy-posture", + "Name": "Allow DB Access", + "Description": "", + "Enabled": true, + "Action": "accept", + "Destinations": null, + "DestinationResource": { + "ID": "res-database", + "Type": "" + }, + "Sources": [ + "group-ops" + ], + "SourceResource": { + "ID": "", + "Type": "" + }, + "Bidirectional": true, + "Protocol": "all", + "Ports": null, + "PortRanges": null, + "AuthorizedGroups": null, + "AuthorizedUser": "" + } + ], + "SourcePostureChecks": [ + "posture-check-ver" + ] + } + ] + }, + "RoutersMap": { + "net-database": { + "peer-95": { + "ID": "router-database", + "NetworkID": "net-database", + "AccountID": "account-comparison-test", + "Peer": "peer-95", + "PeerGroups": null, + "Masquerade": false, + "Metric": 0, + "Enabled": true + } + } + }, + "NetworkResources": [ + { + "ID": "res-database", + "NetworkID": "net-database", + "AccountID": "account-comparison-test", + "Name": "", + "Description": "", + "Type": "", + "Address": "db.netbird.cloud", + "GroupIDs": null, + "Domain": "", + "Prefix": "", + "Enabled": true + } + ], + "GroupIDToUserIDs": null, + "AllowedUserIDs": null, + "PostureFailedPeers": { + "posture-check-ver": { + "peer-51": {}, + "peer-53": {}, + "peer-55": {}, + "peer-57": {}, + "peer-59": {}, + "peer-61": {}, + "peer-63": {}, + "peer-65": {}, + "peer-67": {}, + "peer-69": {}, + "peer-71": {}, + "peer-73": {}, + "peer-75": {}, + "peer-77": {}, + "peer-79": {}, + "peer-81": {}, + "peer-83": {}, + "peer-85": {}, + "peer-87": {}, + "peer-89": {}, + "peer-91": {}, + "peer-93": {}, + "peer-95": {}, + "peer-97": {} + } + }, + "RouterPeers": { + "peer-95": { + "ID": "peer-95", + "Key": "key-peer-95", + "IP": "100.64.0.96", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer96", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + } + } +} \ No newline at end of file diff --git a/management/server/types/testdata/comparison/components_networkmap.json b/management/server/types/testdata/comparison/components_networkmap.json new file mode 100644 index 000000000..2c1b5cd82 --- /dev/null +++ b/management/server/types/testdata/comparison/components_networkmap.json @@ -0,0 +1,10368 @@ +{ + "Peers": [ + { + "ID": "peer-0", + "Key": "key-peer-0", + "IP": "100.64.0.1", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer1", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345176+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-1", + "Key": "key-peer-1", + "IP": "100.64.0.2", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer2", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345176+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-10", + "Key": "key-peer-10", + "IP": "100.64.0.11", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer11", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-11", + "Key": "key-peer-11", + "IP": "100.64.0.12", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer12", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-12", + "Key": "key-peer-12", + "IP": "100.64.0.13", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer13", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-13", + "Key": "key-peer-13", + "IP": "100.64.0.14", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer14", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-14", + "Key": "key-peer-14", + "IP": "100.64.0.15", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer15", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34518+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-15", + "Key": "key-peer-15", + "IP": "100.64.0.16", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer16", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34518+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-16", + "Key": "key-peer-16", + "IP": "100.64.0.17", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer17", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34518+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-17", + "Key": "key-peer-17", + "IP": "100.64.0.18", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer18", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-18", + "Key": "key-peer-18", + "IP": "100.64.0.19", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer19", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-19", + "Key": "key-peer-19", + "IP": "100.64.0.20", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer20", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-2", + "Key": "key-peer-2", + "IP": "100.64.0.3", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer3", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-20", + "Key": "key-peer-20", + "IP": "100.64.0.21", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer21", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-21", + "Key": "key-peer-21", + "IP": "100.64.0.22", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer22", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-22", + "Key": "key-peer-22", + "IP": "100.64.0.23", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer23", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-23", + "Key": "key-peer-23", + "IP": "100.64.0.24", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer24", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-24", + "Key": "key-peer-24", + "IP": "100.64.0.25", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer25", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-25", + "Key": "key-peer-25", + "IP": "100.64.0.26", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer26", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-26", + "Key": "key-peer-26", + "IP": "100.64.0.27", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer27", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-27", + "Key": "key-peer-27", + "IP": "100.64.0.28", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer28", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345184+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-28", + "Key": "key-peer-28", + "IP": "100.64.0.29", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer29", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345184+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-29", + "Key": "key-peer-29", + "IP": "100.64.0.30", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer30", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345184+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-3", + "Key": "key-peer-3", + "IP": "100.64.0.4", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer4", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-30", + "Key": "key-peer-30", + "IP": "100.64.0.31", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer31", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-31", + "Key": "key-peer-31", + "IP": "100.64.0.32", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer32", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-32", + "Key": "key-peer-32", + "IP": "100.64.0.33", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer33", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-33", + "Key": "key-peer-33", + "IP": "100.64.0.34", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer34", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-34", + "Key": "key-peer-34", + "IP": "100.64.0.35", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer35", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-35", + "Key": "key-peer-35", + "IP": "100.64.0.36", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer36", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-36", + "Key": "key-peer-36", + "IP": "100.64.0.37", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer37", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-37", + "Key": "key-peer-37", + "IP": "100.64.0.38", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer38", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-38", + "Key": "key-peer-38", + "IP": "100.64.0.39", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer39", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-39", + "Key": "key-peer-39", + "IP": "100.64.0.40", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer40", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-4", + "Key": "key-peer-4", + "IP": "100.64.0.5", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer5", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-40", + "Key": "key-peer-40", + "IP": "100.64.0.41", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer41", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-41", + "Key": "key-peer-41", + "IP": "100.64.0.42", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer42", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345187+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-42", + "Key": "key-peer-42", + "IP": "100.64.0.43", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer43", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345187+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-43", + "Key": "key-peer-43", + "IP": "100.64.0.44", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer44", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-44", + "Key": "key-peer-44", + "IP": "100.64.0.45", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer45", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-45", + "Key": "key-peer-45", + "IP": "100.64.0.46", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer46", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-46", + "Key": "key-peer-46", + "IP": "100.64.0.47", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer47", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-47", + "Key": "key-peer-47", + "IP": "100.64.0.48", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer48", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-48", + "Key": "key-peer-48", + "IP": "100.64.0.49", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer49", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-49", + "Key": "key-peer-49", + "IP": "100.64.0.50", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer50", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-5", + "Key": "key-peer-5", + "IP": "100.64.0.6", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer6", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-50", + "Key": "key-peer-50", + "IP": "100.64.0.51", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer51", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-51", + "Key": "key-peer-51", + "IP": "100.64.0.52", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer52", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-52", + "Key": "key-peer-52", + "IP": "100.64.0.53", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer53", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-53", + "Key": "key-peer-53", + "IP": "100.64.0.54", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer54", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-54", + "Key": "key-peer-54", + "IP": "100.64.0.55", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer55", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-55", + "Key": "key-peer-55", + "IP": "100.64.0.56", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer56", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-56", + "Key": "key-peer-56", + "IP": "100.64.0.57", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer57", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-57", + "Key": "key-peer-57", + "IP": "100.64.0.58", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer58", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345191+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-58", + "Key": "key-peer-58", + "IP": "100.64.0.59", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer59", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345191+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-59", + "Key": "key-peer-59", + "IP": "100.64.0.60", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer60", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-6", + "Key": "key-peer-6", + "IP": "100.64.0.7", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer7", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345178+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-61", + "Key": "key-peer-61", + "IP": "100.64.0.62", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer62", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-62", + "Key": "key-peer-62", + "IP": "100.64.0.63", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer63", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-63", + "Key": "key-peer-63", + "IP": "100.64.0.64", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer64", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-64", + "Key": "key-peer-64", + "IP": "100.64.0.65", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer65", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-65", + "Key": "key-peer-65", + "IP": "100.64.0.66", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer66", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-66", + "Key": "key-peer-66", + "IP": "100.64.0.67", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer67", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-67", + "Key": "key-peer-67", + "IP": "100.64.0.68", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer68", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-68", + "Key": "key-peer-68", + "IP": "100.64.0.69", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer69", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-69", + "Key": "key-peer-69", + "IP": "100.64.0.70", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer70", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-7", + "Key": "key-peer-7", + "IP": "100.64.0.8", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer8", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345178+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-70", + "Key": "key-peer-70", + "IP": "100.64.0.71", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer71", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-71", + "Key": "key-peer-71", + "IP": "100.64.0.72", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer72", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-72", + "Key": "key-peer-72", + "IP": "100.64.0.73", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer73", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-73", + "Key": "key-peer-73", + "IP": "100.64.0.74", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer74", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-74", + "Key": "key-peer-74", + "IP": "100.64.0.75", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer75", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-75", + "Key": "key-peer-75", + "IP": "100.64.0.76", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer76", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-76", + "Key": "key-peer-76", + "IP": "100.64.0.77", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer77", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-77", + "Key": "key-peer-77", + "IP": "100.64.0.78", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer78", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-78", + "Key": "key-peer-78", + "IP": "100.64.0.79", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer79", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-79", + "Key": "key-peer-79", + "IP": "100.64.0.80", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer80", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-8", + "Key": "key-peer-8", + "IP": "100.64.0.9", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer9", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345178+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-80", + "Key": "key-peer-80", + "IP": "100.64.0.81", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer81", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-81", + "Key": "key-peer-81", + "IP": "100.64.0.82", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer82", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-82", + "Key": "key-peer-82", + "IP": "100.64.0.83", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer83", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345196+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-83", + "Key": "key-peer-83", + "IP": "100.64.0.84", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer84", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345197+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-84", + "Key": "key-peer-84", + "IP": "100.64.0.85", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer85", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345197+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-85", + "Key": "key-peer-85", + "IP": "100.64.0.86", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer86", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345197+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-86", + "Key": "key-peer-86", + "IP": "100.64.0.87", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer87", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-87", + "Key": "key-peer-87", + "IP": "100.64.0.88", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer88", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-88", + "Key": "key-peer-88", + "IP": "100.64.0.89", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer89", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-89", + "Key": "key-peer-89", + "IP": "100.64.0.90", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer90", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-9", + "Key": "key-peer-9", + "IP": "100.64.0.10", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer10", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-90", + "Key": "key-peer-90", + "IP": "100.64.0.91", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer91", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-91", + "Key": "key-peer-91", + "IP": "100.64.0.92", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer92", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-92", + "Key": "key-peer-92", + "IP": "100.64.0.93", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer93", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-93", + "Key": "key-peer-93", + "IP": "100.64.0.94", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer94", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-94", + "Key": "key-peer-94", + "IP": "100.64.0.95", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer95", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-95", + "Key": "key-peer-95", + "IP": "100.64.0.96", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer96", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-96", + "Key": "key-peer-96", + "IP": "100.64.0.97", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer97", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-97", + "Key": "key-peer-97", + "IP": "100.64.0.98", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer98", + "Status": { + "LastSeen": "2026-02-24T17:47:26.3452+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + } + ], + "Network": { + "id": "net-comparison-test", + "Net": { + "IP": "100.64.0.0", + "Mask": "//8AAA==" + }, + "Dns": "", + "Serial": 1 + }, + "Routes": [ + { + "ID": "res-database:peer-95", + "AccountID": "account-comparison-test", + "Network": "", + "Domains": null, + "KeepRoute": true, + "NetID": "", + "Description": "", + "Peer": "key-peer-95", + "PeerID": "peer-95", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 0, + "Enabled": true, + "Groups": null, + "AccessControlGroups": null, + "SkipAutoApply": false + }, + { + "ID": "route-ha-1:peer-60", + "AccountID": "account-comparison-test", + "Network": "10.10.0.0/16", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "HA Route 1", + "Peer": "key-peer-60", + "PeerID": "peer-80", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 1000, + "Enabled": true, + "Groups": [ + "group-all" + ], + "AccessControlGroups": [ + "group-all" + ], + "SkipAutoApply": false + }, + { + "ID": "route-ha-2:peer-60", + "AccountID": "account-comparison-test", + "Network": "10.10.0.0/16", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "HA Route 2", + "Peer": "key-peer-60", + "PeerID": "peer-90", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 900, + "Enabled": true, + "Groups": [ + "group-dev", + "group-ops" + ], + "AccessControlGroups": [ + "group-all" + ], + "SkipAutoApply": false + }, + { + "ID": "route-main:peer-60", + "AccountID": "account-comparison-test", + "Network": "192.168.10.0/24", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "Route to internal resource", + "Peer": "key-peer-60", + "PeerID": "peer-75", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 0, + "Enabled": true, + "Groups": [ + "group-dev", + "group-ops" + ], + "AccessControlGroups": [ + "group-dev" + ], + "SkipAutoApply": false + } + ], + "DNSConfig": { + "ServiceEnable": false, + "NameServerGroups": null, + "CustomZones": null, + "ForwarderPort": 0 + }, + "OfflinePeers": [ + { + "ID": "peer-98", + "Key": "key-peer-98", + "IP": "100.64.0.99", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer99", + "Status": { + "LastSeen": "2026-02-24T17:47:26.3452+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": true, + "InactivityExpirationEnabled": false, + "LastLogin": "2026-02-24T15:47:26.3452+01:00", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + } + ], + "FirewallRules": [ + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.1", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.1", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.1", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.1", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.10", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.10", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.10", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.10", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.11", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.11", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.11", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.11", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.12", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.12", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.12", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.12", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.13", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.13", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.13", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.13", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.14", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.14", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.14", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.14", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.15", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.15", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.15", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.15", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.16", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.16", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.16", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.16", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.17", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.17", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.17", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.17", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.18", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.18", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.18", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.18", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.19", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.19", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.19", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.19", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.2", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.2", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.2", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.2", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.20", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.20", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.20", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.20", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.21", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.21", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.21", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.21", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.22", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.22", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.22", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.22", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.23", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.23", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.23", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.23", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.24", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.24", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.24", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.24", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.25", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.25", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.25", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.25", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.26", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.26", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.26", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.26", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.27", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.27", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.27", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.27", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.28", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.28", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.28", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.28", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.29", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.29", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.29", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.29", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.3", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.3", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.3", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.3", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.30", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.30", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.30", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.30", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.31", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.31", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.31", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.31", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.32", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.32", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.32", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.32", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.33", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.33", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.33", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.33", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.34", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.34", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.34", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.34", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.35", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.35", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.35", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.35", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.36", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.36", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.36", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.36", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.37", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.37", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.37", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.37", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.38", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.38", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.38", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.38", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.39", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.39", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.39", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.39", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.4", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.4", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.4", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.4", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.40", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.40", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.40", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.40", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.41", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.41", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.41", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.41", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.42", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.42", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.42", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.42", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.43", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.43", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.43", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.43", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.44", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.44", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.44", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.44", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.45", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.45", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.45", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.45", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.46", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.46", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.46", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.46", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.47", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.47", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.47", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.47", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.48", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.48", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.48", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.48", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.49", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.49", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.49", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.49", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.5", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.5", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.5", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.5", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.50", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.50", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.50", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.50", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.51", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.51", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.52", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.52", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.53", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.53", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.54", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.54", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.55", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.55", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.56", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.56", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.57", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.57", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.58", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.58", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.59", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.59", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.6", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.6", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.6", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.6", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.60", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.60", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.62", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.62", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.63", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.63", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.64", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.64", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.65", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.65", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.66", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.66", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.67", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.67", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.68", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.68", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.69", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.69", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.7", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.7", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.7", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.7", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.70", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.70", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.71", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.71", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.72", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.72", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.73", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.73", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.74", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.74", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.75", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.75", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.76", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.76", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.77", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.77", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.78", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.78", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.79", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.79", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.8", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.8", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.8", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.8", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.80", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.80", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.81", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.81", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.82", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.82", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.83", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.83", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.84", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.84", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.85", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.85", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.86", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.86", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.87", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.87", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.88", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.88", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.89", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.89", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.9", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.9", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.9", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.9", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.90", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.90", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.91", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.91", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.92", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.92", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.93", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.93", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.94", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.94", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.95", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.95", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.96", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.96", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.97", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.97", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.98", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.98", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.99", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.99", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + } + ], + "RoutesFirewallRules": [ + { + "PolicyID": "", + "RouteID": "route-ha-1:peer-60", + "SourceRanges": [ + "100.64.0.1/32", + "100.64.0.10/32", + "100.64.0.11/32", + "100.64.0.12/32", + "100.64.0.13/32", + "100.64.0.14/32", + "100.64.0.15/32", + "100.64.0.16/32", + "100.64.0.17/32", + "100.64.0.18/32", + "100.64.0.19/32", + "100.64.0.2/32", + "100.64.0.20/32", + "100.64.0.21/32", + "100.64.0.22/32", + "100.64.0.23/32", + "100.64.0.24/32", + "100.64.0.25/32", + "100.64.0.26/32", + "100.64.0.27/32", + "100.64.0.28/32", + "100.64.0.29/32", + "100.64.0.3/32", + "100.64.0.30/32", + "100.64.0.31/32", + "100.64.0.32/32", + "100.64.0.33/32", + "100.64.0.34/32", + "100.64.0.35/32", + "100.64.0.36/32", + "100.64.0.37/32", + "100.64.0.38/32", + "100.64.0.39/32", + "100.64.0.4/32", + "100.64.0.40/32", + "100.64.0.41/32", + "100.64.0.42/32", + "100.64.0.43/32", + "100.64.0.44/32", + "100.64.0.45/32", + "100.64.0.46/32", + "100.64.0.47/32", + "100.64.0.48/32", + "100.64.0.49/32", + "100.64.0.5/32", + "100.64.0.50/32", + "100.64.0.51/32", + "100.64.0.52/32", + "100.64.0.53/32", + "100.64.0.54/32", + "100.64.0.55/32", + "100.64.0.56/32", + "100.64.0.57/32", + "100.64.0.58/32", + "100.64.0.59/32", + "100.64.0.6/32", + "100.64.0.60/32", + "100.64.0.62/32", + "100.64.0.63/32", + "100.64.0.64/32", + "100.64.0.65/32", + "100.64.0.66/32", + "100.64.0.67/32", + "100.64.0.68/32", + "100.64.0.69/32", + "100.64.0.7/32", + "100.64.0.70/32", + "100.64.0.71/32", + "100.64.0.72/32", + "100.64.0.73/32", + "100.64.0.74/32", + "100.64.0.75/32", + "100.64.0.76/32", + "100.64.0.77/32", + "100.64.0.78/32", + "100.64.0.79/32", + "100.64.0.8/32", + "100.64.0.80/32", + "100.64.0.81/32", + "100.64.0.82/32", + "100.64.0.83/32", + "100.64.0.84/32", + "100.64.0.85/32", + "100.64.0.86/32", + "100.64.0.87/32", + "100.64.0.88/32", + "100.64.0.89/32", + "100.64.0.9/32", + "100.64.0.90/32", + "100.64.0.91/32", + "100.64.0.92/32", + "100.64.0.93/32", + "100.64.0.94/32", + "100.64.0.95/32", + "100.64.0.96/32", + "100.64.0.97/32", + "100.64.0.98/32", + "100.64.0.99/32" + ], + "Action": "accept", + "Destination": "10.10.0.0/16", + "Protocol": "all", + "Port": 0, + "PortRange": { + "Start": 0, + "End": 0 + }, + "Domains": null, + "IsDynamic": false + }, + { + "PolicyID": "", + "RouteID": "route-ha-2:peer-60", + "SourceRanges": [ + "100.64.0.1/32", + "100.64.0.10/32", + "100.64.0.11/32", + "100.64.0.12/32", + "100.64.0.13/32", + "100.64.0.14/32", + "100.64.0.15/32", + "100.64.0.16/32", + "100.64.0.17/32", + "100.64.0.18/32", + "100.64.0.19/32", + "100.64.0.2/32", + "100.64.0.20/32", + "100.64.0.21/32", + "100.64.0.22/32", + "100.64.0.23/32", + "100.64.0.24/32", + "100.64.0.25/32", + "100.64.0.26/32", + "100.64.0.27/32", + "100.64.0.28/32", + "100.64.0.29/32", + "100.64.0.3/32", + "100.64.0.30/32", + "100.64.0.31/32", + "100.64.0.32/32", + "100.64.0.33/32", + "100.64.0.34/32", + "100.64.0.35/32", + "100.64.0.36/32", + "100.64.0.37/32", + "100.64.0.38/32", + "100.64.0.39/32", + "100.64.0.4/32", + "100.64.0.40/32", + "100.64.0.41/32", + "100.64.0.42/32", + "100.64.0.43/32", + "100.64.0.44/32", + "100.64.0.45/32", + "100.64.0.46/32", + "100.64.0.47/32", + "100.64.0.48/32", + "100.64.0.49/32", + "100.64.0.5/32", + "100.64.0.50/32", + "100.64.0.51/32", + "100.64.0.52/32", + "100.64.0.53/32", + "100.64.0.54/32", + "100.64.0.55/32", + "100.64.0.56/32", + "100.64.0.57/32", + "100.64.0.58/32", + "100.64.0.59/32", + "100.64.0.6/32", + "100.64.0.60/32", + "100.64.0.62/32", + "100.64.0.63/32", + "100.64.0.64/32", + "100.64.0.65/32", + "100.64.0.66/32", + "100.64.0.67/32", + "100.64.0.68/32", + "100.64.0.69/32", + "100.64.0.7/32", + "100.64.0.70/32", + "100.64.0.71/32", + "100.64.0.72/32", + "100.64.0.73/32", + "100.64.0.74/32", + "100.64.0.75/32", + "100.64.0.76/32", + "100.64.0.77/32", + "100.64.0.78/32", + "100.64.0.79/32", + "100.64.0.8/32", + "100.64.0.80/32", + "100.64.0.81/32", + "100.64.0.82/32", + "100.64.0.83/32", + "100.64.0.84/32", + "100.64.0.85/32", + "100.64.0.86/32", + "100.64.0.87/32", + "100.64.0.88/32", + "100.64.0.89/32", + "100.64.0.9/32", + "100.64.0.90/32", + "100.64.0.91/32", + "100.64.0.92/32", + "100.64.0.93/32", + "100.64.0.94/32", + "100.64.0.95/32", + "100.64.0.96/32", + "100.64.0.97/32", + "100.64.0.98/32", + "100.64.0.99/32" + ], + "Action": "accept", + "Destination": "10.10.0.0/16", + "Protocol": "all", + "Port": 0, + "PortRange": { + "Start": 0, + "End": 0 + }, + "Domains": null, + "IsDynamic": false + } + ], + "ForwardingRules": null, + "AuthorizedUsers": {}, + "EnableSSH": false +} \ No newline at end of file diff --git a/management/server/types/testdata/comparison/legacy_networkmap.json b/management/server/types/testdata/comparison/legacy_networkmap.json new file mode 100644 index 000000000..2c1b5cd82 --- /dev/null +++ b/management/server/types/testdata/comparison/legacy_networkmap.json @@ -0,0 +1,10368 @@ +{ + "Peers": [ + { + "ID": "peer-0", + "Key": "key-peer-0", + "IP": "100.64.0.1", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer1", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345176+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-1", + "Key": "key-peer-1", + "IP": "100.64.0.2", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer2", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345176+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-10", + "Key": "key-peer-10", + "IP": "100.64.0.11", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer11", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-11", + "Key": "key-peer-11", + "IP": "100.64.0.12", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer12", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-12", + "Key": "key-peer-12", + "IP": "100.64.0.13", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer13", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-13", + "Key": "key-peer-13", + "IP": "100.64.0.14", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer14", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-14", + "Key": "key-peer-14", + "IP": "100.64.0.15", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer15", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34518+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-15", + "Key": "key-peer-15", + "IP": "100.64.0.16", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer16", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34518+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-16", + "Key": "key-peer-16", + "IP": "100.64.0.17", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer17", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34518+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-17", + "Key": "key-peer-17", + "IP": "100.64.0.18", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer18", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-18", + "Key": "key-peer-18", + "IP": "100.64.0.19", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer19", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-19", + "Key": "key-peer-19", + "IP": "100.64.0.20", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer20", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-2", + "Key": "key-peer-2", + "IP": "100.64.0.3", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer3", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-20", + "Key": "key-peer-20", + "IP": "100.64.0.21", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer21", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345182+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-21", + "Key": "key-peer-21", + "IP": "100.64.0.22", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer22", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-22", + "Key": "key-peer-22", + "IP": "100.64.0.23", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer23", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-23", + "Key": "key-peer-23", + "IP": "100.64.0.24", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer24", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-24", + "Key": "key-peer-24", + "IP": "100.64.0.25", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer25", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-25", + "Key": "key-peer-25", + "IP": "100.64.0.26", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer26", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-26", + "Key": "key-peer-26", + "IP": "100.64.0.27", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer27", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345183+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-27", + "Key": "key-peer-27", + "IP": "100.64.0.28", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer28", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345184+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-28", + "Key": "key-peer-28", + "IP": "100.64.0.29", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer29", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345184+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-29", + "Key": "key-peer-29", + "IP": "100.64.0.30", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer30", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345184+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-3", + "Key": "key-peer-3", + "IP": "100.64.0.4", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer4", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-30", + "Key": "key-peer-30", + "IP": "100.64.0.31", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer31", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-31", + "Key": "key-peer-31", + "IP": "100.64.0.32", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer32", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-32", + "Key": "key-peer-32", + "IP": "100.64.0.33", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer33", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-33", + "Key": "key-peer-33", + "IP": "100.64.0.34", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer34", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-34", + "Key": "key-peer-34", + "IP": "100.64.0.35", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer35", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345185+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-35", + "Key": "key-peer-35", + "IP": "100.64.0.36", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer36", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-36", + "Key": "key-peer-36", + "IP": "100.64.0.37", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer37", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-37", + "Key": "key-peer-37", + "IP": "100.64.0.38", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer38", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-38", + "Key": "key-peer-38", + "IP": "100.64.0.39", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer39", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-39", + "Key": "key-peer-39", + "IP": "100.64.0.40", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer40", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-4", + "Key": "key-peer-4", + "IP": "100.64.0.5", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer5", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-40", + "Key": "key-peer-40", + "IP": "100.64.0.41", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer41", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345186+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-41", + "Key": "key-peer-41", + "IP": "100.64.0.42", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer42", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345187+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-42", + "Key": "key-peer-42", + "IP": "100.64.0.43", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer43", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345187+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-43", + "Key": "key-peer-43", + "IP": "100.64.0.44", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer44", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-44", + "Key": "key-peer-44", + "IP": "100.64.0.45", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer45", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-45", + "Key": "key-peer-45", + "IP": "100.64.0.46", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer46", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-46", + "Key": "key-peer-46", + "IP": "100.64.0.47", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer47", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-47", + "Key": "key-peer-47", + "IP": "100.64.0.48", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer48", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345188+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-48", + "Key": "key-peer-48", + "IP": "100.64.0.49", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer49", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-49", + "Key": "key-peer-49", + "IP": "100.64.0.50", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer50", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-5", + "Key": "key-peer-5", + "IP": "100.64.0.6", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer6", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345177+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-50", + "Key": "key-peer-50", + "IP": "100.64.0.51", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer51", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-51", + "Key": "key-peer-51", + "IP": "100.64.0.52", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer52", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-52", + "Key": "key-peer-52", + "IP": "100.64.0.53", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer53", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345189+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-53", + "Key": "key-peer-53", + "IP": "100.64.0.54", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer54", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-54", + "Key": "key-peer-54", + "IP": "100.64.0.55", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer55", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-55", + "Key": "key-peer-55", + "IP": "100.64.0.56", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer56", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-56", + "Key": "key-peer-56", + "IP": "100.64.0.57", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer57", + "Status": { + "LastSeen": "2026-02-24T17:47:26.34519+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-57", + "Key": "key-peer-57", + "IP": "100.64.0.58", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer58", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345191+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-58", + "Key": "key-peer-58", + "IP": "100.64.0.59", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer59", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345191+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-59", + "Key": "key-peer-59", + "IP": "100.64.0.60", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer60", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-6", + "Key": "key-peer-6", + "IP": "100.64.0.7", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer7", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345178+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-61", + "Key": "key-peer-61", + "IP": "100.64.0.62", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer62", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-62", + "Key": "key-peer-62", + "IP": "100.64.0.63", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer63", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-63", + "Key": "key-peer-63", + "IP": "100.64.0.64", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer64", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-64", + "Key": "key-peer-64", + "IP": "100.64.0.65", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer65", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345192+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-65", + "Key": "key-peer-65", + "IP": "100.64.0.66", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer66", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-66", + "Key": "key-peer-66", + "IP": "100.64.0.67", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer67", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-67", + "Key": "key-peer-67", + "IP": "100.64.0.68", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer68", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-68", + "Key": "key-peer-68", + "IP": "100.64.0.69", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer69", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-69", + "Key": "key-peer-69", + "IP": "100.64.0.70", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer70", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345193+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-7", + "Key": "key-peer-7", + "IP": "100.64.0.8", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer8", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345178+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-70", + "Key": "key-peer-70", + "IP": "100.64.0.71", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer71", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-71", + "Key": "key-peer-71", + "IP": "100.64.0.72", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer72", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-72", + "Key": "key-peer-72", + "IP": "100.64.0.73", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer73", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-73", + "Key": "key-peer-73", + "IP": "100.64.0.74", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer74", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-74", + "Key": "key-peer-74", + "IP": "100.64.0.75", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer75", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-75", + "Key": "key-peer-75", + "IP": "100.64.0.76", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer76", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345194+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-76", + "Key": "key-peer-76", + "IP": "100.64.0.77", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer77", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-77", + "Key": "key-peer-77", + "IP": "100.64.0.78", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer78", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-78", + "Key": "key-peer-78", + "IP": "100.64.0.79", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer79", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-79", + "Key": "key-peer-79", + "IP": "100.64.0.80", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer80", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-8", + "Key": "key-peer-8", + "IP": "100.64.0.9", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer9", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345178+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-80", + "Key": "key-peer-80", + "IP": "100.64.0.81", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer81", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-81", + "Key": "key-peer-81", + "IP": "100.64.0.82", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer82", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345195+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-82", + "Key": "key-peer-82", + "IP": "100.64.0.83", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer83", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345196+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-83", + "Key": "key-peer-83", + "IP": "100.64.0.84", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer84", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345197+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-84", + "Key": "key-peer-84", + "IP": "100.64.0.85", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer85", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345197+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-85", + "Key": "key-peer-85", + "IP": "100.64.0.86", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer86", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345197+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-86", + "Key": "key-peer-86", + "IP": "100.64.0.87", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer87", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-87", + "Key": "key-peer-87", + "IP": "100.64.0.88", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer88", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-88", + "Key": "key-peer-88", + "IP": "100.64.0.89", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer89", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-89", + "Key": "key-peer-89", + "IP": "100.64.0.90", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer90", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-9", + "Key": "key-peer-9", + "IP": "100.64.0.10", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer10", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345179+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-90", + "Key": "key-peer-90", + "IP": "100.64.0.91", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer91", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-91", + "Key": "key-peer-91", + "IP": "100.64.0.92", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer92", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345198+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-92", + "Key": "key-peer-92", + "IP": "100.64.0.93", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer93", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-93", + "Key": "key-peer-93", + "IP": "100.64.0.94", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer94", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-94", + "Key": "key-peer-94", + "IP": "100.64.0.95", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer95", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-95", + "Key": "key-peer-95", + "IP": "100.64.0.96", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer96", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-96", + "Key": "key-peer-96", + "IP": "100.64.0.97", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer97", + "Status": { + "LastSeen": "2026-02-24T17:47:26.345199+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-97", + "Key": "key-peer-97", + "IP": "100.64.0.98", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer98", + "Status": { + "LastSeen": "2026-02-24T17:47:26.3452+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": null, + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + } + ], + "Network": { + "id": "net-comparison-test", + "Net": { + "IP": "100.64.0.0", + "Mask": "//8AAA==" + }, + "Dns": "", + "Serial": 1 + }, + "Routes": [ + { + "ID": "res-database:peer-95", + "AccountID": "account-comparison-test", + "Network": "", + "Domains": null, + "KeepRoute": true, + "NetID": "", + "Description": "", + "Peer": "key-peer-95", + "PeerID": "peer-95", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 0, + "Enabled": true, + "Groups": null, + "AccessControlGroups": null, + "SkipAutoApply": false + }, + { + "ID": "route-ha-1:peer-60", + "AccountID": "account-comparison-test", + "Network": "10.10.0.0/16", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "HA Route 1", + "Peer": "key-peer-60", + "PeerID": "peer-80", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 1000, + "Enabled": true, + "Groups": [ + "group-all" + ], + "AccessControlGroups": [ + "group-all" + ], + "SkipAutoApply": false + }, + { + "ID": "route-ha-2:peer-60", + "AccountID": "account-comparison-test", + "Network": "10.10.0.0/16", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "HA Route 2", + "Peer": "key-peer-60", + "PeerID": "peer-90", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 900, + "Enabled": true, + "Groups": [ + "group-dev", + "group-ops" + ], + "AccessControlGroups": [ + "group-all" + ], + "SkipAutoApply": false + }, + { + "ID": "route-main:peer-60", + "AccountID": "account-comparison-test", + "Network": "192.168.10.0/24", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "Route to internal resource", + "Peer": "key-peer-60", + "PeerID": "peer-75", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 0, + "Enabled": true, + "Groups": [ + "group-dev", + "group-ops" + ], + "AccessControlGroups": [ + "group-dev" + ], + "SkipAutoApply": false + } + ], + "DNSConfig": { + "ServiceEnable": false, + "NameServerGroups": null, + "CustomZones": null, + "ForwarderPort": 0 + }, + "OfflinePeers": [ + { + "ID": "peer-98", + "Key": "key-peer-98", + "IP": "100.64.0.99", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer99", + "Status": { + "LastSeen": "2026-02-24T17:47:26.3452+01:00", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": true, + "InactivityExpirationEnabled": false, + "LastLogin": "2026-02-24T15:47:26.3452+01:00", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + } + ], + "FirewallRules": [ + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.1", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.1", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.1", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.1", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.10", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.10", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.10", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.10", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.11", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.11", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.11", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.11", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.12", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.12", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.12", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.12", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.13", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.13", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.13", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.13", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.14", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.14", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.14", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.14", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.15", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.15", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.15", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.15", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.16", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.16", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.16", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.16", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.17", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.17", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.17", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.17", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.18", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.18", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.18", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.18", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.19", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.19", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.19", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.19", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.2", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.2", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.2", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.2", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.20", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.20", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.20", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.20", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.21", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.21", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.21", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.21", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.22", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.22", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.22", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.22", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.23", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.23", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.23", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.23", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.24", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.24", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.24", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.24", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.25", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.25", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.25", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.25", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.26", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.26", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.26", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.26", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.27", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.27", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.27", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.27", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.28", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.28", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.28", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.28", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.29", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.29", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.29", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.29", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.3", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.3", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.3", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.3", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.30", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.30", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.30", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.30", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.31", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.31", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.31", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.31", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.32", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.32", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.32", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.32", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.33", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.33", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.33", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.33", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.34", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.34", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.34", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.34", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.35", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.35", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.35", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.35", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.36", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.36", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.36", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.36", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.37", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.37", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.37", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.37", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.38", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.38", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.38", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.38", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.39", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.39", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.39", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.39", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.4", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.4", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.4", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.4", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.40", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.40", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.40", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.40", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.41", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.41", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.41", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.41", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.42", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.42", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.42", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.42", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.43", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.43", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.43", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.43", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.44", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.44", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.44", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.44", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.45", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.45", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.45", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.45", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.46", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.46", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.46", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.46", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.47", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.47", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.47", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.47", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.48", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.48", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.48", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.48", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.49", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.49", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.49", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.49", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.5", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.5", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.5", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.5", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.50", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.50", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.50", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.50", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.51", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.51", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.52", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.52", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.53", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.53", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.54", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.54", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.55", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.55", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.56", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.56", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.57", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.57", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.58", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.58", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.59", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.59", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.6", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.6", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.6", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.6", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.60", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.60", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.62", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.62", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.63", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.63", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.64", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.64", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.65", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.65", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.66", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.66", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.67", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.67", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.68", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.68", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.69", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.69", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.7", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.7", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.7", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.7", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.70", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.70", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.71", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.71", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.72", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.72", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.73", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.73", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.74", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.74", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.75", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.75", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.76", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.76", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.77", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.77", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.78", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.78", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.79", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.79", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.8", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.8", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.8", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.8", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.80", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.80", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.81", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.81", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.82", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.82", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.83", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.83", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.84", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.84", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.85", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.85", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.86", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.86", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.87", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.87", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.88", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.88", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.89", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.89", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.9", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.9", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.9", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.9", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.90", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.90", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.91", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.91", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.92", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.92", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.93", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.93", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.94", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.94", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.95", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.95", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.96", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.96", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.97", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.97", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.98", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.98", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.99", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.99", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + } + ], + "RoutesFirewallRules": [ + { + "PolicyID": "", + "RouteID": "route-ha-1:peer-60", + "SourceRanges": [ + "100.64.0.1/32", + "100.64.0.10/32", + "100.64.0.11/32", + "100.64.0.12/32", + "100.64.0.13/32", + "100.64.0.14/32", + "100.64.0.15/32", + "100.64.0.16/32", + "100.64.0.17/32", + "100.64.0.18/32", + "100.64.0.19/32", + "100.64.0.2/32", + "100.64.0.20/32", + "100.64.0.21/32", + "100.64.0.22/32", + "100.64.0.23/32", + "100.64.0.24/32", + "100.64.0.25/32", + "100.64.0.26/32", + "100.64.0.27/32", + "100.64.0.28/32", + "100.64.0.29/32", + "100.64.0.3/32", + "100.64.0.30/32", + "100.64.0.31/32", + "100.64.0.32/32", + "100.64.0.33/32", + "100.64.0.34/32", + "100.64.0.35/32", + "100.64.0.36/32", + "100.64.0.37/32", + "100.64.0.38/32", + "100.64.0.39/32", + "100.64.0.4/32", + "100.64.0.40/32", + "100.64.0.41/32", + "100.64.0.42/32", + "100.64.0.43/32", + "100.64.0.44/32", + "100.64.0.45/32", + "100.64.0.46/32", + "100.64.0.47/32", + "100.64.0.48/32", + "100.64.0.49/32", + "100.64.0.5/32", + "100.64.0.50/32", + "100.64.0.51/32", + "100.64.0.52/32", + "100.64.0.53/32", + "100.64.0.54/32", + "100.64.0.55/32", + "100.64.0.56/32", + "100.64.0.57/32", + "100.64.0.58/32", + "100.64.0.59/32", + "100.64.0.6/32", + "100.64.0.60/32", + "100.64.0.62/32", + "100.64.0.63/32", + "100.64.0.64/32", + "100.64.0.65/32", + "100.64.0.66/32", + "100.64.0.67/32", + "100.64.0.68/32", + "100.64.0.69/32", + "100.64.0.7/32", + "100.64.0.70/32", + "100.64.0.71/32", + "100.64.0.72/32", + "100.64.0.73/32", + "100.64.0.74/32", + "100.64.0.75/32", + "100.64.0.76/32", + "100.64.0.77/32", + "100.64.0.78/32", + "100.64.0.79/32", + "100.64.0.8/32", + "100.64.0.80/32", + "100.64.0.81/32", + "100.64.0.82/32", + "100.64.0.83/32", + "100.64.0.84/32", + "100.64.0.85/32", + "100.64.0.86/32", + "100.64.0.87/32", + "100.64.0.88/32", + "100.64.0.89/32", + "100.64.0.9/32", + "100.64.0.90/32", + "100.64.0.91/32", + "100.64.0.92/32", + "100.64.0.93/32", + "100.64.0.94/32", + "100.64.0.95/32", + "100.64.0.96/32", + "100.64.0.97/32", + "100.64.0.98/32", + "100.64.0.99/32" + ], + "Action": "accept", + "Destination": "10.10.0.0/16", + "Protocol": "all", + "Port": 0, + "PortRange": { + "Start": 0, + "End": 0 + }, + "Domains": null, + "IsDynamic": false + }, + { + "PolicyID": "", + "RouteID": "route-ha-2:peer-60", + "SourceRanges": [ + "100.64.0.1/32", + "100.64.0.10/32", + "100.64.0.11/32", + "100.64.0.12/32", + "100.64.0.13/32", + "100.64.0.14/32", + "100.64.0.15/32", + "100.64.0.16/32", + "100.64.0.17/32", + "100.64.0.18/32", + "100.64.0.19/32", + "100.64.0.2/32", + "100.64.0.20/32", + "100.64.0.21/32", + "100.64.0.22/32", + "100.64.0.23/32", + "100.64.0.24/32", + "100.64.0.25/32", + "100.64.0.26/32", + "100.64.0.27/32", + "100.64.0.28/32", + "100.64.0.29/32", + "100.64.0.3/32", + "100.64.0.30/32", + "100.64.0.31/32", + "100.64.0.32/32", + "100.64.0.33/32", + "100.64.0.34/32", + "100.64.0.35/32", + "100.64.0.36/32", + "100.64.0.37/32", + "100.64.0.38/32", + "100.64.0.39/32", + "100.64.0.4/32", + "100.64.0.40/32", + "100.64.0.41/32", + "100.64.0.42/32", + "100.64.0.43/32", + "100.64.0.44/32", + "100.64.0.45/32", + "100.64.0.46/32", + "100.64.0.47/32", + "100.64.0.48/32", + "100.64.0.49/32", + "100.64.0.5/32", + "100.64.0.50/32", + "100.64.0.51/32", + "100.64.0.52/32", + "100.64.0.53/32", + "100.64.0.54/32", + "100.64.0.55/32", + "100.64.0.56/32", + "100.64.0.57/32", + "100.64.0.58/32", + "100.64.0.59/32", + "100.64.0.6/32", + "100.64.0.60/32", + "100.64.0.62/32", + "100.64.0.63/32", + "100.64.0.64/32", + "100.64.0.65/32", + "100.64.0.66/32", + "100.64.0.67/32", + "100.64.0.68/32", + "100.64.0.69/32", + "100.64.0.7/32", + "100.64.0.70/32", + "100.64.0.71/32", + "100.64.0.72/32", + "100.64.0.73/32", + "100.64.0.74/32", + "100.64.0.75/32", + "100.64.0.76/32", + "100.64.0.77/32", + "100.64.0.78/32", + "100.64.0.79/32", + "100.64.0.8/32", + "100.64.0.80/32", + "100.64.0.81/32", + "100.64.0.82/32", + "100.64.0.83/32", + "100.64.0.84/32", + "100.64.0.85/32", + "100.64.0.86/32", + "100.64.0.87/32", + "100.64.0.88/32", + "100.64.0.89/32", + "100.64.0.9/32", + "100.64.0.90/32", + "100.64.0.91/32", + "100.64.0.92/32", + "100.64.0.93/32", + "100.64.0.94/32", + "100.64.0.95/32", + "100.64.0.96/32", + "100.64.0.97/32", + "100.64.0.98/32", + "100.64.0.99/32" + ], + "Action": "accept", + "Destination": "10.10.0.0/16", + "Protocol": "all", + "Port": 0, + "PortRange": { + "Start": 0, + "End": 0 + }, + "Domains": null, + "IsDynamic": false + } + ], + "ForwardingRules": null, + "AuthorizedUsers": {}, + "EnableSSH": false +} \ No newline at end of file diff --git a/management/server/types/testdata/networkmap_golden_new_with_onpeeradded_router.json b/management/server/types/testdata/networkmap_golden_new_with_onpeeradded_router.json new file mode 100644 index 000000000..8e322a5b2 --- /dev/null +++ b/management/server/types/testdata/networkmap_golden_new_with_onpeeradded_router.json @@ -0,0 +1,11093 @@ +{ + "Peers": [ + { + "ID": "peer-0", + "Key": "key-peer-0", + "IP": "100.64.0.1", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer1", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-1", + "Key": "key-peer-1", + "IP": "100.64.0.2", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer2", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-10", + "Key": "key-peer-10", + "IP": "100.64.0.11", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer11", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-11", + "Key": "key-peer-11", + "IP": "100.64.0.12", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer12", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-12", + "Key": "key-peer-12", + "IP": "100.64.0.13", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer13", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-13", + "Key": "key-peer-13", + "IP": "100.64.0.14", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer14", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-14", + "Key": "key-peer-14", + "IP": "100.64.0.15", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer15", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-15", + "Key": "key-peer-15", + "IP": "100.64.0.16", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer16", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-16", + "Key": "key-peer-16", + "IP": "100.64.0.17", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer17", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-17", + "Key": "key-peer-17", + "IP": "100.64.0.18", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer18", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-18", + "Key": "key-peer-18", + "IP": "100.64.0.19", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer19", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-19", + "Key": "key-peer-19", + "IP": "100.64.0.20", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer20", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-2", + "Key": "key-peer-2", + "IP": "100.64.0.3", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer3", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-20", + "Key": "key-peer-20", + "IP": "100.64.0.21", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer21", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-21", + "Key": "key-peer-21", + "IP": "100.64.0.22", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer22", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-22", + "Key": "key-peer-22", + "IP": "100.64.0.23", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer23", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-23", + "Key": "key-peer-23", + "IP": "100.64.0.24", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer24", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-24", + "Key": "key-peer-24", + "IP": "100.64.0.25", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer25", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-25", + "Key": "key-peer-25", + "IP": "100.64.0.26", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer26", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-26", + "Key": "key-peer-26", + "IP": "100.64.0.27", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer27", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-27", + "Key": "key-peer-27", + "IP": "100.64.0.28", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer28", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-28", + "Key": "key-peer-28", + "IP": "100.64.0.29", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer29", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-29", + "Key": "key-peer-29", + "IP": "100.64.0.30", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer30", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-3", + "Key": "key-peer-3", + "IP": "100.64.0.4", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer4", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-30", + "Key": "key-peer-30", + "IP": "100.64.0.31", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer31", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-31", + "Key": "key-peer-31", + "IP": "100.64.0.32", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer32", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-32", + "Key": "key-peer-32", + "IP": "100.64.0.33", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer33", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-33", + "Key": "key-peer-33", + "IP": "100.64.0.34", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer34", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-34", + "Key": "key-peer-34", + "IP": "100.64.0.35", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer35", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-35", + "Key": "key-peer-35", + "IP": "100.64.0.36", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer36", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-36", + "Key": "key-peer-36", + "IP": "100.64.0.37", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer37", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-37", + "Key": "key-peer-37", + "IP": "100.64.0.38", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer38", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-38", + "Key": "key-peer-38", + "IP": "100.64.0.39", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer39", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-39", + "Key": "key-peer-39", + "IP": "100.64.0.40", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer40", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-4", + "Key": "key-peer-4", + "IP": "100.64.0.5", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer5", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-40", + "Key": "key-peer-40", + "IP": "100.64.0.41", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer41", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-41", + "Key": "key-peer-41", + "IP": "100.64.0.42", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer42", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-42", + "Key": "key-peer-42", + "IP": "100.64.0.43", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer43", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-43", + "Key": "key-peer-43", + "IP": "100.64.0.44", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer44", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-44", + "Key": "key-peer-44", + "IP": "100.64.0.45", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer45", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-45", + "Key": "key-peer-45", + "IP": "100.64.0.46", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer46", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-46", + "Key": "key-peer-46", + "IP": "100.64.0.47", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer47", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-47", + "Key": "key-peer-47", + "IP": "100.64.0.48", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer48", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-48", + "Key": "key-peer-48", + "IP": "100.64.0.49", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer49", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-49", + "Key": "key-peer-49", + "IP": "100.64.0.50", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer50", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-5", + "Key": "key-peer-5", + "IP": "100.64.0.6", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer6", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-50", + "Key": "key-peer-50", + "IP": "100.64.0.51", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer51", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-51", + "Key": "key-peer-51", + "IP": "100.64.0.52", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer52", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-52", + "Key": "key-peer-52", + "IP": "100.64.0.53", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer53", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-53", + "Key": "key-peer-53", + "IP": "100.64.0.54", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer54", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-54", + "Key": "key-peer-54", + "IP": "100.64.0.55", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer55", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-55", + "Key": "key-peer-55", + "IP": "100.64.0.56", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer56", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-56", + "Key": "key-peer-56", + "IP": "100.64.0.57", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer57", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-57", + "Key": "key-peer-57", + "IP": "100.64.0.58", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer58", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-58", + "Key": "key-peer-58", + "IP": "100.64.0.59", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer59", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-59", + "Key": "key-peer-59", + "IP": "100.64.0.60", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer60", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-6", + "Key": "key-peer-6", + "IP": "100.64.0.7", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer7", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-61", + "Key": "key-peer-61", + "IP": "100.64.0.62", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer62", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-62", + "Key": "key-peer-62", + "IP": "100.64.0.63", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer63", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-63", + "Key": "key-peer-63", + "IP": "100.64.0.64", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer64", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-64", + "Key": "key-peer-64", + "IP": "100.64.0.65", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer65", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-65", + "Key": "key-peer-65", + "IP": "100.64.0.66", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer66", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-66", + "Key": "key-peer-66", + "IP": "100.64.0.67", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer67", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-67", + "Key": "key-peer-67", + "IP": "100.64.0.68", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer68", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-68", + "Key": "key-peer-68", + "IP": "100.64.0.69", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer69", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-69", + "Key": "key-peer-69", + "IP": "100.64.0.70", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer70", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-7", + "Key": "key-peer-7", + "IP": "100.64.0.8", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer8", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-70", + "Key": "key-peer-70", + "IP": "100.64.0.71", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer71", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-71", + "Key": "key-peer-71", + "IP": "100.64.0.72", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer72", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-72", + "Key": "key-peer-72", + "IP": "100.64.0.73", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer73", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-73", + "Key": "key-peer-73", + "IP": "100.64.0.74", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer74", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-74", + "Key": "key-peer-74", + "IP": "100.64.0.75", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer75", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-75", + "Key": "key-peer-75", + "IP": "100.64.0.76", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer76", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-76", + "Key": "key-peer-76", + "IP": "100.64.0.77", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer77", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-77", + "Key": "key-peer-77", + "IP": "100.64.0.78", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer78", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-78", + "Key": "key-peer-78", + "IP": "100.64.0.79", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer79", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-79", + "Key": "key-peer-79", + "IP": "100.64.0.80", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer80", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-8", + "Key": "key-peer-8", + "IP": "100.64.0.9", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer9", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-80", + "Key": "key-peer-80", + "IP": "100.64.0.81", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer81", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-81", + "Key": "key-peer-81", + "IP": "100.64.0.82", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer82", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-82", + "Key": "key-peer-82", + "IP": "100.64.0.83", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer83", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-83", + "Key": "key-peer-83", + "IP": "100.64.0.84", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer84", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-84", + "Key": "key-peer-84", + "IP": "100.64.0.85", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer85", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-85", + "Key": "key-peer-85", + "IP": "100.64.0.86", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer86", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-86", + "Key": "key-peer-86", + "IP": "100.64.0.87", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer87", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-87", + "Key": "key-peer-87", + "IP": "100.64.0.88", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer88", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-88", + "Key": "key-peer-88", + "IP": "100.64.0.89", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer89", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-89", + "Key": "key-peer-89", + "IP": "100.64.0.90", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer90", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-9", + "Key": "key-peer-9", + "IP": "100.64.0.10", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer10", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-90", + "Key": "key-peer-90", + "IP": "100.64.0.91", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer91", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-91", + "Key": "key-peer-91", + "IP": "100.64.0.92", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer92", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-92", + "Key": "key-peer-92", + "IP": "100.64.0.93", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer93", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-93", + "Key": "key-peer-93", + "IP": "100.64.0.94", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer94", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-94", + "Key": "key-peer-94", + "IP": "100.64.0.95", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer95", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-95", + "Key": "key-peer-95", + "IP": "100.64.0.96", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer96", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-96", + "Key": "key-peer-96", + "IP": "100.64.0.97", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer97", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-97", + "Key": "key-peer-97", + "IP": "100.64.0.98", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.25.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer98", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + }, + { + "ID": "peer-new-router-102", + "Key": "key-peer-new-router-102", + "IP": "100.64.1.2", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.26.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "newrouter102", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": false, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + } + ], + "Network": { + "id": "net-golden-test", + "Net": { + "IP": "100.64.0.0", + "Mask": "//8AAA==" + }, + "Dns": "", + "Serial": 2 + }, + "Routes": [ + { + "ID": "res-database:peer-95", + "AccountID": "account-golden-test", + "Network": "", + "Domains": null, + "KeepRoute": true, + "NetID": "", + "Description": "", + "Peer": "key-peer-95", + "PeerID": "peer-95", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 0, + "Enabled": true, + "Groups": null, + "AccessControlGroups": null, + "SkipAutoApply": false + }, + { + "ID": "route-ha-1:peer-60", + "AccountID": "account-golden-test", + "Network": "10.10.0.0/16", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "HA Route 1", + "Peer": "key-peer-60", + "PeerID": "peer-80", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 1000, + "Enabled": true, + "Groups": [ + "group-all" + ], + "AccessControlGroups": [ + "group-all" + ], + "SkipAutoApply": false + }, + { + "ID": "route-ha-2:peer-60", + "AccountID": "account-golden-test", + "Network": "10.10.0.0/16", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "HA Route 2", + "Peer": "key-peer-60", + "PeerID": "peer-90", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 900, + "Enabled": true, + "Groups": [ + "group-dev", + "group-ops" + ], + "AccessControlGroups": [ + "group-all" + ], + "SkipAutoApply": false + }, + { + "ID": "route-main:peer-60", + "AccountID": "account-golden-test", + "Network": "192.168.10.0/24", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "Route to internal resource", + "Peer": "key-peer-60", + "PeerID": "peer-75", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 0, + "Enabled": true, + "Groups": [ + "group-dev", + "group-ops" + ], + "AccessControlGroups": [ + "group-dev" + ], + "SkipAutoApply": false + }, + { + "ID": "route-new-router:peer-60", + "AccountID": "account-golden-test", + "Network": "172.16.0.0/24", + "Domains": null, + "KeepRoute": false, + "NetID": "", + "Description": "Route from new router", + "Peer": "key-peer-60", + "PeerID": "peer-new-router-102", + "PeerGroups": null, + "NetworkType": 0, + "Masquerade": false, + "Metric": 0, + "Enabled": true, + "Groups": [ + "group-dev", + "group-ops" + ], + "AccessControlGroups": [ + "group-dev" + ], + "SkipAutoApply": false + } + ], + "DNSConfig": { + "ServiceEnable": false, + "NameServerGroups": null, + "CustomZones": null, + "ForwarderPort": 0 + }, + "OfflinePeers": [ + { + "ID": "peer-98", + "Key": "key-peer-98", + "IP": "100.64.0.99", + "Meta": { + "Hostname": "", + "GoOS": "linux", + "Kernel": "", + "Core": "", + "Platform": "", + "OS": "", + "OSVersion": "", + "WtVersion": "0.40.0", + "UIVersion": "", + "KernelVersion": "", + "NetworkAddresses": null, + "SystemSerialNumber": "", + "SystemProductName": "", + "SystemManufacturer": "", + "Environment": { + "Cloud": "", + "Platform": "" + }, + "Flags": { + "RosenpassEnabled": false, + "RosenpassPermissive": false, + "ServerSSHAllowed": false, + "DisableClientRoutes": false, + "DisableServerRoutes": false, + "DisableDNS": false, + "DisableFirewall": false, + "BlockLANAccess": false, + "BlockInbound": false, + "LazyConnectionEnabled": false + }, + "Files": null + }, + "ProxyMeta": { + "Embedded": false, + "Cluster": "" + }, + "Name": "", + "DNSLabel": "peer99", + "Status": { + "LastSeen": "0001-01-01T00:00:00Z", + "Connected": true, + "LoginExpired": false, + "RequiresApproval": false + }, + "UserID": "user-admin", + "SSHKey": "", + "SSHEnabled": false, + "LoginExpirationEnabled": true, + "InactivityExpirationEnabled": false, + "LastLogin": "0001-01-01T00:00:00Z", + "CreatedAt": "0001-01-01T00:00:00Z", + "Ephemeral": false, + "Location": { + "ConnectionIP": "", + "CountryCode": "", + "CityName": "", + "GeoNameID": 0 + }, + "ExtraDNSLabels": null, + "AllowExtraDNSLabels": false + } + ], + "FirewallRules": [ + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.1", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.1", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.1", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.1", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.1", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.10", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.10", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.10", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.10", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.10", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.11", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.11", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.11", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.11", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.11", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.12", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.12", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.12", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.12", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.12", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.13", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.13", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.13", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.13", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.13", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.14", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.14", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.14", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.14", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.14", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.15", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.15", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.15", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.15", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.15", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.16", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.16", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.16", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.16", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.16", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.17", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.17", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.17", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.17", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.17", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.18", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.18", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.18", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.18", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.18", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.19", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.19", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.19", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.19", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.19", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.2", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.2", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.2", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.2", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.2", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.20", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.20", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.20", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.20", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.20", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.21", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.21", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.21", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.21", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.21", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.22", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.22", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.22", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.22", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.22", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.23", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.23", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.23", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.23", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.23", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.24", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.24", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.24", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.24", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.24", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.25", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.25", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.25", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.25", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.25", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.26", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.26", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.26", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.26", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.26", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.27", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.27", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.27", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.27", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.27", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.28", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.28", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.28", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.28", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.28", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.29", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.29", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.29", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.29", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.29", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.3", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.3", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.3", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.3", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.3", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.30", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.30", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.30", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.30", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.30", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.31", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.31", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.31", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.31", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.31", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.32", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.32", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.32", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.32", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.32", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.33", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.33", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.33", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.33", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.33", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.34", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.34", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.34", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.34", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.34", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.35", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.35", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.35", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.35", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.35", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.36", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.36", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.36", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.36", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.36", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.37", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.37", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.37", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.37", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.37", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.38", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.38", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.38", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.38", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.38", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.39", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.39", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.39", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.39", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.39", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.4", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.4", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.4", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.4", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.4", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.40", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.40", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.40", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.40", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.40", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.41", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.41", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.41", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.41", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.41", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.42", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.42", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.42", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.42", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.42", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.43", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.43", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.43", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.43", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.43", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.44", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.44", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.44", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.44", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.44", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.45", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.45", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.45", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.45", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.45", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.46", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.46", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.46", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.46", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.46", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.47", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.47", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.47", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.47", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.47", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.48", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.48", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.48", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.48", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.48", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.49", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.49", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.49", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.49", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.49", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.5", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.5", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.5", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.5", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.5", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.50", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.50", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.50", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.50", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.50", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.51", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.51", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.52", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.52", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.53", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.53", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.54", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.54", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.55", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.55", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.56", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.56", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.57", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.57", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.58", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.58", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.59", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.59", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.6", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.6", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.6", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.6", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.6", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.60", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.60", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.62", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.62", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.63", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.63", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.64", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.64", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.65", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.65", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.66", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.66", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.67", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.67", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.68", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.68", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.69", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.69", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.7", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.7", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.7", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.7", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.7", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.70", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.70", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.71", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.71", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.72", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.72", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.73", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.73", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.74", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.74", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.75", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.75", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.76", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.76", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.77", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.77", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.78", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.78", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.79", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.79", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.8", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.8", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.8", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.8", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.8", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.80", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.80", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.81", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.81", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.82", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.82", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.83", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.83", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.84", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.84", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.85", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.85", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.86", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.86", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.87", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.87", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.88", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.88", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.89", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.89", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.9", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.9", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-ssh", + "PeerIP": "100.64.0.9", + "Direction": 0, + "Action": "accept", + "Protocol": "tcp", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.9", + "Direction": 0, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-drop", + "PeerIP": "100.64.0.9", + "Direction": 1, + "Action": "drop", + "Protocol": "tcp", + "Port": "5432", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.90", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.90", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.91", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.91", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.92", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.92", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.93", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.93", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.94", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.94", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.95", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.95", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.96", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.96", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.97", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.97", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.98", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.98", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.99", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.0.99", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.1.2", + "Direction": 0, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + }, + { + "PolicyID": "policy-all", + "PeerIP": "100.64.1.2", + "Direction": 1, + "Action": "accept", + "Protocol": "all", + "Port": "", + "PortRange": { + "Start": 0, + "End": 0 + } + } + ], + "RoutesFirewallRules": [ + { + "PolicyID": "", + "RouteID": "route-ha-1:peer-60", + "SourceRanges": [ + "100.64.0.1/32", + "100.64.0.10/32", + "100.64.0.11/32", + "100.64.0.12/32", + "100.64.0.13/32", + "100.64.0.14/32", + "100.64.0.15/32", + "100.64.0.16/32", + "100.64.0.17/32", + "100.64.0.18/32", + "100.64.0.19/32", + "100.64.0.2/32", + "100.64.0.20/32", + "100.64.0.21/32", + "100.64.0.22/32", + "100.64.0.23/32", + "100.64.0.24/32", + "100.64.0.25/32", + "100.64.0.26/32", + "100.64.0.27/32", + "100.64.0.28/32", + "100.64.0.29/32", + "100.64.0.3/32", + "100.64.0.30/32", + "100.64.0.31/32", + "100.64.0.32/32", + "100.64.0.33/32", + "100.64.0.34/32", + "100.64.0.35/32", + "100.64.0.36/32", + "100.64.0.37/32", + "100.64.0.38/32", + "100.64.0.39/32", + "100.64.0.4/32", + "100.64.0.40/32", + "100.64.0.41/32", + "100.64.0.42/32", + "100.64.0.43/32", + "100.64.0.44/32", + "100.64.0.45/32", + "100.64.0.46/32", + "100.64.0.47/32", + "100.64.0.48/32", + "100.64.0.49/32", + "100.64.0.5/32", + "100.64.0.50/32", + "100.64.0.51/32", + "100.64.0.52/32", + "100.64.0.53/32", + "100.64.0.54/32", + "100.64.0.55/32", + "100.64.0.56/32", + "100.64.0.57/32", + "100.64.0.58/32", + "100.64.0.59/32", + "100.64.0.6/32", + "100.64.0.60/32", + "100.64.0.62/32", + "100.64.0.63/32", + "100.64.0.64/32", + "100.64.0.65/32", + "100.64.0.66/32", + "100.64.0.67/32", + "100.64.0.68/32", + "100.64.0.69/32", + "100.64.0.7/32", + "100.64.0.70/32", + "100.64.0.71/32", + "100.64.0.72/32", + "100.64.0.73/32", + "100.64.0.74/32", + "100.64.0.75/32", + "100.64.0.76/32", + "100.64.0.77/32", + "100.64.0.78/32", + "100.64.0.79/32", + "100.64.0.8/32", + "100.64.0.80/32", + "100.64.0.81/32", + "100.64.0.82/32", + "100.64.0.83/32", + "100.64.0.84/32", + "100.64.0.85/32", + "100.64.0.86/32", + "100.64.0.87/32", + "100.64.0.88/32", + "100.64.0.89/32", + "100.64.0.9/32", + "100.64.0.90/32", + "100.64.0.91/32", + "100.64.0.92/32", + "100.64.0.93/32", + "100.64.0.94/32", + "100.64.0.95/32", + "100.64.0.96/32", + "100.64.0.97/32", + "100.64.0.98/32", + "100.64.0.99/32", + "100.64.1.2/32" + ], + "Action": "accept", + "Destination": "10.10.0.0/16", + "Protocol": "all", + "Port": 0, + "PortRange": { + "Start": 0, + "End": 0 + }, + "Domains": null, + "IsDynamic": false + }, + { + "PolicyID": "", + "RouteID": "route-ha-2:peer-60", + "SourceRanges": [ + "100.64.0.1/32", + "100.64.0.10/32", + "100.64.0.11/32", + "100.64.0.12/32", + "100.64.0.13/32", + "100.64.0.14/32", + "100.64.0.15/32", + "100.64.0.16/32", + "100.64.0.17/32", + "100.64.0.18/32", + "100.64.0.19/32", + "100.64.0.2/32", + "100.64.0.20/32", + "100.64.0.21/32", + "100.64.0.22/32", + "100.64.0.23/32", + "100.64.0.24/32", + "100.64.0.25/32", + "100.64.0.26/32", + "100.64.0.27/32", + "100.64.0.28/32", + "100.64.0.29/32", + "100.64.0.3/32", + "100.64.0.30/32", + "100.64.0.31/32", + "100.64.0.32/32", + "100.64.0.33/32", + "100.64.0.34/32", + "100.64.0.35/32", + "100.64.0.36/32", + "100.64.0.37/32", + "100.64.0.38/32", + "100.64.0.39/32", + "100.64.0.4/32", + "100.64.0.40/32", + "100.64.0.41/32", + "100.64.0.42/32", + "100.64.0.43/32", + "100.64.0.44/32", + "100.64.0.45/32", + "100.64.0.46/32", + "100.64.0.47/32", + "100.64.0.48/32", + "100.64.0.49/32", + "100.64.0.5/32", + "100.64.0.50/32", + "100.64.0.51/32", + "100.64.0.52/32", + "100.64.0.53/32", + "100.64.0.54/32", + "100.64.0.55/32", + "100.64.0.56/32", + "100.64.0.57/32", + "100.64.0.58/32", + "100.64.0.59/32", + "100.64.0.6/32", + "100.64.0.60/32", + "100.64.0.62/32", + "100.64.0.63/32", + "100.64.0.64/32", + "100.64.0.65/32", + "100.64.0.66/32", + "100.64.0.67/32", + "100.64.0.68/32", + "100.64.0.69/32", + "100.64.0.7/32", + "100.64.0.70/32", + "100.64.0.71/32", + "100.64.0.72/32", + "100.64.0.73/32", + "100.64.0.74/32", + "100.64.0.75/32", + "100.64.0.76/32", + "100.64.0.77/32", + "100.64.0.78/32", + "100.64.0.79/32", + "100.64.0.8/32", + "100.64.0.80/32", + "100.64.0.81/32", + "100.64.0.82/32", + "100.64.0.83/32", + "100.64.0.84/32", + "100.64.0.85/32", + "100.64.0.86/32", + "100.64.0.87/32", + "100.64.0.88/32", + "100.64.0.89/32", + "100.64.0.9/32", + "100.64.0.90/32", + "100.64.0.91/32", + "100.64.0.92/32", + "100.64.0.93/32", + "100.64.0.94/32", + "100.64.0.95/32", + "100.64.0.96/32", + "100.64.0.97/32", + "100.64.0.98/32", + "100.64.0.99/32", + "100.64.1.2/32" + ], + "Action": "accept", + "Destination": "10.10.0.0/16", + "Protocol": "all", + "Port": 0, + "PortRange": { + "Start": 0, + "End": 0 + }, + "Domains": null, + "IsDynamic": false + } + ], + "ForwardingRules": null, + "AuthorizedUsers": { + "admin": { + "user-dev": {}, + "user-ops": {} + }, + "root": { + "user-dev": {}, + "user-ops": {} + } + }, + "EnableSSH": true +} \ No newline at end of file diff --git a/proxy/Dockerfile b/proxy/Dockerfile index 096c71f21..e64680fd6 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -10,7 +10,7 @@ FROM gcr.io/distroless/base:debug COPY netbird-proxy /go/bin/netbird-proxy COPY --from=builder /tmp/passwd /etc/passwd COPY --from=builder /tmp/group /etc/group -COPY --from=builder /tmp/var/lib/netbird /var/lib/netbird +COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird COPY --from=builder --chown=1000:1000 --chmod=755 /tmp/certs /certs USER netbird:netbird ENV HOME=/var/lib/netbird diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage index 2e3ac3561..01e342c0e 100644 --- a/proxy/Dockerfile.multistage +++ b/proxy/Dockerfile.multistage @@ -28,7 +28,7 @@ FROM gcr.io/distroless/base:debug COPY --from=builder /app/netbird-proxy /usr/bin/netbird-proxy COPY --from=builder /tmp/passwd /etc/passwd COPY --from=builder /tmp/group /etc/group -COPY --from=builder /tmp/var/lib/netbird /var/lib/netbird +COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird COPY --from=builder --chown=1000:1000 --chmod=755 /tmp/certs /certs USER netbird:netbird ENV HOME=/var/lib/netbird diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go index 14caa03b3..ca9c260b7 100644 --- a/proxy/auth/auth.go +++ b/proxy/auth/auth.go @@ -13,10 +13,11 @@ import ( type Method string -var ( +const ( MethodPassword Method = "password" MethodPIN Method = "pin" MethodOIDC Method = "oidc" + MethodHeader Method = "header" ) func (m Method) String() string { diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index d82f5b7fc..1c36ee334 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -36,31 +36,34 @@ var ( var ( logLevel string - debugLogs bool - mgmtAddr string - addr string - proxyDomain string - defaultDialTimeout time.Duration - certDir string - acmeCerts bool - acmeAddr string - acmeDir string - acmeEABKID string - acmeEABHMACKey string - acmeChallengeType string - debugEndpoint bool - debugEndpointAddr string - healthAddr string - forwardedProto string - trustedProxies string - certFile string - certKeyFile string - certLockMethod string - wildcardCertDir string - wgPort uint16 - proxyProtocol bool - preSharedKey string - supportsCustomPorts bool + debugLogs bool + mgmtAddr string + addr string + proxyDomain string + maxDialTimeout time.Duration + maxSessionIdleTimeout time.Duration + certDir string + acmeCerts bool + acmeAddr string + acmeDir string + acmeEABKID string + acmeEABHMACKey string + acmeChallengeType string + debugEndpoint bool + debugEndpointAddr string + healthAddr string + forwardedProto string + trustedProxies string + certFile string + certKeyFile string + certLockMethod string + wildcardCertDir string + wgPort uint16 + proxyProtocol bool + preSharedKey string + supportsCustomPorts bool + requireSubdomain bool + geoDataDir string ) var rootCmd = &cobra.Command{ @@ -99,7 +102,10 @@ func init() { rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies") rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers") rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough") - rootCmd.Flags().DurationVar(&defaultDialTimeout, "default-dial-timeout", envDurationOrDefault("NB_PROXY_DEFAULT_DIAL_TIMEOUT", 0), "Default backend dial timeout when no per-service timeout is set (e.g. 30s)") + rootCmd.Flags().BoolVar(&requireSubdomain, "require-subdomain", envBoolOrDefault("NB_PROXY_REQUIRE_SUBDOMAIN", false), "Require a subdomain label in front of the cluster domain") + rootCmd.Flags().DurationVar(&maxDialTimeout, "max-dial-timeout", envDurationOrDefault("NB_PROXY_MAX_DIAL_TIMEOUT", 0), "Cap per-service backend dial timeout (0 = no cap)") + rootCmd.Flags().DurationVar(&maxSessionIdleTimeout, "max-session-idle-timeout", envDurationOrDefault("NB_PROXY_MAX_SESSION_IDLE_TIMEOUT", 0), "Cap per-service session idle timeout (0 = no cap)") + rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)") } // Execute runs the root command. @@ -177,17 +183,16 @@ func runServer(cmd *cobra.Command, args []string) error { ProxyProtocol: proxyProtocol, PreSharedKey: preSharedKey, SupportsCustomPorts: supportsCustomPorts, - DefaultDialTimeout: defaultDialTimeout, + RequireSubdomain: requireSubdomain, + MaxDialTimeout: maxDialTimeout, + MaxSessionIdleTimeout: maxSessionIdleTimeout, + GeoDataDir: geoDataDir, } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() - if err := srv.ListenAndServe(ctx, addr); err != nil { - logger.Error(err) - return err - } - return nil + return srv.ListenAndServe(ctx, addr) } func envBoolOrDefault(key string, def bool) bool { @@ -197,6 +202,7 @@ func envBoolOrDefault(key string, def bool) bool { } parsed, err := strconv.ParseBool(v) if err != nil { + log.Warnf("parse %s=%q: %v, using default %v", key, v, err, def) return def } return parsed @@ -217,6 +223,7 @@ func envUint16OrDefault(key string, def uint16) uint16 { } parsed, err := strconv.ParseUint(v, 10, 16) if err != nil { + log.Warnf("parse %s=%q: %v, using default %d", key, v, err, def) return def } return uint16(parsed) @@ -229,6 +236,7 @@ func envDurationOrDefault(key string, def time.Duration) time.Duration { } parsed, err := time.ParseDuration(v) if err != nil { + log.Warnf("parse %s=%q: %v, using default %s", key, v, err, def) return def } return parsed diff --git a/proxy/internal/accesslog/logger.go b/proxy/internal/accesslog/logger.go index 5b05ab195..3ed3275b5 100644 --- a/proxy/internal/accesslog/logger.go +++ b/proxy/internal/accesslog/logger.go @@ -4,6 +4,7 @@ import ( "context" "net/netip" "sync" + "sync/atomic" "time" "github.com/rs/xid" @@ -22,6 +23,16 @@ const ( usageCleanupPeriod = 1 * time.Hour // Clean up stale counters every hour usageInactiveWindow = 24 * time.Hour // Consider domain inactive if no traffic for 24 hours logSendTimeout = 10 * time.Second + + // denyCooldown is the min interval between deny log entries per service+reason + // to prevent flooding from denied connections (e.g. UDP packets from blocked IPs). + denyCooldown = 10 * time.Second + + // maxDenyBuckets caps tracked deny rate-limit entries to bound memory under DDoS. + maxDenyBuckets = 10000 + + // maxLogWorkers caps concurrent gRPC send goroutines. + maxLogWorkers = 4096 ) type domainUsage struct { @@ -38,6 +49,18 @@ type gRPCClient interface { SendAccessLog(ctx context.Context, in *proto.SendAccessLogRequest, opts ...grpc.CallOption) (*proto.SendAccessLogResponse, error) } +// denyBucketKey identifies a rate-limited deny log stream. +type denyBucketKey struct { + ServiceID types.ServiceID + Reason string +} + +// denyBucket tracks rate-limited deny log entries. +type denyBucket struct { + lastLogged time.Time + suppressed int64 +} + // Logger sends access log entries to the management server via gRPC. type Logger struct { client gRPCClient @@ -47,7 +70,12 @@ type Logger struct { usageMux sync.Mutex domainUsage map[string]*domainUsage + denyMu sync.Mutex + denyBuckets map[denyBucketKey]*denyBucket + + logSem chan struct{} cleanupCancel context.CancelFunc + dropped atomic.Int64 } // NewLogger creates a new access log Logger. The trustedProxies parameter @@ -64,6 +92,8 @@ func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies []netip.Pre logger: logger, trustedProxies: trustedProxies, domainUsage: make(map[string]*domainUsage), + denyBuckets: make(map[denyBucketKey]*denyBucket), + logSem: make(chan struct{}, maxLogWorkers), cleanupCancel: cancel, } @@ -83,7 +113,7 @@ func (l *Logger) Close() { type logEntry struct { ID string AccountID types.AccountID - ServiceId types.ServiceID + ServiceID types.ServiceID Host string Path string DurationMs int64 @@ -91,7 +121,7 @@ type logEntry struct { ResponseCode int32 SourceIP netip.Addr AuthMechanism string - UserId string + UserID string AuthSuccess bool BytesUpload int64 BytesDownload int64 @@ -118,6 +148,10 @@ type L4Entry struct { DurationMs int64 BytesUpload int64 BytesDownload int64 + // DenyReason, when non-empty, indicates the connection was denied. + // Values match the HTTP auth mechanism strings: "ip_restricted", + // "country_restricted", "geo_unavailable". + DenyReason string } // LogL4 sends an access log entry for a layer-4 connection (TCP or UDP). @@ -126,7 +160,7 @@ func (l *Logger) LogL4(entry L4Entry) { le := logEntry{ ID: xid.New().String(), AccountID: entry.AccountID, - ServiceId: entry.ServiceID, + ServiceID: entry.ServiceID, Protocol: entry.Protocol, Host: entry.Host, SourceIP: entry.SourceIP, @@ -134,10 +168,47 @@ func (l *Logger) LogL4(entry L4Entry) { BytesUpload: entry.BytesUpload, BytesDownload: entry.BytesDownload, } + if entry.DenyReason != "" { + if !l.allowDenyLog(entry.ServiceID, entry.DenyReason) { + return + } + le.AuthMechanism = entry.DenyReason + le.AuthSuccess = false + } l.log(le) l.trackUsage(entry.Host, entry.BytesUpload+entry.BytesDownload) } +// allowDenyLog rate-limits deny log entries per service+reason combination. +func (l *Logger) allowDenyLog(serviceID types.ServiceID, reason string) bool { + key := denyBucketKey{ServiceID: serviceID, Reason: reason} + now := time.Now() + + l.denyMu.Lock() + defer l.denyMu.Unlock() + + b, ok := l.denyBuckets[key] + if !ok { + if len(l.denyBuckets) >= maxDenyBuckets { + return false + } + l.denyBuckets[key] = &denyBucket{lastLogged: now} + return true + } + + if now.Sub(b.lastLogged) >= denyCooldown { + if b.suppressed > 0 { + l.logger.Debugf("access restriction: suppressed %d deny log entries for %s (%s)", b.suppressed, serviceID, reason) + } + b.lastLogged = now + b.suppressed = 0 + return true + } + + b.suppressed++ + return false +} + func (l *Logger) log(entry logEntry) { // Fire off the log request in a separate routine. // This increases the possibility of losing a log message @@ -147,12 +218,21 @@ func (l *Logger) log(entry logEntry) { // There is also a chance that log messages will arrive at // the server out of order; however, the timestamp should // allow for resolving that on the server. - now := timestamppb.Now() // Grab the timestamp before launching the goroutine to try to prevent weird timing issues. This is probably unnecessary. + now := timestamppb.Now() + select { + case l.logSem <- struct{}{}: + default: + total := l.dropped.Add(1) + l.logger.Debugf("access log send dropped: worker limit reached (total dropped: %d)", total) + return + } go func() { + defer func() { <-l.logSem }() logCtx, cancel := context.WithTimeout(context.Background(), logSendTimeout) defer cancel() + // Only OIDC sessions have a meaningful user identity. if entry.AuthMechanism != auth.MethodOIDC.String() { - entry.UserId = "" + entry.UserID = "" } var sourceIP string @@ -165,7 +245,7 @@ func (l *Logger) log(entry logEntry) { LogId: entry.ID, AccountId: string(entry.AccountID), Timestamp: now, - ServiceId: string(entry.ServiceId), + ServiceId: string(entry.ServiceID), Host: entry.Host, Path: entry.Path, DurationMs: entry.DurationMs, @@ -173,7 +253,7 @@ func (l *Logger) log(entry logEntry) { ResponseCode: entry.ResponseCode, SourceIp: sourceIP, AuthMechanism: entry.AuthMechanism, - UserId: entry.UserId, + UserId: entry.UserID, AuthSuccess: entry.AuthSuccess, BytesUpload: entry.BytesUpload, BytesDownload: entry.BytesDownload, @@ -181,7 +261,7 @@ func (l *Logger) log(entry logEntry) { }, }); err != nil { l.logger.WithFields(log.Fields{ - "service_id": entry.ServiceId, + "service_id": entry.ServiceID, "host": entry.Host, "path": entry.Path, "duration": entry.DurationMs, @@ -189,7 +269,7 @@ func (l *Logger) log(entry logEntry) { "response_code": entry.ResponseCode, "source_ip": sourceIP, "auth_mechanism": entry.AuthMechanism, - "user_id": entry.UserId, + "user_id": entry.UserID, "auth_success": entry.AuthSuccess, "error": err, }).Error("Error sending access log on gRPC connection") @@ -248,7 +328,7 @@ func (l *Logger) trackUsage(domain string, bytesTransferred int64) { } } -// cleanupStaleUsage removes usage entries for domains that have been inactive. +// cleanupStaleUsage removes usage and deny-rate-limit entries that have been inactive. func (l *Logger) cleanupStaleUsage(ctx context.Context) { ticker := time.NewTicker(usageCleanupPeriod) defer ticker.Stop() @@ -258,20 +338,41 @@ func (l *Logger) cleanupStaleUsage(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - l.usageMux.Lock() now := time.Now() - removed := 0 - for domain, usage := range l.domainUsage { - if now.Sub(usage.lastActivity) > usageInactiveWindow { - delete(l.domainUsage, domain) - removed++ - } - } - l.usageMux.Unlock() - - if removed > 0 { - l.logger.Debugf("cleaned up %d stale domain usage entries", removed) - } + l.cleanupDomainUsage(now) + l.cleanupDenyBuckets(now) } } } + +func (l *Logger) cleanupDomainUsage(now time.Time) { + l.usageMux.Lock() + defer l.usageMux.Unlock() + + removed := 0 + for domain, usage := range l.domainUsage { + if now.Sub(usage.lastActivity) > usageInactiveWindow { + delete(l.domainUsage, domain) + removed++ + } + } + if removed > 0 { + l.logger.Debugf("cleaned up %d stale domain usage entries", removed) + } +} + +func (l *Logger) cleanupDenyBuckets(now time.Time) { + l.denyMu.Lock() + defer l.denyMu.Unlock() + + removed := 0 + for key, bucket := range l.denyBuckets { + if now.Sub(bucket.lastLogged) > usageInactiveWindow { + delete(l.denyBuckets, key) + removed++ + } + } + if removed > 0 { + l.logger.Debugf("cleaned up %d stale deny rate-limit entries", removed) + } +} diff --git a/proxy/internal/accesslog/middleware.go b/proxy/internal/accesslog/middleware.go index 593a77ef2..81c790b17 100644 --- a/proxy/internal/accesslog/middleware.go +++ b/proxy/internal/accesslog/middleware.go @@ -13,6 +13,7 @@ import ( "github.com/netbirdio/netbird/proxy/web" ) +// Middleware wraps an HTTP handler to log access entries and resolve client IPs. func (l *Logger) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip logging for internal proxy assets (CSS, JS, etc.) @@ -47,8 +48,9 @@ func (l *Logger) Middleware(next http.Handler) http.Handler { // Create a mutable struct to capture data from downstream handlers. // We pass a pointer in the context - the pointer itself flows down immutably, // but the struct it points to can be mutated by inner handlers. - capturedData := &proxy.CapturedData{RequestID: requestID} + capturedData := proxy.NewCapturedData(requestID) capturedData.SetClientIP(sourceIp) + ctx := proxy.WithCapturedData(r.Context(), capturedData) start := time.Now() @@ -66,8 +68,8 @@ func (l *Logger) Middleware(next http.Handler) http.Handler { entry := logEntry{ ID: requestID, - ServiceId: capturedData.GetServiceId(), - AccountID: capturedData.GetAccountId(), + ServiceID: capturedData.GetServiceID(), + AccountID: capturedData.GetAccountID(), Host: host, Path: r.URL.Path, DurationMs: duration.Milliseconds(), @@ -75,14 +77,14 @@ func (l *Logger) Middleware(next http.Handler) http.Handler { ResponseCode: int32(sw.status), SourceIP: sourceIp, AuthMechanism: capturedData.GetAuthMethod(), - UserId: capturedData.GetUserID(), + UserID: capturedData.GetUserID(), AuthSuccess: sw.status != http.StatusUnauthorized && sw.status != http.StatusForbidden, BytesUpload: bytesUpload, BytesDownload: bytesDownload, Protocol: ProtocolHTTP, } l.logger.Debugf("response: request_id=%s method=%s host=%s path=%s status=%d duration=%dms source=%s origin=%s service=%s account=%s", - requestID, r.Method, host, r.URL.Path, sw.status, duration.Milliseconds(), sourceIp, capturedData.GetOrigin(), capturedData.GetServiceId(), capturedData.GetAccountId()) + requestID, r.Method, host, r.URL.Path, sw.status, duration.Milliseconds(), sourceIp, capturedData.GetOrigin(), capturedData.GetServiceID(), capturedData.GetAccountID()) l.log(entry) diff --git a/proxy/internal/auth/header.go b/proxy/internal/auth/header.go new file mode 100644 index 000000000..194800a49 --- /dev/null +++ b/proxy/internal/auth/header.go @@ -0,0 +1,69 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// ErrHeaderAuthFailed indicates that the header was present but the +// credential did not validate. Callers should return 401 instead of +// falling through to other auth schemes. +var ErrHeaderAuthFailed = errors.New("header authentication failed") + +// Header implements header-based authentication. The proxy checks for the +// configured header in each request and validates its value via gRPC. +type Header struct { + id types.ServiceID + accountId types.AccountID + headerName string + client authenticator +} + +// NewHeader creates a Header authentication scheme for the given header name. +func NewHeader(client authenticator, id types.ServiceID, accountId types.AccountID, headerName string) Header { + return Header{ + id: id, + accountId: accountId, + headerName: headerName, + client: client, + } +} + +// Type returns auth.MethodHeader. +func (Header) Type() auth.Method { + return auth.MethodHeader +} + +// Authenticate checks for the configured header in the request. If absent, +// returns empty (unauthenticated). If present, validates via gRPC. +func (h Header) Authenticate(r *http.Request) (string, string, error) { + value := r.Header.Get(h.headerName) + if value == "" { + return "", "", nil + } + + res, err := h.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ + Id: string(h.id), + AccountId: string(h.accountId), + Request: &proto.AuthenticateRequest_HeaderAuth{ + HeaderAuth: &proto.HeaderAuthRequest{ + HeaderValue: value, + HeaderName: h.headerName, + }, + }, + }) + if err != nil { + return "", "", fmt.Errorf("authenticate header: %w", err) + } + + if res.GetSuccess() { + return res.GetSessionToken(), "", nil + } + + return "", "", ErrHeaderAuthFailed +} diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go index 3cf86e4b3..670cafb68 100644 --- a/proxy/internal/auth/middleware.go +++ b/proxy/internal/auth/middleware.go @@ -4,9 +4,12 @@ import ( "context" "crypto/ed25519" "encoding/base64" + "errors" "fmt" + "html" "net" "net/http" + "net/netip" "net/url" "sync" "time" @@ -16,11 +19,16 @@ import ( "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/web" "github.com/netbirdio/netbird/shared/management/proto" ) +// errValidationUnavailable indicates that session validation failed due to +// an infrastructure error (e.g. gRPC unavailable), not an invalid token. +var errValidationUnavailable = errors.New("session validation unavailable") + type authenticator interface { Authenticate(ctx context.Context, in *proto.AuthenticateRequest, opts ...grpc.CallOption) (*proto.AuthenticateResponse, error) } @@ -40,12 +48,14 @@ type Scheme interface { Authenticate(*http.Request) (token string, promptData string, err error) } +// DomainConfig holds the authentication and restriction settings for a protected domain. type DomainConfig struct { Schemes []Scheme SessionPublicKey ed25519.PublicKey SessionExpiration time.Duration AccountID types.AccountID ServiceID types.ServiceID + IPRestrictions *restrict.Filter } type validationResult struct { @@ -54,17 +64,18 @@ type validationResult struct { DeniedReason string } +// Middleware applies per-domain authentication and IP restriction checks. type Middleware struct { domainsMux sync.RWMutex domains map[string]DomainConfig logger *log.Logger sessionValidator SessionValidator + geo restrict.GeoResolver } -// NewMiddleware creates a new authentication middleware. -// The sessionValidator is optional; if nil, OIDC session tokens will be validated -// locally without group access checks. -func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator) *Middleware { +// NewMiddleware creates a new authentication middleware. The sessionValidator is +// optional; if nil, OIDC session tokens are validated locally without group access checks. +func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo restrict.GeoResolver) *Middleware { if logger == nil { logger = log.StandardLogger() } @@ -72,18 +83,12 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator) *Middl domains: make(map[string]DomainConfig), logger: logger, sessionValidator: sessionValidator, + geo: geo, } } -// Protect applies authentication middleware to the passed handler. -// For each incoming request it will be checked against the middleware's -// internal list of protected domains. -// If the Host domain in the inbound request is not present, then it will -// simply be passed through. -// However, if the Host domain is present, then the specified authentication -// schemes for that domain will be applied to the request. -// In the event that no authentication schemes are defined for the domain, -// then the request will also be simply passed through. +// Protect wraps next with per-domain authentication and IP restriction checks. +// Requests whose Host is not registered pass through unchanged. func (mw *Middleware) Protect(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host, _, err := net.SplitHostPort(r.Host) @@ -94,8 +99,7 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler { config, exists := mw.getDomainConfig(host) mw.logger.Debugf("checking authentication for host: %s, exists: %t", host, exists) - // Domains that are not configured here or have no authentication schemes applied should simply pass through. - if !exists || len(config.Schemes) == 0 { + if !exists { next.ServeHTTP(w, r) return } @@ -103,6 +107,16 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler { // Set account and service IDs in captured data for access logging. setCapturedIDs(r, config) + if !mw.checkIPRestrictions(w, r, config) { + return + } + + // Domains with no authentication schemes pass through after IP checks. + if len(config.Schemes) == 0 { + next.ServeHTTP(w, r) + return + } + if mw.handleOAuthCallbackError(w, r) { return } @@ -111,6 +125,10 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler { return } + if mw.forwardWithHeaderAuth(w, r, host, config, next) { + return + } + mw.authenticateWithSchemes(w, r, host, config) }) } @@ -124,11 +142,65 @@ func (mw *Middleware) getDomainConfig(host string) (DomainConfig, bool) { func setCapturedIDs(r *http.Request, config DomainConfig) { if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { - cd.SetAccountId(config.AccountID) - cd.SetServiceId(config.ServiceID) + cd.SetAccountID(config.AccountID) + cd.SetServiceID(config.ServiceID) } } +// checkIPRestrictions validates the client IP against the domain's IP restrictions. +// Uses the resolved client IP from CapturedData (which accounts for trusted proxies) +// rather than r.RemoteAddr directly. +func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request, config DomainConfig) bool { + if config.IPRestrictions == nil { + return true + } + + clientIP := mw.resolveClientIP(r) + if !clientIP.IsValid() { + mw.logger.Debugf("IP restriction: cannot resolve client address for %q, denying", r.RemoteAddr) + http.Error(w, "Forbidden", http.StatusForbidden) + return false + } + + verdict := config.IPRestrictions.Check(clientIP, mw.geo) + if verdict == restrict.Allow { + return true + } + + reason := verdict.String() + mw.blockIPRestriction(r, reason) + http.Error(w, "Forbidden", http.StatusForbidden) + return false +} + +// resolveClientIP extracts the real client IP from CapturedData, falling back to r.RemoteAddr. +func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + if ip := cd.GetClientIP(); ip.IsValid() { + return ip + } + } + + clientIPStr, _, _ := net.SplitHostPort(r.RemoteAddr) + if clientIPStr == "" { + clientIPStr = r.RemoteAddr + } + addr, err := netip.ParseAddr(clientIPStr) + if err != nil { + return netip.Addr{} + } + return addr.Unmap() +} + +// blockIPRestriction sets captured data fields for an IP-restriction block event. +func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(reason) + } + mw.logger.Debugf("IP restriction: %s for %s", reason, r.RemoteAddr) +} + // handleOAuthCallbackError checks for error query parameters from an OAuth // callback and renders the access denied page if present. func (mw *Middleware) handleOAuthCallbackError(w http.ResponseWriter, r *http.Request) bool { @@ -146,6 +218,8 @@ func (mw *Middleware) handleOAuthCallbackError(w http.ResponseWriter, r *http.Re errDesc := r.URL.Query().Get("error_description") if errDesc == "" { errDesc = "An error occurred during authentication" + } else { + errDesc = html.EscapeString(errDesc) } web.ServeAccessDeniedPage(w, r, http.StatusForbidden, "Access Denied", errDesc, requestID) return true @@ -170,6 +244,85 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re return true } +// forwardWithHeaderAuth checks for a Header auth scheme. If the header validates, +// the request is forwarded directly (no redirect), which is important for API clients. +func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool { + for _, scheme := range config.Schemes { + hdr, ok := scheme.(Header) + if !ok { + continue + } + + handled := mw.tryHeaderScheme(w, r, host, config, hdr, next) + if handled { + return true + } + } + return false +} + +func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, hdr Header, next http.Handler) bool { + token, _, err := hdr.Authenticate(r) + if err != nil { + return mw.handleHeaderAuthError(w, r, err) + } + if token == "" { + return false + } + + result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader) + if err != nil { + setHeaderCapturedData(r.Context(), "") + status := http.StatusBadRequest + msg := "invalid session token" + if errors.Is(err, errValidationUnavailable) { + status = http.StatusBadGateway + msg = "authentication service unavailable" + } + http.Error(w, msg, status) + return true + } + + if !result.Valid { + setHeaderCapturedData(r.Context(), result.UserID) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return true + } + + setSessionCookie(w, token, config.SessionExpiration) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetUserID(result.UserID) + cd.SetAuthMethod(auth.MethodHeader.String()) + } + + next.ServeHTTP(w, r) + return true +} + +func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Request, err error) bool { + if errors.Is(err, ErrHeaderAuthFailed) { + setHeaderCapturedData(r.Context(), "") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return true + } + mw.logger.WithField("scheme", "header").Warnf("header auth infrastructure error: %v", err) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + } + http.Error(w, "authentication service unavailable", http.StatusBadGateway) + return true +} + +func setHeaderCapturedData(ctx context.Context, userID string) { + cd := proxy.CapturedDataFromContext(ctx) + if cd == nil { + return + } + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(auth.MethodHeader.String()) + cd.SetUserID(userID) +} + // authenticateWithSchemes tries each configured auth scheme in order. // On success it sets a session cookie and redirects; on failure it renders the login page. func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Request, host string, config DomainConfig) { @@ -217,7 +370,13 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re cd.SetOrigin(proxy.OriginAuth) cd.SetAuthMethod(scheme.Type().String()) } - http.Error(w, err.Error(), http.StatusBadRequest) + status := http.StatusBadRequest + msg := "invalid session token" + if errors.Is(err, errValidationUnavailable) { + status = http.StatusBadGateway + msg = "authentication service unavailable" + } + http.Error(w, msg, status) return } @@ -233,7 +392,21 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re return } - expiration := config.SessionExpiration + setSessionCookie(w, token, config.SessionExpiration) + + // Redirect instead of forwarding the auth POST to the backend. + // The browser will follow with a GET carrying the new session cookie. + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetUserID(result.UserID) + cd.SetAuthMethod(scheme.Type().String()) + } + redirectURL := stripSessionTokenParam(r.URL) + http.Redirect(w, r, redirectURL, http.StatusSeeOther) +} + +// setSessionCookie writes a session cookie with secure defaults. +func setSessionCookie(w http.ResponseWriter, token string, expiration time.Duration) { if expiration == 0 { expiration = auth.DefaultSessionExpiry } @@ -245,16 +418,6 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re SameSite: http.SameSiteLaxMode, MaxAge: int(expiration.Seconds()), }) - - // Redirect instead of forwarding the auth POST to the backend. - // The browser will follow with a GET carrying the new session cookie. - if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { - cd.SetOrigin(proxy.OriginAuth) - cd.SetUserID(result.UserID) - cd.SetAuthMethod(scheme.Type().String()) - } - redirectURL := stripSessionTokenParam(r.URL) - http.Redirect(w, r, redirectURL, http.StatusSeeOther) } // wasCredentialSubmitted checks if credentials were submitted for the given auth method. @@ -275,13 +438,14 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { // session JWTs. Returns an error if the key is missing or invalid. // Callers must not serve the domain if this returns an error, to avoid // exposing an unauthenticated service. -func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID) error { +func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter) error { if len(schemes) == 0 { mw.domainsMux.Lock() defer mw.domainsMux.Unlock() mw.domains[domain] = DomainConfig{ - AccountID: accountID, - ServiceID: serviceID, + AccountID: accountID, + ServiceID: serviceID, + IPRestrictions: ipRestrictions, } return nil } @@ -302,30 +466,28 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st SessionExpiration: expiration, AccountID: accountID, ServiceID: serviceID, + IPRestrictions: ipRestrictions, } return nil } +// RemoveDomain unregisters authentication for the given domain. func (mw *Middleware) RemoveDomain(domain string) { mw.domainsMux.Lock() defer mw.domainsMux.Unlock() delete(mw.domains, domain) } -// validateSessionToken validates a session token, optionally checking group access via gRPC. -// For OIDC tokens with a configured validator, it calls ValidateSession to check group access. -// For other auth methods (PIN, password), it validates the JWT locally. -// Returns a validationResult with user ID and validity status, or error for invalid tokens. +// validateSessionToken validates a session token. OIDC tokens with a configured +// validator go through gRPC for group access checks; other methods validate locally. func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) { - // For OIDC with a session validator, call the gRPC service to check group access if method == auth.MethodOIDC && mw.sessionValidator != nil { resp, err := mw.sessionValidator.ValidateSession(ctx, &proto.ValidateSessionRequest{ Domain: host, SessionToken: token, }) if err != nil { - mw.logger.WithError(err).Error("ValidateSession gRPC call failed") - return nil, fmt.Errorf("session validation failed") + return nil, fmt.Errorf("%w: %w", errValidationUnavailable, err) } if !resp.Valid { mw.logger.WithFields(log.Fields{ @@ -342,7 +504,6 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri return &validationResult{UserID: resp.UserId, Valid: true}, nil } - // For non-OIDC methods or when no validator is configured, validate JWT locally userID, _, err := auth.ValidateSessionJWT(token, host, publicKey) if err != nil { return nil, err diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go index 7d9ac1bd5..6063f070e 100644 --- a/proxy/internal/auth/middleware_test.go +++ b/proxy/internal/auth/middleware_test.go @@ -1,11 +1,14 @@ package auth import ( + "context" "crypto/ed25519" "crypto/rand" "encoding/base64" + "errors" "net/http" "net/http/httptest" + "net/netip" "net/url" "strings" "testing" @@ -14,10 +17,13 @@ import ( log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/shared/management/proto" ) func generateTestKeyPair(t *testing.T) *sessionkey.KeyPair { @@ -52,11 +58,11 @@ func newPassthroughHandler() http.Handler { } func TestAddDomain_ValidKey(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "") + err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil) require.NoError(t, err) mw.domainsMux.RLock() @@ -70,10 +76,10 @@ func TestAddDomain_ValidKey(t *testing.T) { } func TestAddDomain_EmptyKey(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "") + err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil) require.Error(t, err) assert.Contains(t, err.Error(), "invalid session public key size") @@ -84,10 +90,10 @@ func TestAddDomain_EmptyKey(t *testing.T) { } func TestAddDomain_InvalidBase64(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "") + err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil) require.Error(t, err) assert.Contains(t, err.Error(), "decode session public key") @@ -98,11 +104,11 @@ func TestAddDomain_InvalidBase64(t *testing.T) { } func TestAddDomain_WrongKeySize(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort")) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "") + err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil) require.Error(t, err) assert.Contains(t, err.Error(), "invalid session public key size") @@ -113,9 +119,9 @@ func TestAddDomain_WrongKeySize(t *testing.T) { } func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) - err := mw.AddDomain("example.com", nil, "", time.Hour, "", "") + err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil) require.NoError(t, err, "domains with no auth schemes should not require a key") mw.domainsMux.RLock() @@ -125,14 +131,14 @@ func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) { } func TestAddDomain_OverwritesPreviousConfig(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp1 := generateTestKeyPair(t) kp2 := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "")) - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil)) mw.domainsMux.RLock() config := mw.domains["example.com"] @@ -144,11 +150,11 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) { } func TestRemoveDomain(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) mw.RemoveDomain("example.com") @@ -159,7 +165,7 @@ func TestRemoveDomain(t *testing.T) { } func TestProtect_UnknownDomainPassesThrough(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) handler := mw.Protect(newPassthroughHandler()) req := httptest.NewRequest(http.MethodGet, "http://unknown.com/", nil) @@ -171,8 +177,8 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) { } func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) - require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "")) + mw := NewMiddleware(log.StandardLogger(), nil, nil) + require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil)) handler := mw.Protect(newPassthroughHandler()) @@ -185,11 +191,11 @@ func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) { } func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) var backendCalled bool backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -206,11 +212,11 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) { } func TestProtect_HostWithPortIsMatched(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) var backendCalled bool backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -227,16 +233,16 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) { } func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour) require.NoError(t, err) - capturedData := &proxy.CapturedData{} + capturedData := proxy.NewCapturedData("") handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cd := proxy.CapturedDataFromContext(r.Context()) require.NotNil(t, cd) @@ -257,11 +263,11 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) { } func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) // Sign a token that expired 1 second ago. token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, -time.Second) @@ -283,11 +289,11 @@ func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) { } func TestProtect_WrongDomainCookieIsRejected(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) // Token signed for a different domain audience. token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "other.com", auth.MethodPIN, time.Hour) @@ -309,12 +315,12 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) { } func TestProtect_WrongKeyCookieIsRejected(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp1 := generateTestKeyPair(t) kp2 := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil)) // Token signed with a different private key. token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour) @@ -336,7 +342,7 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) { } func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "example.com", auth.MethodPIN, time.Hour) @@ -351,7 +357,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) { return "", "pin", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) var backendCalled bool backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -386,7 +392,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) { } func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{ @@ -395,7 +401,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) { return "", "pin", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) handler := mw.Protect(newPassthroughHandler()) @@ -409,7 +415,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) { } func TestProtect_MultipleSchemes(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) token, err := sessionkey.SignToken(kp.PrivateKey, "password-user", "example.com", auth.MethodPassword, time.Hour) @@ -431,7 +437,7 @@ func TestProtect_MultipleSchemes(t *testing.T) { return "", "password", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil)) var backendCalled bool backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -451,7 +457,7 @@ func TestProtect_MultipleSchemes(t *testing.T) { } func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) // Return a garbage token that won't validate. @@ -461,7 +467,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) { return "invalid-jwt-token", "", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) handler := mw.Protect(newPassthroughHandler()) @@ -473,7 +479,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) { } func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) // 32 random bytes that happen to be valid base64 and correct size // but are actually a valid ed25519 public key length-wise. @@ -485,19 +491,19 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) { key := base64.StdEncoding.EncodeToString(randomBytes) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "") + err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil) require.NoError(t, err, "any 32-byte key should be accepted at registration time") } func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) // Attempt to overwrite with an invalid key. - err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "") + err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil) require.Error(t, err) // The original valid config should still be intact. @@ -511,7 +517,7 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) { } func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) // Scheme that always fails authentication (returns empty token) @@ -521,9 +527,9 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) { return "", "pin", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) - capturedData := &proxy.CapturedData{} + capturedData := proxy.NewCapturedData("") handler := mw.Protect(newPassthroughHandler()) // Submit wrong PIN - should capture auth method @@ -539,7 +545,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) { } func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{ @@ -548,9 +554,9 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) { return "", "password", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) - capturedData := &proxy.CapturedData{} + capturedData := proxy.NewCapturedData("") handler := mw.Protect(newPassthroughHandler()) // Submit wrong password - should capture auth method @@ -566,7 +572,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) { } func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) { - mw := NewMiddleware(log.StandardLogger(), nil) + mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) scheme := &stubScheme{ @@ -575,9 +581,9 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) { return "", "pin", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "")) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) - capturedData := &proxy.CapturedData{} + capturedData := proxy.NewCapturedData("") handler := mw.Protect(newPassthroughHandler()) // No credentials submitted - should not capture auth method @@ -658,3 +664,339 @@ func TestWasCredentialSubmitted(t *testing.T) { }) } } + +func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil)) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + tests := []struct { + name string + remoteAddr string + wantCode int + }{ + {"unparsable address denies", "not-an-ip:1234", http.StatusForbidden}, + {"empty address denies", "", http.StatusForbidden}, + {"allowed address passes", "10.1.2.3:5678", http.StatusOK}, + {"denied address blocked", "192.168.1.1:5678", http.StatusForbidden}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = tt.remoteAddr + req.Host = "example.com" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, tt.wantCode, rr.Code) + }) + } +} + +func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) { + // When CapturedData is set (by the access log middleware, which resolves + // trusted proxies), checkIPRestrictions should use that IP, not RemoteAddr. + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter([]string{"203.0.113.0/24"}, nil, nil, nil)) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // RemoteAddr is a trusted proxy, but CapturedData has the real client IP. + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "10.0.0.1:5000" + req.Host = "example.com" + + cd := proxy.NewCapturedData("") + cd.SetClientIP(netip.MustParseAddr("203.0.113.50")) + ctx := proxy.WithCapturedData(req.Context(), cd) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code, "should use CapturedData IP (203.0.113.50), not RemoteAddr (10.0.0.1)") + + // Same request but CapturedData has a blocked IP. + req2 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req2.RemoteAddr = "203.0.113.50:5000" + req2.Host = "example.com" + + cd2 := proxy.NewCapturedData("") + cd2.SetClientIP(netip.MustParseAddr("10.0.0.1")) + ctx2 := proxy.WithCapturedData(req2.Context(), cd2) + req2 = req2.WithContext(ctx2) + + rr2 := httptest.NewRecorder() + handler.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusForbidden, rr2.Code, "should use CapturedData IP (10.0.0.1), not RemoteAddr (203.0.113.50)") +} + +func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) { + // Geo is nil, country restrictions are configured: must deny (fail-close). + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter(nil, nil, []string{"US"}, nil)) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "1.2.3.4:5678" + req.Host = "example.com" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code, "country restrictions with nil geo must deny") +} + +// mockAuthenticator is a minimal mock for the authenticator gRPC interface +// used by the Header scheme. +type mockAuthenticator struct { + fn func(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) +} + +func (m *mockAuthenticator) Authenticate(ctx context.Context, in *proto.AuthenticateRequest, _ ...grpc.CallOption) (*proto.AuthenticateResponse, error) { + return m.fn(ctx, in) +} + +// newHeaderSchemeWithToken creates a Header scheme backed by a mock that +// returns a signed session token when the expected header value is provided. +func newHeaderSchemeWithToken(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string) Header { + t.Helper() + token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "example.com", auth.MethodHeader, time.Hour) + require.NoError(t, err) + + mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + ha := req.GetHeaderAuth() + if ha != nil && ha.GetHeaderValue() == expectedValue { + return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil + } + return &proto.AuthenticateResponse{Success: false}, nil + }} + return NewHeader(mock, "svc1", "acc1", headerName) +} + +func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + var backendCalled bool + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/path", nil) + req.Header.Set("X-API-Key", "secret-key") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.True(t, backendCalled, "backend should be called directly for header auth (no redirect)") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "ok", rec.Body.String()) + + // Session cookie should be set. + var sessionCookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie, "session cookie should be set after successful header auth") + assert.True(t, sessionCookie.HttpOnly) + assert.True(t, sessionCookie.Secure) + + assert.Equal(t, "header-user", capturedData.GetUserID()) + assert.Equal(t, "header", capturedData.GetAuthMethod()) +} + +func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key") + // Also add a PIN scheme so we can verify fallthrough behavior. + pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + handler := mw.Protect(newPassthroughHandler()) + + // No X-API-Key header: should fall through to PIN login page (401). + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, "missing header should fall through to login page") +} + +func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + return &proto.AuthenticateResponse{Success: false}, nil + }} + hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("X-API-Key", "wrong-key") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "header", capturedData.GetAuthMethod()) +} + +func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + return nil, errors.New("gRPC unavailable") + }} + hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("X-API-Key", "some-key") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadGateway, rec.Code) +} + +func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // First request with header auth. + req1 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req1.Header.Set("X-API-Key", "secret-key") + req1 = req1.WithContext(proxy.WithCapturedData(req1.Context(), proxy.NewCapturedData(""))) + rec1 := httptest.NewRecorder() + handler.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + // Extract session cookie. + var sessionCookie *http.Cookie + for _, c := range rec1.Result().Cookies() { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie) + + // Second request with only the session cookie (no header). + capturedData2 := proxy.NewCapturedData("") + req2 := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil) + req2.AddCookie(sessionCookie) + req2 = req2.WithContext(proxy.WithCapturedData(req2.Context(), capturedData2)) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, req2) + + assert.Equal(t, http.StatusOK, rec2.Code) + assert.Equal(t, "header-user", capturedData2.GetUserID()) + assert.Equal(t, "header", capturedData2.GetAuthMethod()) +} + +// TestProtect_HeaderAuth_MultipleValuesSameHeader verifies that the proxy +// correctly handles multiple valid credentials for the same header name. +// In production, the mgmt gRPC authenticateHeader iterates all configured +// header auths and accepts if any hash matches (OR semantics). The proxy +// creates one Header scheme per entry, but a single gRPC call checks all. +func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + // Mock simulates mgmt behavior: accepts either token-a or token-b. + accepted := map[string]bool{"Bearer token-a": true, "Bearer token-b": true} + mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + ha := req.GetHeaderAuth() + if ha != nil && accepted[ha.GetHeaderValue()] { + token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "example.com", auth.MethodHeader, time.Hour) + require.NoError(t, err) + return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil + } + return &proto.AuthenticateResponse{Success: false}, nil + }} + + // Single Header scheme (as if one entry existed), but the mock checks both values. + hdr := NewHeader(mock, "svc1", "acc1", "Authorization") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + var backendCalled bool + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + })) + + t.Run("first value accepted", func(t *testing.T) { + backendCalled = false + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("Authorization", "Bearer token-a") + req = req.WithContext(proxy.WithCapturedData(req.Context(), proxy.NewCapturedData(""))) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, backendCalled, "first token should be accepted") + }) + + t.Run("second value accepted", func(t *testing.T) { + backendCalled = false + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("Authorization", "Bearer token-b") + req = req.WithContext(proxy.WithCapturedData(req.Context(), proxy.NewCapturedData(""))) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, backendCalled, "second token should be accepted") + }) + + t.Run("unknown value rejected", func(t *testing.T) { + backendCalled = false + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("Authorization", "Bearer token-c") + req = req.WithContext(proxy.WithCapturedData(req.Context(), proxy.NewCapturedData(""))) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, backendCalled, "unknown token should be rejected") + }) +} diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go index 237010922..c507cfad9 100644 --- a/proxy/internal/debug/handler.go +++ b/proxy/internal/debug/handler.go @@ -409,17 +409,13 @@ func (h *Handler) handleClientStatus(w http.ResponseWriter, r *http.Request, acc } pbStatus := nbstatus.ToProtoFullStatus(fullStatus) - overview := nbstatus.ConvertToStatusOutputOverview( - pbStatus, - false, - version.NetbirdVersion(), - statusFilter, - prefixNamesFilter, - prefixNamesFilterMap, - ipsFilterMap, - connectionTypeFilter, - "", - ) + overview := nbstatus.ConvertToStatusOutputOverview(pbStatus, nbstatus.ConvertOptions{ + StatusFilter: statusFilter, + PrefixNamesFilter: prefixNamesFilter, + PrefixNamesFilterMap: prefixNamesFilterMap, + IPsFilter: ipsFilterMap, + ConnectionTypeFilter: connectionTypeFilter, + }) if wantJSON { h.writeJSON(w, map[string]interface{}{ diff --git a/proxy/internal/geolocation/download.go b/proxy/internal/geolocation/download.go new file mode 100644 index 000000000..64d515275 --- /dev/null +++ b/proxy/internal/geolocation/download.go @@ -0,0 +1,264 @@ +package geolocation + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "crypto/sha256" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + mmdbTarGZURL = "https://pkgs.netbird.io/geolocation-dbs/GeoLite2-City/download?suffix=tar.gz" + mmdbSha256URL = "https://pkgs.netbird.io/geolocation-dbs/GeoLite2-City/download?suffix=tar.gz.sha256" + mmdbInnerName = "GeoLite2-City.mmdb" + + downloadTimeout = 2 * time.Minute + maxMMDBSize = 256 << 20 // 256 MB +) + +// ensureMMDB checks for an existing MMDB file in dataDir. If none is found, +// it downloads from pkgs.netbird.io with SHA256 verification. +func ensureMMDB(logger *log.Logger, dataDir string) (string, error) { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return "", fmt.Errorf("create geo data directory %s: %w", dataDir, err) + } + + pattern := filepath.Join(dataDir, mmdbGlob) + if files, _ := filepath.Glob(pattern); len(files) > 0 { + mmdbPath := files[len(files)-1] + logger.Debugf("using existing geolocation database: %s", mmdbPath) + return mmdbPath, nil + } + + logger.Info("geolocation database not found, downloading from pkgs.netbird.io") + return downloadMMDB(logger, dataDir) +} + +func downloadMMDB(logger *log.Logger, dataDir string) (string, error) { + client := &http.Client{Timeout: downloadTimeout} + + datedName, err := fetchRemoteFilename(client, mmdbTarGZURL) + if err != nil { + return "", fmt.Errorf("get remote filename: %w", err) + } + + mmdbFilename := deriveMMDBFilename(datedName) + mmdbPath := filepath.Join(dataDir, mmdbFilename) + + tmp, err := os.MkdirTemp("", "geolite-proxy-*") + if err != nil { + return "", fmt.Errorf("create temp directory: %w", err) + } + defer os.RemoveAll(tmp) + + checksumFile := filepath.Join(tmp, "checksum.sha256") + if err := downloadToFile(client, mmdbSha256URL, checksumFile); err != nil { + return "", fmt.Errorf("download checksum: %w", err) + } + + expectedHash, err := readChecksumFile(checksumFile) + if err != nil { + return "", fmt.Errorf("read checksum: %w", err) + } + + tarFile := filepath.Join(tmp, datedName) + logger.Debugf("downloading geolocation database (%s)", datedName) + if err := downloadToFile(client, mmdbTarGZURL, tarFile); err != nil { + return "", fmt.Errorf("download database: %w", err) + } + + if err := verifySHA256(tarFile, expectedHash); err != nil { + return "", fmt.Errorf("verify database checksum: %w", err) + } + + if err := extractMMDBFromTarGZ(tarFile, mmdbPath); err != nil { + return "", fmt.Errorf("extract database: %w", err) + } + + logger.Infof("geolocation database downloaded: %s", mmdbPath) + return mmdbPath, nil +} + +// deriveMMDBFilename converts a tar.gz filename to an MMDB filename. +// Example: GeoLite2-City_20240101.tar.gz -> GeoLite2-City_20240101.mmdb +func deriveMMDBFilename(tarName string) string { + base, _, _ := strings.Cut(tarName, ".") + if !strings.Contains(base, "_") { + return "GeoLite2-City.mmdb" + } + return base + ".mmdb" +} + +func fetchRemoteFilename(client *http.Client, url string) (string, error) { + resp, err := client.Head(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HEAD request: HTTP %d", resp.StatusCode) + } + + cd := resp.Header.Get("Content-Disposition") + if cd == "" { + return "", errors.New("no Content-Disposition header") + } + + _, params, err := mime.ParseMediaType(cd) + if err != nil { + return "", fmt.Errorf("parse Content-Disposition: %w", err) + } + + name := filepath.Base(params["filename"]) + if name == "" || name == "." { + return "", errors.New("no filename in Content-Disposition") + } + return name, nil +} + +func downloadToFile(client *http.Client, url, dest string) error { + resp, err := client.Get(url) //nolint:gosec + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + f, err := os.Create(dest) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + + // Cap download at 256 MB to prevent unbounded reads from a compromised server. + if _, err := io.Copy(f, io.LimitReader(resp.Body, maxMMDBSize)); err != nil { + return err + } + return nil +} + +func readChecksumFile(path string) (string, error) { + f, err := os.Open(path) //nolint:gosec + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if scanner.Scan() { + parts := strings.Fields(scanner.Text()) + if len(parts) > 0 { + return parts[0], nil + } + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", errors.New("empty checksum file") +} + +func verifySHA256(path, expected string) error { + f, err := os.Open(path) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + + actual := fmt.Sprintf("%x", h.Sum(nil)) + if actual != expected { + return fmt.Errorf("SHA256 mismatch: expected %s, got %s", expected, actual) + } + return nil +} + +func extractMMDBFromTarGZ(tarGZPath, destPath string) error { + f, err := os.Open(tarGZPath) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + + if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == mmdbInnerName { + if hdr.Size < 0 || hdr.Size > maxMMDBSize { + return fmt.Errorf("mmdb entry size %d exceeds limit %d", hdr.Size, maxMMDBSize) + } + if err := extractToFileAtomic(io.LimitReader(tr, hdr.Size), destPath); err != nil { + return err + } + return nil + } + } + + return fmt.Errorf("%s not found in archive", mmdbInnerName) +} + +// extractToFileAtomic writes r to a temporary file in the same directory as +// destPath, then renames it into place so a crash never leaves a truncated file. +func extractToFileAtomic(r io.Reader, destPath string) error { + dir := filepath.Dir(destPath) + tmp, err := os.CreateTemp(dir, ".mmdb-*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + + if _, err := io.Copy(tmp, r); err != nil { //nolint:gosec // G110: caller bounds with LimitReader + if closeErr := tmp.Close(); closeErr != nil { + log.Debugf("failed to close temp file %s: %v", tmpPath, closeErr) + } + if removeErr := os.Remove(tmpPath); removeErr != nil { + log.Debugf("failed to remove temp file %s: %v", tmpPath, removeErr) + } + return fmt.Errorf("write mmdb: %w", err) + } + if err := tmp.Close(); err != nil { + if removeErr := os.Remove(tmpPath); removeErr != nil { + log.Debugf("failed to remove temp file %s: %v", tmpPath, removeErr) + } + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tmpPath, destPath); err != nil { + if removeErr := os.Remove(tmpPath); removeErr != nil { + log.Debugf("failed to remove temp file %s: %v", tmpPath, removeErr) + } + return fmt.Errorf("rename to %s: %w", destPath, err) + } + return nil +} diff --git a/proxy/internal/geolocation/geolocation.go b/proxy/internal/geolocation/geolocation.go new file mode 100644 index 000000000..81b02efb3 --- /dev/null +++ b/proxy/internal/geolocation/geolocation.go @@ -0,0 +1,152 @@ +// Package geolocation provides IP-to-country lookups using MaxMind GeoLite2 databases. +package geolocation + +import ( + "fmt" + "net/netip" + "os" + "strconv" + "sync" + + "github.com/oschwald/maxminddb-golang" + log "github.com/sirupsen/logrus" +) + +const ( + // EnvDisable disables geolocation lookups entirely when set to a truthy value. + EnvDisable = "NB_PROXY_DISABLE_GEOLOCATION" + + mmdbGlob = "GeoLite2-City_*.mmdb" +) + +type record struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + } `maxminddb:"country"` + City struct { + Names struct { + En string `maxminddb:"en"` + } `maxminddb:"names"` + } `maxminddb:"city"` + Subdivisions []struct { + ISOCode string `maxminddb:"iso_code"` + Names struct { + En string `maxminddb:"en"` + } `maxminddb:"names"` + } `maxminddb:"subdivisions"` +} + +// Result holds the outcome of a geo lookup. +type Result struct { + CountryCode string + CityName string + SubdivisionCode string + SubdivisionName string +} + +// Lookup provides IP geolocation lookups. +type Lookup struct { + mu sync.RWMutex + db *maxminddb.Reader + logger *log.Logger +} + +// NewLookup opens or downloads the GeoLite2-City MMDB in dataDir. +// Returns nil without error if geolocation is disabled via environment +// variable, no data directory is configured, or the download fails +// (graceful degradation: country restrictions will deny all requests). +func NewLookup(logger *log.Logger, dataDir string) (*Lookup, error) { + if isDisabledByEnv(logger) { + logger.Info("geolocation disabled via environment variable") + return nil, nil //nolint:nilnil + } + + if dataDir == "" { + return nil, nil //nolint:nilnil + } + + mmdbPath, err := ensureMMDB(logger, dataDir) + if err != nil { + logger.Warnf("geolocation database unavailable: %v", err) + logger.Warn("country-based access restrictions will deny all requests until a database is available") + return nil, nil //nolint:nilnil + } + + db, err := maxminddb.Open(mmdbPath) + if err != nil { + return nil, fmt.Errorf("open GeoLite2 database %s: %w", mmdbPath, err) + } + + logger.Infof("geolocation database loaded from %s", mmdbPath) + return &Lookup{db: db, logger: logger}, nil +} + +// LookupAddr returns the country ISO code and city name for the given IP. +// Returns an empty Result if the database is nil or the lookup fails. +func (l *Lookup) LookupAddr(addr netip.Addr) Result { + if l == nil { + return Result{} + } + + l.mu.RLock() + defer l.mu.RUnlock() + + if l.db == nil { + return Result{} + } + + addr = addr.Unmap() + + var rec record + if err := l.db.Lookup(addr.AsSlice(), &rec); err != nil { + l.logger.Debugf("geolocation lookup %s: %v", addr, err) + return Result{} + } + r := Result{ + CountryCode: rec.Country.ISOCode, + CityName: rec.City.Names.En, + } + if len(rec.Subdivisions) > 0 { + r.SubdivisionCode = rec.Subdivisions[0].ISOCode + r.SubdivisionName = rec.Subdivisions[0].Names.En + } + return r +} + +// Available reports whether the lookup has a loaded database. +func (l *Lookup) Available() bool { + if l == nil { + return false + } + l.mu.RLock() + defer l.mu.RUnlock() + return l.db != nil +} + +// Close releases the database resources. +func (l *Lookup) Close() error { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + if l.db != nil { + err := l.db.Close() + l.db = nil + return err + } + return nil +} + +func isDisabledByEnv(logger *log.Logger) bool { + val := os.Getenv(EnvDisable) + if val == "" { + return false + } + disabled, err := strconv.ParseBool(val) + if err != nil { + logger.Warnf("parse %s=%q: %v", EnvDisable, val, err) + return false + } + return disabled +} diff --git a/proxy/internal/proxy/context.go b/proxy/internal/proxy/context.go index 4a61f6bcf..d3f67dc57 100644 --- a/proxy/internal/proxy/context.go +++ b/proxy/internal/proxy/context.go @@ -11,8 +11,6 @@ import ( type requestContextKey string const ( - serviceIdKey requestContextKey = "serviceId" - accountIdKey requestContextKey = "accountId" capturedDataKey requestContextKey = "capturedData" ) @@ -47,112 +45,117 @@ func (o ResponseOrigin) String() string { // to pass data back up the middleware chain. type CapturedData struct { mu sync.RWMutex - RequestID string - ServiceId types.ServiceID - AccountId types.AccountID - Origin ResponseOrigin - ClientIP netip.Addr - UserID string - AuthMethod string + requestID string + serviceID types.ServiceID + accountID types.AccountID + origin ResponseOrigin + clientIP netip.Addr + userID string + authMethod string } -// GetRequestID safely gets the request ID +// NewCapturedData creates a CapturedData with the given request ID. +func NewCapturedData(requestID string) *CapturedData { + return &CapturedData{requestID: requestID} +} + +// GetRequestID returns the request ID. func (c *CapturedData) GetRequestID() string { c.mu.RLock() defer c.mu.RUnlock() - return c.RequestID + return c.requestID } -// SetServiceId safely sets the service ID -func (c *CapturedData) SetServiceId(serviceId types.ServiceID) { +// SetServiceID sets the service ID. +func (c *CapturedData) SetServiceID(serviceID types.ServiceID) { c.mu.Lock() defer c.mu.Unlock() - c.ServiceId = serviceId + c.serviceID = serviceID } -// GetServiceId safely gets the service ID -func (c *CapturedData) GetServiceId() types.ServiceID { +// GetServiceID returns the service ID. +func (c *CapturedData) GetServiceID() types.ServiceID { c.mu.RLock() defer c.mu.RUnlock() - return c.ServiceId + return c.serviceID } -// SetAccountId safely sets the account ID -func (c *CapturedData) SetAccountId(accountId types.AccountID) { +// SetAccountID sets the account ID. +func (c *CapturedData) SetAccountID(accountID types.AccountID) { c.mu.Lock() defer c.mu.Unlock() - c.AccountId = accountId + c.accountID = accountID } -// GetAccountId safely gets the account ID -func (c *CapturedData) GetAccountId() types.AccountID { +// GetAccountID returns the account ID. +func (c *CapturedData) GetAccountID() types.AccountID { c.mu.RLock() defer c.mu.RUnlock() - return c.AccountId + return c.accountID } -// SetOrigin safely sets the response origin +// SetOrigin sets the response origin. func (c *CapturedData) SetOrigin(origin ResponseOrigin) { c.mu.Lock() defer c.mu.Unlock() - c.Origin = origin + c.origin = origin } -// GetOrigin safely gets the response origin +// GetOrigin returns the response origin. func (c *CapturedData) GetOrigin() ResponseOrigin { c.mu.RLock() defer c.mu.RUnlock() - return c.Origin + return c.origin } -// SetClientIP safely sets the resolved client IP. +// SetClientIP sets the resolved client IP. func (c *CapturedData) SetClientIP(ip netip.Addr) { c.mu.Lock() defer c.mu.Unlock() - c.ClientIP = ip + c.clientIP = ip } -// GetClientIP safely gets the resolved client IP. +// GetClientIP returns the resolved client IP. func (c *CapturedData) GetClientIP() netip.Addr { c.mu.RLock() defer c.mu.RUnlock() - return c.ClientIP + return c.clientIP } -// SetUserID safely sets the authenticated user ID. +// SetUserID sets the authenticated user ID. func (c *CapturedData) SetUserID(userID string) { c.mu.Lock() defer c.mu.Unlock() - c.UserID = userID + c.userID = userID } -// GetUserID safely gets the authenticated user ID. +// GetUserID returns the authenticated user ID. func (c *CapturedData) GetUserID() string { c.mu.RLock() defer c.mu.RUnlock() - return c.UserID + return c.userID } -// SetAuthMethod safely sets the authentication method used. +// SetAuthMethod sets the authentication method used. func (c *CapturedData) SetAuthMethod(method string) { c.mu.Lock() defer c.mu.Unlock() - c.AuthMethod = method + c.authMethod = method } -// GetAuthMethod safely gets the authentication method used. +// GetAuthMethod returns the authentication method used. func (c *CapturedData) GetAuthMethod() string { c.mu.RLock() defer c.mu.RUnlock() - return c.AuthMethod + return c.authMethod } -// WithCapturedData adds a CapturedData struct to the context +// WithCapturedData adds a CapturedData struct to the context. func WithCapturedData(ctx context.Context, data *CapturedData) context.Context { return context.WithValue(ctx, capturedDataKey, data) } -// CapturedDataFromContext retrieves the CapturedData from context +// CapturedDataFromContext retrieves the CapturedData from context. func CapturedDataFromContext(ctx context.Context) *CapturedData { v := ctx.Value(capturedDataKey) data, ok := v.(*CapturedData) @@ -161,28 +164,3 @@ func CapturedDataFromContext(ctx context.Context) *CapturedData { } return data } - -func withServiceId(ctx context.Context, serviceId types.ServiceID) context.Context { - return context.WithValue(ctx, serviceIdKey, serviceId) -} - -func ServiceIdFromContext(ctx context.Context) types.ServiceID { - v := ctx.Value(serviceIdKey) - serviceId, ok := v.(types.ServiceID) - if !ok { - return "" - } - return serviceId -} -func withAccountId(ctx context.Context, accountId types.AccountID) context.Context { - return context.WithValue(ctx, accountIdKey, accountId) -} - -func AccountIdFromContext(ctx context.Context) types.AccountID { - v := ctx.Value(accountIdKey) - accountId, ok := v.(types.AccountID) - if !ok { - return "" - } - return accountId -} diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 1ee9b2a42..246851d24 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -66,19 +66,16 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Set the serviceId in the context for later retrieval. - ctx := withServiceId(r.Context(), result.serviceID) - // Set the accountId in the context for later retrieval (for middleware). - ctx = withAccountId(ctx, result.accountID) - // Set the accountId in the context for the roundtripper to use. + ctx := r.Context() + // Set the account ID in the context for the roundtripper to use. ctx = roundtrip.WithAccountID(ctx, result.accountID) - // Also populate captured data if it exists (allows middleware to read after handler completes). + // Populate captured data if it exists (allows middleware to read after handler completes). // This solves the problem of passing data UP the middleware chain: we put a mutable struct // pointer in the context, and mutate the struct here so outer middleware can read it. if capturedData := CapturedDataFromContext(ctx); capturedData != nil { - capturedData.SetServiceId(result.serviceID) - capturedData.SetAccountId(result.accountID) + capturedData.SetServiceID(result.serviceID) + capturedData.SetAccountID(result.accountID) } pt := result.target @@ -96,10 +93,10 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } rp := &httputil.ReverseProxy{ - Rewrite: p.rewriteFunc(pt.URL, rewriteMatchedPath, result.passHostHeader, pt.PathRewrite, pt.CustomHeaders), + Rewrite: p.rewriteFunc(pt.URL, rewriteMatchedPath, result.passHostHeader, pt.PathRewrite, pt.CustomHeaders, result.stripAuthHeaders), Transport: p.transport, FlushInterval: -1, - ErrorHandler: proxyErrorHandler, + ErrorHandler: p.proxyErrorHandler, } if result.rewriteRedirects { rp.ModifyResponse = p.rewriteLocationFunc(pt.URL, rewriteMatchedPath, r) //nolint:bodyclose @@ -113,7 +110,7 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // When passHostHeader is true, the original client Host header is preserved // instead of being rewritten to the backend's address. // The pathRewrite parameter controls how the request path is transformed. -func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHostHeader bool, pathRewrite PathRewriteMode, customHeaders map[string]string) func(r *httputil.ProxyRequest) { +func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHostHeader bool, pathRewrite PathRewriteMode, customHeaders map[string]string, stripAuthHeaders []string) func(r *httputil.ProxyRequest) { return func(r *httputil.ProxyRequest) { switch pathRewrite { case PathRewritePreserve: @@ -137,6 +134,10 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost r.Out.Host = target.Host } + for _, h := range stripAuthHeaders { + r.Out.Header.Del(h) + } + for k, v := range customHeaders { r.Out.Header.Set(k, v) } @@ -305,7 +306,7 @@ func extractForwardedPort(host, resolvedProto string) string { // proxyErrorHandler handles errors from the reverse proxy and serves // user-friendly error pages instead of raw error responses. -func proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) { +func (p *ReverseProxy) proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) { if cd := CapturedDataFromContext(r.Context()); cd != nil { cd.SetOrigin(OriginProxyError) } @@ -313,7 +314,7 @@ func proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) { clientIP := getClientIP(r) title, message, code, status := classifyProxyError(err) - log.Warnf("proxy error: request_id=%s client_ip=%s method=%s host=%s path=%s status=%d title=%q err=%v", + p.logger.Warnf("proxy error: request_id=%s client_ip=%s method=%s host=%s path=%s status=%d title=%q err=%v", requestID, clientIP, r.Method, r.Host, r.URL.Path, code, title, err) web.ServeErrorPage(w, r, code, title, message, requestID, status) diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index b05ead198..c53307837 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -28,7 +28,7 @@ func TestRewriteFunc_HostRewriting(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} t.Run("rewrites host to backend by default", func(t *testing.T) { - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345") rewrite(pr) @@ -37,7 +37,7 @@ func TestRewriteFunc_HostRewriting(t *testing.T) { }) t.Run("preserves original host when passHostHeader is true", func(t *testing.T) { - rewrite := p.rewriteFunc(target, "", true, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", true, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345") rewrite(pr) @@ -52,7 +52,7 @@ func TestRewriteFunc_HostRewriting(t *testing.T) { func TestRewriteFunc_XForwardedForStripping(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) t.Run("sets X-Forwarded-For from direct connection IP", func(t *testing.T) { pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") @@ -89,7 +89,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("sets X-Forwarded-Host to original host", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://myapp.example.com:8443/path", "1.2.3.4:5000") rewrite(pr) @@ -99,7 +99,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("sets X-Forwarded-Port from explicit host port", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com:8443/path", "1.2.3.4:5000") rewrite(pr) @@ -109,7 +109,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("defaults X-Forwarded-Port to 443 for https", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") pr.In.TLS = &tls.ConnectionState{} @@ -120,7 +120,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("defaults X-Forwarded-Port to 80 for http", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") rewrite(pr) @@ -130,7 +130,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("auto detects https from TLS", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") pr.In.TLS = &tls.ConnectionState{} @@ -141,7 +141,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("auto detects http without TLS", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") rewrite(pr) @@ -151,7 +151,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("forced proto overrides TLS detection", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "https"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") // No TLS, but forced to https @@ -162,7 +162,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("forced http proto", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "http"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") pr.In.TLS = &tls.ConnectionState{} @@ -175,7 +175,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { func TestRewriteFunc_SessionCookieStripping(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) t.Run("strips nb_session cookie", func(t *testing.T) { pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") @@ -220,7 +220,7 @@ func TestRewriteFunc_SessionCookieStripping(t *testing.T) { func TestRewriteFunc_SessionTokenQueryStripping(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) t.Run("strips session_token query parameter", func(t *testing.T) { pr := newProxyRequest(t, "http://example.com/callback?session_token=secret123&other=keep", "1.2.3.4:5000") @@ -248,7 +248,7 @@ func TestRewriteFunc_URLRewriting(t *testing.T) { t.Run("rewrites URL to target with path prefix", func(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080/app") - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/somepath", "1.2.3.4:5000") rewrite(pr) @@ -261,7 +261,7 @@ func TestRewriteFunc_URLRewriting(t *testing.T) { t.Run("strips matched path prefix to avoid duplication", func(t *testing.T) { target, _ := url.Parse("https://backend.example.org:443/app") - rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/app", "1.2.3.4:5000") rewrite(pr) @@ -274,7 +274,7 @@ func TestRewriteFunc_URLRewriting(t *testing.T) { t.Run("strips matched prefix and preserves subpath", func(t *testing.T) { target, _ := url.Parse("https://backend.example.org:443/app") - rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/app/article/123", "1.2.3.4:5000") rewrite(pr) @@ -332,7 +332,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("appends to X-Forwarded-For", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") @@ -344,7 +344,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("preserves upstream X-Real-IP", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") @@ -357,7 +357,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("resolves X-Real-IP from XFF when not set by upstream", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.2") @@ -370,7 +370,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("preserves upstream X-Forwarded-Host", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://proxy.internal/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-Host", "original.example.com") @@ -382,7 +382,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("preserves upstream X-Forwarded-Proto", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-Proto", "https") @@ -394,7 +394,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("preserves upstream X-Forwarded-Port", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-Port", "8443") @@ -406,7 +406,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("falls back to local proto when upstream does not set it", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "https", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") @@ -418,7 +418,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("sets X-Forwarded-Host from request when upstream does not set it", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") @@ -429,7 +429,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("untrusted RemoteAddr strips headers even with trusted list", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") pr.In.Header.Set("X-Forwarded-For", "10.0.0.1, 172.16.0.1") @@ -454,7 +454,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("empty trusted list behaves as untrusted", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: nil} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") @@ -467,7 +467,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("XFF starts fresh when trusted proxy has no upstream XFF", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") @@ -490,7 +490,7 @@ func TestRewriteFunc_PathForwarding(t *testing.T) { t.Run("path prefix baked into target URL is a no-op", func(t *testing.T) { // Management builds: path="/heise", target="https://heise.de:443/heise" target, _ := url.Parse("https://heise.de:443/heise") - rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000") rewrite(pr) @@ -501,7 +501,7 @@ func TestRewriteFunc_PathForwarding(t *testing.T) { t.Run("subpath under prefix also preserved", func(t *testing.T) { target, _ := url.Parse("https://heise.de:443/heise") - rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://external.test/heise/article/123", "1.2.3.4:5000") rewrite(pr) @@ -513,7 +513,7 @@ func TestRewriteFunc_PathForwarding(t *testing.T) { // What the behavior WOULD be if target URL had no path (true stripping) t.Run("target without path prefix gives true stripping", func(t *testing.T) { target, _ := url.Parse("https://heise.de:443") - rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000") rewrite(pr) @@ -524,7 +524,7 @@ func TestRewriteFunc_PathForwarding(t *testing.T) { t.Run("target without path prefix strips and preserves subpath", func(t *testing.T) { target, _ := url.Parse("https://heise.de:443") - rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://external.test/heise/article/123", "1.2.3.4:5000") rewrite(pr) @@ -536,7 +536,7 @@ func TestRewriteFunc_PathForwarding(t *testing.T) { // Root path "/" — no stripping expected t.Run("root path forwards full request path unchanged", func(t *testing.T) { target, _ := url.Parse("https://backend.example.com:443/") - rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000") rewrite(pr) @@ -551,7 +551,7 @@ func TestRewriteFunc_PreservePath(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") t.Run("preserve keeps full request path", func(t *testing.T) { - rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, nil) + rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, nil, nil) pr := newProxyRequest(t, "http://example.com/api/users/123", "1.2.3.4:5000") rewrite(pr) @@ -561,7 +561,7 @@ func TestRewriteFunc_PreservePath(t *testing.T) { }) t.Run("preserve with root matchedPath", func(t *testing.T) { - rewrite := p.rewriteFunc(target, "/", false, PathRewritePreserve, nil) + rewrite := p.rewriteFunc(target, "/", false, PathRewritePreserve, nil, nil) pr := newProxyRequest(t, "http://example.com/anything", "1.2.3.4:5000") rewrite(pr) @@ -579,7 +579,7 @@ func TestRewriteFunc_CustomHeaders(t *testing.T) { "X-Custom-Auth": "token-abc", "X-Env": "production", } - rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers) + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, nil) pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") rewrite(pr) @@ -589,7 +589,7 @@ func TestRewriteFunc_CustomHeaders(t *testing.T) { }) t.Run("nil customHeaders is fine", func(t *testing.T) { - rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil) + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, nil) pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") rewrite(pr) @@ -599,7 +599,7 @@ func TestRewriteFunc_CustomHeaders(t *testing.T) { t.Run("custom headers override existing request headers", func(t *testing.T) { headers := map[string]string{"X-Override": "new-value"} - rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers) + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, nil) pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") pr.In.Header.Set("X-Override", "old-value") @@ -609,11 +609,38 @@ func TestRewriteFunc_CustomHeaders(t *testing.T) { }) } +func TestRewriteFunc_StripsAuthorizationHeader(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + target, _ := url.Parse("http://backend.internal:8080") + + t.Run("strips incoming Authorization when no custom Authorization set", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, []string{"Authorization"}) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.Header.Set("Authorization", "Bearer proxy-token") + + rewrite(pr) + + assert.Empty(t, pr.Out.Header.Get("Authorization"), "Authorization should be stripped") + }) + + t.Run("custom Authorization replaces incoming", func(t *testing.T) { + headers := map[string]string{"Authorization": "Basic YmFja2VuZDpzZWNyZXQ="} + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, []string{"Authorization"}) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.Header.Set("Authorization", "Bearer proxy-token") + + rewrite(pr) + + assert.Equal(t, "Basic YmFja2VuZDpzZWNyZXQ=", pr.Out.Header.Get("Authorization"), + "backend Authorization from custom headers should be set") + }) +} + func TestRewriteFunc_PreservePathWithCustomHeaders(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} target, _ := url.Parse("http://backend.internal:8080") - rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, map[string]string{"X-Via": "proxy"}) + rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, map[string]string{"X-Via": "proxy"}, nil) pr := newProxyRequest(t, "http://example.com/api/deep/path", "1.2.3.4:5000") rewrite(pr) diff --git a/proxy/internal/proxy/servicemapping.go b/proxy/internal/proxy/servicemapping.go index 1513fbe45..fe470cf01 100644 --- a/proxy/internal/proxy/servicemapping.go +++ b/proxy/internal/proxy/servicemapping.go @@ -38,6 +38,11 @@ type Mapping struct { Paths map[string]*PathTarget PassHostHeader bool RewriteRedirects bool + // StripAuthHeaders are header names used for header-based auth. + // These headers are stripped from requests before forwarding. + StripAuthHeaders []string + // sortedPaths caches the paths sorted by length (longest first). + sortedPaths []string } type targetResult struct { @@ -47,6 +52,7 @@ type targetResult struct { accountID types.AccountID passHostHeader bool rewriteRedirects bool + stripAuthHeaders []string } func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bool) { @@ -65,16 +71,7 @@ func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bo return targetResult{}, false } - // Sort paths by length (longest first) in a naive attempt to match the most specific route first. - paths := make([]string, 0, len(m.Paths)) - for path := range m.Paths { - paths = append(paths, path) - } - sort.Slice(paths, func(i, j int) bool { - return len(paths[i]) > len(paths[j]) - }) - - for _, path := range paths { + for _, path := range m.sortedPaths { if strings.HasPrefix(req.URL.Path, path) { pt := m.Paths[path] if pt == nil || pt.URL == nil { @@ -89,6 +86,7 @@ func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bo accountID: m.AccountID, passHostHeader: m.PassHostHeader, rewriteRedirects: m.RewriteRedirects, + stripAuthHeaders: m.StripAuthHeaders, }, true } } @@ -96,7 +94,18 @@ func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bo return targetResult{}, false } +// AddMapping registers a host-to-backend mapping for the reverse proxy. func (p *ReverseProxy) AddMapping(m Mapping) { + // Sort paths longest-first to match the most specific route first. + paths := make([]string, 0, len(m.Paths)) + for path := range m.Paths { + paths = append(paths, path) + } + sort.Slice(paths, func(i, j int) bool { + return len(paths[i]) > len(paths[j]) + }) + m.sortedPaths = paths + p.mappingsMux.Lock() defer p.mappingsMux.Unlock() p.mappings[m.Host] = m diff --git a/proxy/internal/restrict/restrict.go b/proxy/internal/restrict/restrict.go new file mode 100644 index 000000000..a0d99ce93 --- /dev/null +++ b/proxy/internal/restrict/restrict.go @@ -0,0 +1,183 @@ +// Package restrict provides connection-level access control based on +// IP CIDR ranges and geolocation (country codes). +package restrict + +import ( + "net/netip" + "slices" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/geolocation" +) + +// GeoResolver resolves an IP address to geographic information. +type GeoResolver interface { + LookupAddr(addr netip.Addr) geolocation.Result + Available() bool +} + +// Filter evaluates IP restrictions. CIDR checks are performed first +// (cheap), followed by country lookups (more expensive) only when needed. +type Filter struct { + AllowedCIDRs []netip.Prefix + BlockedCIDRs []netip.Prefix + AllowedCountries []string + BlockedCountries []string +} + +// ParseFilter builds a Filter from the raw string slices. Returns nil +// if all slices are empty. +func ParseFilter(allowedCIDRs, blockedCIDRs, allowedCountries, blockedCountries []string) *Filter { + if len(allowedCIDRs) == 0 && len(blockedCIDRs) == 0 && + len(allowedCountries) == 0 && len(blockedCountries) == 0 { + return nil + } + + f := &Filter{ + AllowedCountries: normalizeCountryCodes(allowedCountries), + BlockedCountries: normalizeCountryCodes(blockedCountries), + } + for _, cidr := range allowedCIDRs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + log.Warnf("skip invalid allowed CIDR %q: %v", cidr, err) + continue + } + f.AllowedCIDRs = append(f.AllowedCIDRs, prefix.Masked()) + } + for _, cidr := range blockedCIDRs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + log.Warnf("skip invalid blocked CIDR %q: %v", cidr, err) + continue + } + f.BlockedCIDRs = append(f.BlockedCIDRs, prefix.Masked()) + } + return f +} + +func normalizeCountryCodes(codes []string) []string { + if len(codes) == 0 { + return nil + } + out := make([]string, len(codes)) + for i, c := range codes { + out[i] = strings.ToUpper(c) + } + return out +} + +// Verdict is the result of an access check. +type Verdict int + +const ( + // Allow indicates the address passed all checks. + Allow Verdict = iota + // DenyCIDR indicates the address was blocked by a CIDR rule. + DenyCIDR + // DenyCountry indicates the address was blocked by a country rule. + DenyCountry + // DenyGeoUnavailable indicates that country restrictions are configured + // but the geo lookup is unavailable. + DenyGeoUnavailable +) + +// String returns the deny reason string matching the HTTP auth mechanism names. +func (v Verdict) String() string { + switch v { + case Allow: + return "allow" + case DenyCIDR: + return "ip_restricted" + case DenyCountry: + return "country_restricted" + case DenyGeoUnavailable: + return "geo_unavailable" + default: + return "unknown" + } +} + +// Check evaluates whether addr is permitted. CIDR rules are evaluated +// first because they are O(n) prefix comparisons. Country rules run +// only when CIDR checks pass and require a geo lookup. +func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict { + if f == nil { + return Allow + } + + // Normalize v4-mapped-v6 (e.g. ::ffff:10.1.2.3) to plain v4 so that + // IPv4 CIDR rules match regardless of how the address was received. + addr = addr.Unmap() + + if v := f.checkCIDR(addr); v != Allow { + return v + } + return f.checkCountry(addr, geo) +} + +func (f *Filter) checkCIDR(addr netip.Addr) Verdict { + if len(f.AllowedCIDRs) > 0 { + allowed := false + for _, prefix := range f.AllowedCIDRs { + if prefix.Contains(addr) { + allowed = true + break + } + } + if !allowed { + return DenyCIDR + } + } + + for _, prefix := range f.BlockedCIDRs { + if prefix.Contains(addr) { + return DenyCIDR + } + } + return Allow +} + +func (f *Filter) checkCountry(addr netip.Addr, geo GeoResolver) Verdict { + if len(f.AllowedCountries) == 0 && len(f.BlockedCountries) == 0 { + return Allow + } + + if geo == nil || !geo.Available() { + return DenyGeoUnavailable + } + + result := geo.LookupAddr(addr) + if result.CountryCode == "" { + // Unknown country: deny if an allowlist is active, allow otherwise. + // Blocklists are best-effort: unknown countries pass through since + // the default policy is allow. + if len(f.AllowedCountries) > 0 { + return DenyCountry + } + return Allow + } + + if len(f.AllowedCountries) > 0 { + if !slices.Contains(f.AllowedCountries, result.CountryCode) { + return DenyCountry + } + } + + if slices.Contains(f.BlockedCountries, result.CountryCode) { + return DenyCountry + } + + return Allow +} + +// HasRestrictions returns true if any restriction rules are configured. +func (f *Filter) HasRestrictions() bool { + if f == nil { + return false + } + return len(f.AllowedCIDRs) > 0 || len(f.BlockedCIDRs) > 0 || + len(f.AllowedCountries) > 0 || len(f.BlockedCountries) > 0 +} diff --git a/proxy/internal/restrict/restrict_test.go b/proxy/internal/restrict/restrict_test.go new file mode 100644 index 000000000..17a5848d8 --- /dev/null +++ b/proxy/internal/restrict/restrict_test.go @@ -0,0 +1,278 @@ +package restrict + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/proxy/internal/geolocation" +) + +type mockGeo struct { + countries map[string]string +} + +func (m *mockGeo) LookupAddr(addr netip.Addr) geolocation.Result { + return geolocation.Result{CountryCode: m.countries[addr.String()]} +} + +func (m *mockGeo) Available() bool { return true } + +func newMockGeo(entries map[string]string) *mockGeo { + return &mockGeo{countries: entries} +} + +func TestFilter_Check_NilFilter(t *testing.T) { + var f *Filter + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) +} + +func TestFilter_Check_AllowedCIDR(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil)) + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil)) +} + +func TestFilter_Check_BlockedCIDR(t *testing.T) { + f := ParseFilter(nil, []string{"10.0.0.0/8"}, nil, nil) + + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil)) + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("192.168.1.1"), nil)) +} + +func TestFilter_Check_AllowedAndBlockedCIDR(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8"}, []string{"10.1.0.0/16"}, nil, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.2.3.4"), nil), "allowed by allowlist, not in blocklist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "allowed by allowlist but in blocklist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil), "not in allowlist") +} + +func TestFilter_Check_AllowedCountry(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + "2.2.2.2": "DE", + "3.3.3.3": "CN", + }) + f := ParseFilter(nil, nil, []string{"US", "DE"}, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US in allowlist") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "DE in allowlist") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "CN not in allowlist") +} + +func TestFilter_Check_BlockedCountry(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "CN", + "2.2.2.2": "RU", + "3.3.3.3": "US", + }) + f := ParseFilter(nil, nil, nil, []string{"CN", "RU"}) + + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "CN in blocklist") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "RU in blocklist") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "US not in blocklist") +} + +func TestFilter_Check_AllowedAndBlockedCountry(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + "2.2.2.2": "DE", + "3.3.3.3": "CN", + }) + // Allow US and DE, but block DE explicitly. + f := ParseFilter(nil, nil, []string{"US", "DE"}, []string{"DE"}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US allowed and not blocked") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "DE allowed but also blocked, block wins") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "CN not in allowlist") +} + +func TestFilter_Check_UnknownCountryWithAllowlist(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + }) + f := ParseFilter(nil, nil, []string{"US"}, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "known US in allowlist") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("9.9.9.9"), geo), "unknown country denied when allowlist is active") +} + +func TestFilter_Check_UnknownCountryWithBlocklistOnly(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "CN", + }) + f := ParseFilter(nil, nil, nil, []string{"CN"}) + + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "known CN in blocklist") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("9.9.9.9"), geo), "unknown country allowed when only blocklist is active") +} + +func TestFilter_Check_CountryWithoutGeo(t *testing.T) { + f := ParseFilter(nil, nil, []string{"US"}, nil) + assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil), "nil geo with country allowlist") +} + +func TestFilter_Check_CountryBlocklistWithoutGeo(t *testing.T) { + f := ParseFilter(nil, nil, nil, []string{"CN"}) + assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil), "nil geo with country blocklist") +} + +func TestFilter_Check_GeoUnavailable(t *testing.T) { + geo := &unavailableGeo{} + + f := ParseFilter(nil, nil, []string{"US"}, nil) + assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), geo), "unavailable geo with country allowlist") + + f2 := ParseFilter(nil, nil, nil, []string{"CN"}) + assert.Equal(t, DenyGeoUnavailable, f2.Check(netip.MustParseAddr("1.2.3.4"), geo), "unavailable geo with country blocklist") +} + +func TestFilter_Check_CIDROnlySkipsGeo(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + + // CIDR-only filter should never touch geo, so nil geo is fine. + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil)) + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil)) +} + +func TestFilter_Check_CIDRAllowThenCountryBlock(t *testing.T) { + geo := newMockGeo(map[string]string{ + "10.1.2.3": "CN", + "10.2.3.4": "US", + }) + f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, []string{"CN"}) + + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "CIDR allowed but country blocked") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.2.3.4"), geo), "CIDR allowed and country not blocked") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), geo), "CIDR denied before country check") +} + +func TestParseFilter_Empty(t *testing.T) { + f := ParseFilter(nil, nil, nil, nil) + assert.Nil(t, f) +} + +func TestParseFilter_InvalidCIDR(t *testing.T) { + f := ParseFilter([]string{"invalid", "10.0.0.0/8"}, nil, nil, nil) + + assert.NotNil(t, f) + assert.Len(t, f.AllowedCIDRs, 1, "invalid CIDR should be skipped") + assert.Equal(t, netip.MustParsePrefix("10.0.0.0/8"), f.AllowedCIDRs[0]) +} + +func TestFilter_HasRestrictions(t *testing.T) { + assert.False(t, (*Filter)(nil).HasRestrictions()) + assert.False(t, (&Filter{}).HasRestrictions()) + assert.True(t, ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil).HasRestrictions()) + assert.True(t, ParseFilter(nil, nil, []string{"US"}, nil).HasRestrictions()) +} + +func TestFilter_Check_IPv6CIDR(t *testing.T) { + f := ParseFilter([]string{"2001:db8::/32"}, nil, nil, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2001:db8::1"), nil), "v6 addr in v6 allowlist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("2001:db9::1"), nil), "v6 addr not in v6 allowlist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "v4 addr not in v6 allowlist") +} + +func TestFilter_Check_IPv4MappedIPv6(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + + // A v4-mapped-v6 address like ::ffff:10.1.2.3 must match a v4 CIDR. + v4mapped := netip.MustParseAddr("::ffff:10.1.2.3") + assert.True(t, v4mapped.Is4In6(), "precondition: address is v4-in-v6") + assert.Equal(t, Allow, f.Check(v4mapped, nil), "v4-mapped-v6 must match v4 CIDR after Unmap") + + v4mappedOutside := netip.MustParseAddr("::ffff:192.168.1.1") + assert.Equal(t, DenyCIDR, f.Check(v4mappedOutside, nil), "v4-mapped-v6 outside v4 CIDR") +} + +func TestFilter_Check_MixedV4V6CIDRs(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8", "2001:db8::/32"}, nil, nil, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "v4 in v4 CIDR") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2001:db8::1"), nil), "v6 in v6 CIDR") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("::ffff:10.1.2.3"), nil), "v4-mapped matches v4 CIDR") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil), "v4 not in either CIDR") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("fe80::1"), nil), "v6 not in either CIDR") +} + +func TestParseFilter_CanonicalizesNonMaskedCIDR(t *testing.T) { + // 1.1.1.1/24 has host bits set; ParseFilter should canonicalize to 1.1.1.0/24. + f := ParseFilter([]string{"1.1.1.1/24"}, nil, nil, nil) + assert.Equal(t, netip.MustParsePrefix("1.1.1.0/24"), f.AllowedCIDRs[0]) + + // Verify it still matches correctly. + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.100"), nil)) + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("1.1.2.1"), nil)) +} + +func TestFilter_Check_CountryCodeCaseInsensitive(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + "2.2.2.2": "DE", + "3.3.3.3": "CN", + }) + + tests := []struct { + name string + allowedCountries []string + blockedCountries []string + addr string + want Verdict + }{ + { + name: "lowercase allowlist matches uppercase MaxMind code", + allowedCountries: []string{"us", "de"}, + addr: "1.1.1.1", + want: Allow, + }, + { + name: "mixed-case allowlist matches", + allowedCountries: []string{"Us", "dE"}, + addr: "2.2.2.2", + want: Allow, + }, + { + name: "lowercase allowlist rejects non-matching country", + allowedCountries: []string{"us", "de"}, + addr: "3.3.3.3", + want: DenyCountry, + }, + { + name: "lowercase blocklist blocks matching country", + blockedCountries: []string{"cn"}, + addr: "3.3.3.3", + want: DenyCountry, + }, + { + name: "mixed-case blocklist blocks matching country", + blockedCountries: []string{"Cn"}, + addr: "3.3.3.3", + want: DenyCountry, + }, + { + name: "lowercase blocklist does not block non-matching country", + blockedCountries: []string{"cn"}, + addr: "1.1.1.1", + want: Allow, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := ParseFilter(nil, nil, tc.allowedCountries, tc.blockedCountries) + got := f.Check(netip.MustParseAddr(tc.addr), geo) + assert.Equal(t, tc.want, got) + }) + } +} + +// unavailableGeo simulates a GeoResolver whose database is not loaded. +type unavailableGeo struct{} + +func (u *unavailableGeo) LookupAddr(_ netip.Addr) geolocation.Result { return geolocation.Result{} } +func (u *unavailableGeo) Available() bool { return false } diff --git a/proxy/internal/tcp/router.go b/proxy/internal/tcp/router.go index 84fde0731..8255c36d3 100644 --- a/proxy/internal/tcp/router.go +++ b/proxy/internal/tcp/router.go @@ -7,12 +7,14 @@ import ( "net" "net/netip" "slices" + "strings" "sync" "time" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/proxy/internal/accesslog" + "github.com/netbirdio/netbird/proxy/internal/restrict" "github.com/netbirdio/netbird/proxy/internal/types" ) @@ -20,6 +22,10 @@ import ( // timeout is configured. const defaultDialTimeout = 30 * time.Second +// errAccessRestricted is returned by relayTCP for access restriction +// denials so callers can skip warn-level logging (already logged at debug). +var errAccessRestricted = errors.New("rejected by access restrictions") + // SNIHost is a typed key for SNI hostname lookups. type SNIHost string @@ -64,6 +70,11 @@ type Route struct { // DialTimeout overrides the default dial timeout for this route. // Zero uses defaultDialTimeout. DialTimeout time.Duration + // SessionIdleTimeout overrides the default idle timeout for relay connections. + // Zero uses DefaultIdleTimeout. + SessionIdleTimeout time.Duration + // Filter holds connection-level IP/geo restrictions. Nil means no restrictions. + Filter *restrict.Filter } // l4Logger sends layer-4 access log entries to the management server. @@ -99,6 +110,7 @@ type Router struct { drainDone chan struct{} observer RelayObserver accessLog l4Logger + geo restrict.GeoResolver // svcCtxs tracks a context per service ID. All relay goroutines for a // service derive from its context; canceling it kills them immediately. svcCtxs map[types.ServiceID]context.Context @@ -144,6 +156,7 @@ func (r *Router) HTTPListener() net.Listener { // stored and resolved by priority at lookup time (HTTP > TCP). // Empty host is ignored to prevent conflicts with ECH/ESNI fallback. func (r *Router) AddRoute(host SNIHost, route Route) { + host = SNIHost(strings.ToLower(string(host))) if host == "" { return } @@ -166,6 +179,8 @@ func (r *Router) AddRoute(host SNIHost, route Route) { // Active relay connections for the service are closed immediately. // If other routes remain for the host, they are preserved. func (r *Router) RemoveRoute(host SNIHost, svcID types.ServiceID) { + host = SNIHost(strings.ToLower(string(host))) + r.mu.Lock() defer r.mu.Unlock() @@ -295,7 +310,7 @@ func (r *Router) handleConn(ctx context.Context, conn net.Conn) { return } - host := SNIHost(sni) + host := SNIHost(strings.ToLower(sni)) route, ok := r.lookupRoute(host) if !ok { r.handleUnmatched(ctx, wrapped) @@ -308,11 +323,13 @@ func (r *Router) handleConn(ctx context.Context, conn net.Conn) { } if err := r.relayTCP(ctx, wrapped, host, route); err != nil { - r.logger.WithFields(log.Fields{ - "sni": host, - "service_id": route.ServiceID, - "target": route.Target, - }).Warnf("TCP relay: %v", err) + if !errors.Is(err, errAccessRestricted) { + r.logger.WithFields(log.Fields{ + "sni": host, + "service_id": route.ServiceID, + "target": route.Target, + }).Warnf("TCP relay: %v", err) + } _ = wrapped.Close() } } @@ -336,10 +353,12 @@ func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn) { if fb != nil { if err := r.relayTCP(ctx, conn, SNIHost("fallback"), *fb); err != nil { - r.logger.WithFields(log.Fields{ - "service_id": fb.ServiceID, - "target": fb.Target, - }).Warnf("TCP relay (fallback): %v", err) + if !errors.Is(err, errAccessRestricted) { + r.logger.WithFields(log.Fields{ + "service_id": fb.ServiceID, + "target": fb.Target, + }).Warnf("TCP relay (fallback): %v", err) + } _ = conn.Close() } return @@ -427,10 +446,44 @@ func (r *Router) cancelServiceLocked(svcID types.ServiceID) { } } +// SetGeo sets the geolocation lookup used for country-based restrictions. +func (r *Router) SetGeo(geo restrict.GeoResolver) { + r.mu.Lock() + defer r.mu.Unlock() + r.geo = geo +} + +// checkRestrictions evaluates the route's access filter against the +// connection's remote address. Returns Allow if the connection is +// permitted, or a deny verdict indicating the reason. +func (r *Router) checkRestrictions(conn net.Conn, route Route) restrict.Verdict { + if route.Filter == nil { + return restrict.Allow + } + + addr, err := addrFromConn(conn) + if err != nil { + r.logger.Debugf("cannot parse client address %s for restriction check, denying", conn.RemoteAddr()) + return restrict.DenyCIDR + } + + r.mu.RLock() + geo := r.geo + r.mu.RUnlock() + + return route.Filter.Check(addr, geo) +} + // relayTCP sets up and runs a bidirectional TCP relay. // The caller owns conn and must close it if this method returns an error. // On success (nil error), both conn and backend are closed by the relay. func (r *Router) relayTCP(ctx context.Context, conn net.Conn, sni SNIHost, route Route) error { + if verdict := r.checkRestrictions(conn, route); verdict != restrict.Allow { + r.logger.Debugf("connection from %s rejected by access restrictions: %s", conn.RemoteAddr(), verdict) + r.logL4Deny(route, conn, verdict) + return errAccessRestricted + } + svcCtx, err := r.acquireRelay(ctx, route) if err != nil { return err @@ -468,8 +521,13 @@ func (r *Router) relayTCP(ctx context.Context, conn net.Conn, sni SNIHost, route }) entry.Debug("TCP relay started") + idleTimeout := route.SessionIdleTimeout + if idleTimeout <= 0 { + idleTimeout = DefaultIdleTimeout + } + start := time.Now() - s2d, d2s := Relay(svcCtx, entry, conn, backend, DefaultIdleTimeout) + s2d, d2s := Relay(svcCtx, entry, conn, backend, idleTimeout) elapsed := time.Since(start) if obs != nil { @@ -537,12 +595,7 @@ func (r *Router) logL4Entry(route Route, conn net.Conn, duration time.Duration, return } - var sourceIP netip.Addr - if remote := conn.RemoteAddr(); remote != nil { - if ap, err := netip.ParseAddrPort(remote.String()); err == nil { - sourceIP = ap.Addr().Unmap() - } - } + sourceIP, _ := addrFromConn(conn) al.LogL4(accesslog.L4Entry{ AccountID: route.AccountID, @@ -556,6 +609,28 @@ func (r *Router) logL4Entry(route Route, conn net.Conn, duration time.Duration, }) } +// logL4Deny sends an access log entry for a denied connection. +func (r *Router) logL4Deny(route Route, conn net.Conn, verdict restrict.Verdict) { + r.mu.RLock() + al := r.accessLog + r.mu.RUnlock() + + if al == nil { + return + } + + sourceIP, _ := addrFromConn(conn) + + al.LogL4(accesslog.L4Entry{ + AccountID: route.AccountID, + ServiceID: route.ServiceID, + Protocol: route.Protocol, + Host: route.Domain, + SourceIP: sourceIP, + DenyReason: verdict.String(), + }) +} + // getOrCreateServiceCtxLocked returns the context for a service, creating one // if it doesn't exist yet. The context is a child of the server context. // Must be called with mu held. @@ -568,3 +643,16 @@ func (r *Router) getOrCreateServiceCtxLocked(parent context.Context, svcID types r.svcCancels[svcID] = cancel return ctx } + +// addrFromConn extracts a netip.Addr from a connection's remote address. +func addrFromConn(conn net.Conn) (netip.Addr, error) { + remote := conn.RemoteAddr() + if remote == nil { + return netip.Addr{}, errors.New("no remote address") + } + ap, err := netip.ParseAddrPort(remote.String()) + if err != nil { + return netip.Addr{}, err + } + return ap.Addr().Unmap(), nil +} diff --git a/proxy/internal/tcp/router_test.go b/proxy/internal/tcp/router_test.go index 0e2cfe3e1..189cdc622 100644 --- a/proxy/internal/tcp/router_test.go +++ b/proxy/internal/tcp/router_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/proxy/internal/restrict" "github.com/netbirdio/netbird/proxy/internal/types" ) @@ -1668,3 +1669,73 @@ func startEchoPlain(t *testing.T) net.Listener { return ln } + +// fakeAddr implements net.Addr with a custom string representation. +type fakeAddr string + +func (f fakeAddr) Network() string { return "tcp" } +func (f fakeAddr) String() string { return string(f) } + +// fakeConn is a minimal net.Conn with a controllable RemoteAddr. +type fakeConn struct { + net.Conn + remote net.Addr +} + +func (f *fakeConn) RemoteAddr() net.Addr { return f.remote } + +func TestCheckRestrictions_UnparseableAddress(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + route := Route{Filter: filter} + + conn := &fakeConn{remote: fakeAddr("not-an-ip")} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(conn, route), "unparsable address must be denied") +} + +func TestCheckRestrictions_NilRemoteAddr(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + route := Route{Filter: filter} + + conn := &fakeConn{remote: nil} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(conn, route), "nil remote address must be denied") +} + +func TestCheckRestrictions_AllowedAndDenied(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + route := Route{Filter: filter} + + allowed := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 1234}} + assert.Equal(t, restrict.Allow, router.checkRestrictions(allowed, route), "10.1.2.3 in allowlist") + + denied := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(192, 168, 1, 1), Port: 1234}} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(denied, route), "192.168.1.1 not in allowlist") +} + +func TestCheckRestrictions_NilFilter(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + route := Route{Filter: nil} + + conn := &fakeConn{remote: fakeAddr("not-an-ip")} + assert.Equal(t, restrict.Allow, router.checkRestrictions(conn, route), "nil filter should allow everything") +} + +func TestCheckRestrictions_IPv4MappedIPv6(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + route := Route{Filter: filter} + + // net.IPv4() returns a 16-byte v4-in-v6 representation internally. + // The restriction check must Unmap it to match the v4 CIDR. + conn := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 5678}} + assert.Equal(t, restrict.Allow, router.checkRestrictions(conn, route), "v4-in-v6 TCPAddr must match v4 CIDR") + + // Explicitly v4-mapped-v6 address string. + conn6 := &fakeConn{remote: fakeAddr("[::ffff:10.1.2.3]:5678")} + assert.Equal(t, restrict.Allow, router.checkRestrictions(conn6, route), "::ffff:10.1.2.3 must match v4 CIDR") + + connOutside := &fakeConn{remote: fakeAddr("[::ffff:192.168.1.1]:5678")} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(connOutside, route), "::ffff:192.168.1.1 not in v4 CIDR") +} diff --git a/proxy/internal/udp/relay.go b/proxy/internal/udp/relay.go index f2f58e858..d20ecf48b 100644 --- a/proxy/internal/udp/relay.go +++ b/proxy/internal/udp/relay.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/proxy/internal/accesslog" "github.com/netbirdio/netbird/proxy/internal/netutil" + "github.com/netbirdio/netbird/proxy/internal/restrict" "github.com/netbirdio/netbird/proxy/internal/types" ) @@ -67,6 +68,8 @@ type Relay struct { dialTimeout time.Duration sessionTTL time.Duration maxSessions int + filter *restrict.Filter + geo restrict.GeoResolver mu sync.RWMutex sessions map[clientAddr]*session @@ -114,6 +117,10 @@ type RelayConfig struct { SessionTTL time.Duration MaxSessions int AccessLog l4Logger + // Filter holds connection-level IP/geo restrictions. Nil means no restrictions. + Filter *restrict.Filter + // Geo is the geolocation lookup used for country-based restrictions. + Geo restrict.GeoResolver } // New creates a UDP relay for the given listener and backend target. @@ -146,6 +153,8 @@ func New(parentCtx context.Context, cfg RelayConfig) *Relay { dialTimeout: dialTimeout, sessionTTL: sessionTTL, maxSessions: maxSessions, + filter: cfg.Filter, + geo: cfg.Geo, sessions: make(map[clientAddr]*session), bufPool: sync.Pool{ New: func() any { @@ -166,9 +175,18 @@ func (r *Relay) ServiceID() types.ServiceID { // SetObserver sets the session lifecycle observer. Must be called before Serve. func (r *Relay) SetObserver(obs SessionObserver) { + r.mu.Lock() + defer r.mu.Unlock() r.observer = obs } +// getObserver returns the current session lifecycle observer. +func (r *Relay) getObserver() SessionObserver { + r.mu.RLock() + defer r.mu.RUnlock() + return r.observer +} + // Serve starts the relay loop. It blocks until the context is canceled // or the listener is closed. func (r *Relay) Serve() { @@ -209,8 +227,8 @@ func (r *Relay) Serve() { } sess.bytesIn.Add(int64(nw)) - if r.observer != nil { - r.observer.UDPPacketRelayed(types.RelayDirectionClientToBackend, nw) + if obs := r.getObserver(); obs != nil { + obs.UDPPacketRelayed(types.RelayDirectionClientToBackend, nw) } r.bufPool.Put(bufp) } @@ -234,6 +252,10 @@ func (r *Relay) getOrCreateSession(addr net.Addr) (*session, error) { return nil, r.ctx.Err() } + if err := r.checkAccessRestrictions(addr); err != nil { + return nil, err + } + r.mu.Lock() if sess, ok = r.sessions[key]; ok && sess != nil { @@ -248,16 +270,16 @@ func (r *Relay) getOrCreateSession(addr net.Addr) (*session, error) { if len(r.sessions) >= r.maxSessions { r.mu.Unlock() - if r.observer != nil { - r.observer.UDPSessionRejected(r.accountID) + if obs := r.getObserver(); obs != nil { + obs.UDPSessionRejected(r.accountID) } return nil, fmt.Errorf("session limit reached (%d)", r.maxSessions) } if !r.sessLimiter.Allow() { r.mu.Unlock() - if r.observer != nil { - r.observer.UDPSessionRejected(r.accountID) + if obs := r.getObserver(); obs != nil { + obs.UDPSessionRejected(r.accountID) } return nil, fmt.Errorf("session creation rate limited") } @@ -274,8 +296,8 @@ func (r *Relay) getOrCreateSession(addr net.Addr) (*session, error) { r.mu.Lock() delete(r.sessions, key) r.mu.Unlock() - if r.observer != nil { - r.observer.UDPSessionDialError(r.accountID) + if obs := r.getObserver(); obs != nil { + obs.UDPSessionDialError(r.accountID) } return nil, fmt.Errorf("dial backend %s: %w", r.target, err) } @@ -293,8 +315,8 @@ func (r *Relay) getOrCreateSession(addr net.Addr) (*session, error) { r.sessions[key] = sess r.mu.Unlock() - if r.observer != nil { - r.observer.UDPSessionStarted(r.accountID) + if obs := r.getObserver(); obs != nil { + obs.UDPSessionStarted(r.accountID) } r.sessWg.Go(func() { @@ -305,6 +327,21 @@ func (r *Relay) getOrCreateSession(addr net.Addr) (*session, error) { return sess, nil } +func (r *Relay) checkAccessRestrictions(addr net.Addr) error { + if r.filter == nil { + return nil + } + clientIP, err := addrFromUDPAddr(addr) + if err != nil { + return fmt.Errorf("parse client address %s for restriction check: %w", addr, err) + } + if v := r.filter.Check(clientIP, r.geo); v != restrict.Allow { + r.logDeny(clientIP, v) + return fmt.Errorf("access restricted for %s", addr) + } + return nil +} + // relayBackendToClient reads packets from the backend and writes them // back to the client through the public-facing listener. func (r *Relay) relayBackendToClient(ctx context.Context, sess *session) { @@ -332,8 +369,8 @@ func (r *Relay) relayBackendToClient(ctx context.Context, sess *session) { } sess.bytesOut.Add(int64(nw)) - if r.observer != nil { - r.observer.UDPPacketRelayed(types.RelayDirectionBackendToClient, nw) + if obs := r.getObserver(); obs != nil { + obs.UDPPacketRelayed(types.RelayDirectionBackendToClient, nw) } } } @@ -402,9 +439,10 @@ func (r *Relay) cleanupIdleSessions() { } r.mu.Unlock() + obs := r.getObserver() for _, sess := range expired { - if r.observer != nil { - r.observer.UDPSessionEnded(r.accountID) + if obs != nil { + obs.UDPSessionEnded(r.accountID) } r.logSessionEnd(sess) } @@ -429,8 +467,8 @@ func (r *Relay) removeSession(sess *session) { if removed { r.logger.Debugf("UDP session %s ended (client→backend: %d bytes, backend→client: %d bytes)", sess.addr, sess.bytesIn.Load(), sess.bytesOut.Load()) - if r.observer != nil { - r.observer.UDPSessionEnded(r.accountID) + if obs := r.getObserver(); obs != nil { + obs.UDPSessionEnded(r.accountID) } r.logSessionEnd(sess) } @@ -459,6 +497,22 @@ func (r *Relay) logSessionEnd(sess *session) { }) } +// logDeny sends an access log entry for a denied UDP packet. +func (r *Relay) logDeny(clientIP netip.Addr, verdict restrict.Verdict) { + if r.accessLog == nil { + return + } + + r.accessLog.LogL4(accesslog.L4Entry{ + AccountID: r.accountID, + ServiceID: r.serviceID, + Protocol: accesslog.ProtocolUDP, + Host: r.domain, + SourceIP: clientIP, + DenyReason: verdict.String(), + }) +} + // Close stops the relay, waits for all session goroutines to exit, // and cleans up remaining sessions. func (r *Relay) Close() { @@ -485,12 +539,22 @@ func (r *Relay) Close() { } r.mu.Unlock() + obs := r.getObserver() for _, sess := range closedSessions { - if r.observer != nil { - r.observer.UDPSessionEnded(r.accountID) + if obs != nil { + obs.UDPSessionEnded(r.accountID) } r.logSessionEnd(sess) } r.sessWg.Wait() } + +// addrFromUDPAddr extracts a netip.Addr from a net.Addr. +func addrFromUDPAddr(addr net.Addr) (netip.Addr, error) { + ap, err := netip.ParseAddrPort(addr.String()) + if err != nil { + return netip.Addr{}, err + } + return ap.Addr().Unmap(), nil +} diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index ebecfc6f6..796cad622 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -200,7 +200,7 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, // testProxyManager is a mock implementation of proxy.Manager for testing. type testProxyManager struct{} -func (m *testProxyManager) Connect(_ context.Context, _, _, _ string) error { +func (m *testProxyManager) Connect(_ context.Context, _, _, _ string, _ *nbproxy.Capabilities) error { return nil } @@ -208,7 +208,7 @@ func (m *testProxyManager) Disconnect(_ context.Context, _ string) error { return nil } -func (m *testProxyManager) Heartbeat(_ context.Context, _ string) error { +func (m *testProxyManager) Heartbeat(_ context.Context, _, _, _ string) error { return nil } @@ -216,6 +216,18 @@ func (m *testProxyManager) GetActiveClusterAddresses(_ context.Context) ([]strin return nil, nil } +func (m *testProxyManager) GetActiveClusters(_ context.Context) ([]nbproxy.Cluster, error) { + return nil, nil +} + +func (m *testProxyManager) ClusterSupportsCustomPorts(_ context.Context, _ string) *bool { + return nil +} + +func (m *testProxyManager) ClusterRequireSubdomain(_ context.Context, _ string) *bool { + return nil +} + func (m *testProxyManager) CleanupStale(_ context.Context, _ time.Duration) error { return nil } @@ -243,10 +255,6 @@ func (c *testProxyController) GetProxiesForCluster(_ string) []string { return nil } -func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool { - return nil -} - // storeBackedServiceManager reads directly from the real store. type storeBackedServiceManager struct { store store.Store @@ -323,6 +331,10 @@ func (m *storeBackedServiceManager) StopServiceFromPeer(_ context.Context, _, _, func (m *storeBackedServiceManager) StartExposeReaper(_ context.Context) {} +func (m *storeBackedServiceManager) GetActiveClusters(_ context.Context, _, _ string) ([]nbproxy.Cluster, error) { + return nil, nil +} + func strPtr(s string) *string { return &s } @@ -490,7 +502,7 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T logger := log.New() logger.SetLevel(log.WarnLevel) - authMw := auth.NewMiddleware(logger, nil) + authMw := auth.NewMiddleware(logger, nil, nil) proxyHandler := proxy.NewReverseProxy(nil, "auto", nil, logger) clusterAddress := "test.proxy.io" @@ -511,6 +523,7 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T 0, proxytypes.AccountID(mapping.GetAccountId()), proxytypes.ServiceID(mapping.GetId()), + nil, ) require.NoError(t, err) diff --git a/proxy/server.go b/proxy/server.go index 649d49c9a..acfe3c12d 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -43,12 +43,14 @@ import ( "github.com/netbirdio/netbird/proxy/internal/certwatch" "github.com/netbirdio/netbird/proxy/internal/conntrack" "github.com/netbirdio/netbird/proxy/internal/debug" + "github.com/netbirdio/netbird/proxy/internal/geolocation" proxygrpc "github.com/netbirdio/netbird/proxy/internal/grpc" "github.com/netbirdio/netbird/proxy/internal/health" "github.com/netbirdio/netbird/proxy/internal/k8s" proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics" "github.com/netbirdio/netbird/proxy/internal/netutil" "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" "github.com/netbirdio/netbird/proxy/internal/roundtrip" nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp" "github.com/netbirdio/netbird/proxy/internal/types" @@ -59,7 +61,6 @@ import ( "github.com/netbirdio/netbird/util/embeddedroots" ) - // portRouter bundles a per-port Router with its listener and cancel func. type portRouter struct { router *nbtcp.Router @@ -95,6 +96,9 @@ type Server struct { // so they can be closed during graceful shutdown, since http.Server.Shutdown // does not handle them. hijackTracker conntrack.HijackTracker + // geo resolves IP addresses to country/city for access restrictions and access logs. + geo restrict.GeoResolver + geoRaw *geolocation.Lookup // routerReady is closed once mainRouter is fully initialized. // The mapping worker waits on this before processing updates. @@ -159,10 +163,42 @@ type Server struct { // SupportsCustomPorts indicates whether the proxy can bind arbitrary // ports for TCP/UDP/TLS services. SupportsCustomPorts bool - // DefaultDialTimeout is the default timeout for establishing backend - // connections when no per-service timeout is configured. Zero means - // each transport uses its own hardcoded default (typically 30s). - DefaultDialTimeout time.Duration + // RequireSubdomain indicates whether a subdomain label is required + // in front of this proxy's cluster domain. When true, accounts cannot + // create services on the bare cluster domain. + RequireSubdomain bool + // MaxDialTimeout caps the per-service backend dial timeout. + // When the API sends a timeout, it is clamped to this value. + // When the API sends no timeout, this value is used as the default. + // Zero means no cap (the proxy honors whatever management sends). + MaxDialTimeout time.Duration + // GeoDataDir is the directory containing GeoLite2 MMDB files for + // country-based access restrictions. Empty disables geo lookups. + GeoDataDir string + // MaxSessionIdleTimeout caps the per-service session idle timeout. + // Zero means no cap (the proxy honors whatever management sends). + // Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments. + MaxSessionIdleTimeout time.Duration +} + +// clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured. +func (s *Server) clampIdleTimeout(d time.Duration) time.Duration { + if s.MaxSessionIdleTimeout > 0 && d > s.MaxSessionIdleTimeout { + return s.MaxSessionIdleTimeout + } + return d +} + +// clampDialTimeout returns d capped to MaxDialTimeout when configured. +// If d is zero, MaxDialTimeout is used as the default. +func (s *Server) clampDialTimeout(d time.Duration) time.Duration { + if s.MaxDialTimeout <= 0 { + return d + } + if d <= 0 || d > s.MaxDialTimeout { + return s.MaxDialTimeout + } + return d } // NotifyStatus sends a status update to management about tunnel connectivity. @@ -226,7 +262,6 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) { s.mgmtClient = proto.NewProxyServiceClient(mgmtConn) runCtx, runCancel := context.WithCancel(ctx) defer runCancel() - go s.newManagementMappingWorker(runCtx, s.mgmtClient) // Initialize the netbird client, this is required to build peer connections // to proxy over. @@ -236,6 +271,12 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) { PreSharedKey: s.PreSharedKey, }, s.Logger, s, s.mgmtClient) + // Create health checker before the mapping worker so it can track + // management connectivity from the first stream connection. + s.healthChecker = health.NewChecker(s.Logger, s.netbird) + + go s.newManagementMappingWorker(runCtx, s.mgmtClient) + tlsConfig, err := s.configureTLS(ctx) if err != nil { return err @@ -244,14 +285,33 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) { // Configure the reverse proxy using NetBird's HTTP Client Transport for proxying. s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(s.netbird), s.ForwardedProto, s.TrustedProxies, s.Logger) + geoLookup, err := geolocation.NewLookup(s.Logger, s.GeoDataDir) + if err != nil { + return fmt.Errorf("initialize geolocation: %w", err) + } + s.geoRaw = geoLookup + if geoLookup != nil { + s.geo = geoLookup + } + + var startupOK bool + defer func() { + if startupOK { + return + } + if s.geoRaw != nil { + if err := s.geoRaw.Close(); err != nil { + s.Logger.Debugf("close geolocation on startup failure: %v", err) + } + } + }() + // Configure the authentication middleware with session validator for OIDC group checks. - s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient) + s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo) // Configure Access logs to management server. s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies) - s.healthChecker = health.NewChecker(s.Logger, s.netbird) - s.startDebugEndpoint() if err := s.startHealthServer(); err != nil { @@ -294,6 +354,8 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) { ErrorLog: newHTTPServerLogger(s.Logger, logtagValueHTTPS), } + startupOK = true + httpsErr := make(chan error, 1) go func() { s.Logger.Debug("starting HTTPS server on SNI router HTTP channel") @@ -691,6 +753,16 @@ func (s *Server) shutdownServices() { s.portRouterWg.Wait() wg.Wait() + + if s.accessLog != nil { + s.accessLog.Close() + } + + if s.geoRaw != nil { + if err := s.geoRaw.Close(); err != nil { + s.Logger.Debugf("close geolocation: %v", err) + } + } } // resolveDialFunc returns a DialContextFunc that dials through the @@ -851,6 +923,7 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr Address: s.ProxyURL, Capabilities: &proto.ProxyCapabilities{ SupportsCustomPorts: &s.SupportsCustomPorts, + RequireSubdomain: &s.RequireSubdomain, }, }) if err != nil { @@ -1073,15 +1146,20 @@ func (s *Server) setupTCPMapping(ctx context.Context, mapping *proto.ProxyMappin return fmt.Errorf("router for TCP port %d: %w", port, err) } + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + router.SetGeo(s.geo) router.SetFallback(nbtcp.Route{ - Type: nbtcp.RouteTCP, - AccountID: accountID, - ServiceID: svcID, - Domain: mapping.GetDomain(), - Protocol: accesslog.ProtocolTCP, - Target: targetAddr, - ProxyProtocol: s.l4ProxyProtocol(mapping), - DialTimeout: s.l4DialTimeout(mapping), + Type: nbtcp.RouteTCP, + AccountID: accountID, + ServiceID: svcID, + Domain: mapping.GetDomain(), + Protocol: accesslog.ProtocolTCP, + Target: targetAddr, + ProxyProtocol: s.l4ProxyProtocol(mapping), + DialTimeout: s.l4DialTimeout(mapping), + SessionIdleTimeout: s.clampIdleTimeout(l4SessionIdleTimeout(mapping)), + Filter: parseRestrictions(mapping), }) s.portMu.Lock() @@ -1108,6 +1186,8 @@ func (s *Server) setupUDPMapping(ctx context.Context, mapping *proto.ProxyMappin return fmt.Errorf("empty target address for UDP service %s", svcID) } + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + if err := s.addUDPRelay(ctx, mapping, targetAddr, port); err != nil { return fmt.Errorf("UDP relay for service %s: %w", svcID, err) } @@ -1141,15 +1221,20 @@ func (s *Server) setupTLSMapping(ctx context.Context, mapping *proto.ProxyMappin return fmt.Errorf("router for TLS port %d: %w", tlsPort, err) } + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + router.SetGeo(s.geo) router.AddRoute(nbtcp.SNIHost(mapping.GetDomain()), nbtcp.Route{ - Type: nbtcp.RouteTCP, - AccountID: accountID, - ServiceID: svcID, - Domain: mapping.GetDomain(), - Protocol: accesslog.ProtocolTLS, - Target: targetAddr, - ProxyProtocol: s.l4ProxyProtocol(mapping), - DialTimeout: s.l4DialTimeout(mapping), + Type: nbtcp.RouteTCP, + AccountID: accountID, + ServiceID: svcID, + Domain: mapping.GetDomain(), + Protocol: accesslog.ProtocolTLS, + Target: targetAddr, + ProxyProtocol: s.l4ProxyProtocol(mapping), + DialTimeout: s.l4DialTimeout(mapping), + SessionIdleTimeout: s.clampIdleTimeout(l4SessionIdleTimeout(mapping)), + Filter: parseRestrictions(mapping), }) if tlsPort != s.mainPort { @@ -1181,6 +1266,32 @@ func (s *Server) serviceKeyForMapping(mapping *proto.ProxyMapping) roundtrip.Ser } } +// parseRestrictions converts a proto mapping's access restrictions into +// a restrict.Filter. Returns nil if the mapping has no restrictions. +func parseRestrictions(mapping *proto.ProxyMapping) *restrict.Filter { + r := mapping.GetAccessRestrictions() + if r == nil { + return nil + } + return restrict.ParseFilter(r.GetAllowedCidrs(), r.GetBlockedCidrs(), r.GetAllowedCountries(), r.GetBlockedCountries()) +} + +// warnIfGeoUnavailable logs a warning if the mapping has country restrictions +// but the proxy has no geolocation database loaded. All requests to this +// service will be denied at runtime (fail-close). +func (s *Server) warnIfGeoUnavailable(domain string, r *proto.AccessRestrictions) { + if r == nil { + return + } + if len(r.GetAllowedCountries()) == 0 && len(r.GetBlockedCountries()) == 0 { + return + } + if s.geo != nil && s.geo.Available() { + return + } + s.Logger.Warnf("service %s has country restrictions but no geolocation database is loaded: all requests will be denied", domain) +} + // l4TargetAddress extracts and validates the target address from a mapping's // first path entry. Returns empty string if no paths exist or the address is // not a valid host:port. @@ -1210,15 +1321,15 @@ func (s *Server) l4ProxyProtocol(mapping *proto.ProxyMapping) bool { } // l4DialTimeout returns the dial timeout from the first target's options, -// falling back to the server's DefaultDialTimeout. +// clamped to MaxDialTimeout. func (s *Server) l4DialTimeout(mapping *proto.ProxyMapping) time.Duration { paths := mapping.GetPath() if len(paths) > 0 { if d := paths[0].GetOptions().GetRequestTimeout(); d != nil { - return d.AsDuration() + return s.clampDialTimeout(d.AsDuration()) } } - return s.DefaultDialTimeout + return s.clampDialTimeout(0) } // l4SessionIdleTimeout returns the configured session idle timeout from the @@ -1254,7 +1365,9 @@ func (s *Server) addUDPRelay(ctx context.Context, mapping *proto.ProxyMapping, t dialFn, err := s.resolveDialFunc(accountID) if err != nil { - _ = listener.Close() + if err := listener.Close(); err != nil { + s.Logger.Debugf("close UDP listener on %s: %v", listenAddr, err) + } return fmt.Errorf("resolve dialer for UDP: %w", err) } @@ -1273,8 +1386,10 @@ func (s *Server) addUDPRelay(ctx context.Context, mapping *proto.ProxyMapping, t ServiceID: svcID, DialFunc: dialFn, DialTimeout: s.l4DialTimeout(mapping), - SessionTTL: l4SessionIdleTimeout(mapping), + SessionTTL: s.clampIdleTimeout(l4SessionIdleTimeout(mapping)), AccessLog: s.accessLog, + Filter: parseRestrictions(mapping), + Geo: s.geo, }) relay.SetObserver(s.meter) @@ -1306,9 +1421,15 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) if mapping.GetAuth().GetOidc() { schemes = append(schemes, auth.NewOIDC(s.mgmtClient, svcID, accountID, s.ForwardedProto)) } + for _, ha := range mapping.GetAuth().GetHeaderAuths() { + schemes = append(schemes, auth.NewHeader(s.mgmtClient, svcID, accountID, ha.GetHeader())) + } + + ipRestrictions := parseRestrictions(mapping) + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second - if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID); err != nil { + if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions); err != nil { return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err) } m := s.protoToMapping(ctx, mapping) @@ -1449,12 +1570,10 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping pt.RequestTimeout = d.AsDuration() } } - if pt.RequestTimeout == 0 && s.DefaultDialTimeout > 0 { - pt.RequestTimeout = s.DefaultDialTimeout - } + pt.RequestTimeout = s.clampDialTimeout(pt.RequestTimeout) paths[pathMapping.GetPath()] = pt } - return proxy.Mapping{ + m := proxy.Mapping{ ID: types.ServiceID(mapping.GetId()), AccountID: types.AccountID(mapping.GetAccountId()), Host: mapping.GetDomain(), @@ -1462,6 +1581,10 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping PassHostHeader: mapping.GetPassHostHeader(), RewriteRedirects: mapping.GetRewriteRedirects(), } + for _, ha := range mapping.GetAuth().GetHeaderAuths() { + m.StripAuthHeaders = append(m.StripAuthHeaders, ha.GetHeader()) + } + return m } func protoToPathRewrite(mode proto.PathRewriteMode) proxy.PathRewriteMode { diff --git a/release_files/install.sh b/release_files/install.sh index 6a2c5f458..1e71936f3 100755 --- a/release_files/install.sh +++ b/release_files/install.sh @@ -128,7 +128,7 @@ cat <<-EOF | ${SUDO} tee /etc/yum.repos.d/netbird.repo name=NetBird baseurl=https://pkgs.netbird.io/yum/ enabled=1 -gpgcheck=0 +gpgcheck=1 gpgkey=https://pkgs.netbird.io/yum/repodata/repomd.xml.key repo_gpgcheck=1 EOF diff --git a/shared/management/client/client.go b/shared/management/client/client.go index ba525602e..a15301223 100644 --- a/shared/management/client/client.go +++ b/shared/management/client/client.go @@ -22,6 +22,7 @@ type Client interface { GetDeviceAuthorizationFlow(serverKey wgtypes.Key) (*proto.DeviceAuthorizationFlow, error) GetPKCEAuthorizationFlow(serverKey wgtypes.Key) (*proto.PKCEAuthorizationFlow, error) GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error) + GetServerURL() string IsHealthy() bool SyncMeta(sysInfo *system.Info) error Logout() error diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 333f0bf00..252199498 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io" + "os" + "strconv" "sync" "time" @@ -29,6 +31,10 @@ import ( const ConnectTimeout = 10 * time.Second const ( + // EnvMaxRecvMsgSize overrides the default gRPC max receive message size (4 MB) + // for the management client connection. Value is in bytes. + EnvMaxRecvMsgSize = "NB_MANAGEMENT_GRPC_MAX_MSG_SIZE" + errMsgMgmtPublicKey = "failed getting Management Service public key: %s" errMsgNoMgmtConnection = "no connection to management" ) @@ -46,6 +52,7 @@ type GrpcClient struct { conn *grpc.ClientConn connStateCallback ConnStateNotifier connStateCallbackLock sync.RWMutex + serverURL string } type ExposeRequest struct { @@ -66,13 +73,41 @@ type ExposeResponse struct { PortAutoAssigned bool } +// MaxRecvMsgSize returns the configured max gRPC receive message size from +// the environment, or 0 if unset (which uses the gRPC default of 4 MB). +func MaxRecvMsgSize() int { + val := os.Getenv(EnvMaxRecvMsgSize) + if val == "" { + return 0 + } + + size, err := strconv.Atoi(val) + if err != nil { + log.Warnf("invalid %s value %q, using default: %v", EnvMaxRecvMsgSize, val, err) + return 0 + } + + if size <= 0 { + log.Warnf("invalid %s value %d, must be positive, using default", EnvMaxRecvMsgSize, size) + return 0 + } + + return size +} + // NewClient creates a new client to Management service func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsEnabled bool) (*GrpcClient, error) { var conn *grpc.ClientConn + var extraOpts []grpc.DialOption + if maxSize := MaxRecvMsgSize(); maxSize > 0 { + extraOpts = append(extraOpts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxSize))) + log.Infof("management gRPC max receive message size set to %d bytes", maxSize) + } + operation := func() error { var err error - conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.ManagementComponent) + conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.ManagementComponent, extraOpts...) if err != nil { return fmt.Errorf("create connection: %w", err) } @@ -93,9 +128,15 @@ func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsE ctx: ctx, conn: conn, connStateCallbackLock: sync.RWMutex{}, + serverURL: addr, }, nil } +// GetServerURL returns the management server URL +func (c *GrpcClient) GetServerURL() string { + return c.serverURL +} + // Close closes connection to the Management Service func (c *GrpcClient) Close() error { return c.conn.Close() diff --git a/shared/management/client/grpc_test.go b/shared/management/client/grpc_test.go new file mode 100644 index 000000000..462cc43af --- /dev/null +++ b/shared/management/client/grpc_test.go @@ -0,0 +1,95 @@ +package client + +import ( + "context" + "net" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + mgmtProto "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestMaxRecvMsgSize(t *testing.T) { + tests := []struct { + name string + envValue string + expected int + }{ + {name: "unset returns 0", envValue: "", expected: 0}, + {name: "valid value", envValue: "10485760", expected: 10485760}, + {name: "non-numeric returns 0", envValue: "abc", expected: 0}, + {name: "negative returns 0", envValue: "-1", expected: 0}, + {name: "zero returns 0", envValue: "0", expected: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(EnvMaxRecvMsgSize, tt.envValue) + if tt.envValue == "" { + os.Unsetenv(EnvMaxRecvMsgSize) + } + assert.Equal(t, tt.expected, MaxRecvMsgSize()) + }) + } +} + +// largeSyncServer implements just the Sync RPC, returning a response larger than the default 4MB limit. +type largeSyncServer struct { + mgmtProto.UnimplementedManagementServiceServer + responseSize int +} + +func (s *largeSyncServer) GetServerKey(_ context.Context, _ *mgmtProto.Empty) (*mgmtProto.ServerKeyResponse, error) { + // Return a response with a large WiretrusteeConfig to exceed the default limit. + padding := strings.Repeat("x", s.responseSize) + return &mgmtProto.ServerKeyResponse{ + Key: padding, + }, nil +} + +func TestMaxRecvMsgSizeIntegration(t *testing.T) { + const payloadSize = 5 * 1024 * 1024 // 5MB, exceeds 4MB default + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + srv := grpc.NewServer() + mgmtProto.RegisterManagementServiceServer(srv, &largeSyncServer{responseSize: payloadSize}) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + t.Run("default limit rejects large message", func(t *testing.T) { + conn, err := grpc.NewClient( + lis.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + client := mgmtProto.NewManagementServiceClient(conn) + _, err = client.GetServerKey(context.Background(), &mgmtProto.Empty{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "received message larger than max") + }) + + t.Run("increased limit accepts large message", func(t *testing.T) { + conn, err := grpc.NewClient( + lis.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024)), + ) + require.NoError(t, err) + defer conn.Close() + + client := mgmtProto.NewManagementServiceClient(conn) + resp, err := client.GetServerKey(context.Background(), &mgmtProto.Empty{}) + require.NoError(t, err) + assert.Len(t, resp.Key, payloadSize) + }) +} diff --git a/shared/management/client/mock.go b/shared/management/client/mock.go index 57256d6d4..548e379e8 100644 --- a/shared/management/client/mock.go +++ b/shared/management/client/mock.go @@ -19,6 +19,7 @@ type MockClient struct { LoginFunc func(serverKey wgtypes.Key, info *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error) GetDeviceAuthorizationFlowFunc func(serverKey wgtypes.Key) (*proto.DeviceAuthorizationFlow, error) GetPKCEAuthorizationFlowFunc func(serverKey wgtypes.Key) (*proto.PKCEAuthorizationFlow, error) + GetServerURLFunc func() string SyncMetaFunc func(sysInfo *system.Info) error LogoutFunc func() error JobFunc func(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error @@ -92,6 +93,14 @@ func (m *MockClient) GetNetworkMap(_ *system.Info) (*proto.NetworkMap, error) { return nil, nil } +// GetServerURL mock implementation of GetServerURL from mgm.Client interface +func (m *MockClient) GetServerURL() string { + if m.GetServerURLFunc == nil { + return "" + } + return m.GetServerURLFunc() +} + func (m *MockClient) SyncMeta(sysInfo *system.Info) error { if m.SyncMetaFunc == nil { return nil diff --git a/shared/management/client/rest/azure_idp.go b/shared/management/client/rest/azure_idp.go new file mode 100644 index 000000000..40b90bc30 --- /dev/null +++ b/shared/management/client/rest/azure_idp.go @@ -0,0 +1,112 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// AzureIDPAPI APIs for Azure AD IDP integrations +type AzureIDPAPI struct { + c *Client +} + +// List retrieves all Azure AD IDP integrations +func (a *AzureIDPAPI) List(ctx context.Context) ([]api.AzureIntegration, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/integrations/azure-idp", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.AzureIntegration](resp) + return ret, err +} + +// Get retrieves a specific Azure AD IDP integration by ID +func (a *AzureIDPAPI) Get(ctx context.Context, integrationID string) (*api.AzureIntegration, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/integrations/azure-idp/"+integrationID, nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AzureIntegration](resp) + return &ret, err +} + +// Create creates a new Azure AD IDP integration +func (a *AzureIDPAPI) Create(ctx context.Context, request api.CreateAzureIntegrationRequest) (*api.AzureIntegration, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "POST", "/api/integrations/azure-idp", bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AzureIntegration](resp) + return &ret, err +} + +// Update updates an existing Azure AD IDP integration +func (a *AzureIDPAPI) Update(ctx context.Context, integrationID string, request api.UpdateAzureIntegrationRequest) (*api.AzureIntegration, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "PUT", "/api/integrations/azure-idp/"+integrationID, bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AzureIntegration](resp) + return &ret, err +} + +// Delete deletes an Azure AD IDP integration +func (a *AzureIDPAPI) Delete(ctx context.Context, integrationID string) error { + resp, err := a.c.NewRequest(ctx, "DELETE", "/api/integrations/azure-idp/"+integrationID, nil, nil) + if err != nil { + return err + } + if resp.Body != nil { + defer resp.Body.Close() + } + return nil +} + +// Sync triggers a manual sync for an Azure AD IDP integration +func (a *AzureIDPAPI) Sync(ctx context.Context, integrationID string) (*api.SyncResult, error) { + resp, err := a.c.NewRequest(ctx, "POST", "/api/integrations/azure-idp/"+integrationID+"/sync", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.SyncResult](resp) + return &ret, err +} + +// GetLogs retrieves synchronization logs for an Azure AD IDP integration +func (a *AzureIDPAPI) GetLogs(ctx context.Context, integrationID string) ([]api.IdpIntegrationSyncLog, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/integrations/azure-idp/"+integrationID+"/logs", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.IdpIntegrationSyncLog](resp) + return ret, err +} diff --git a/shared/management/client/rest/azure_idp_test.go b/shared/management/client/rest/azure_idp_test.go new file mode 100644 index 000000000..480d2a313 --- /dev/null +++ b/shared/management/client/rest/azure_idp_test.go @@ -0,0 +1,252 @@ +//go:build integration + +package rest_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +var testAzureIntegration = api.AzureIntegration{ + Id: 1, + Enabled: true, + ClientId: "12345678-1234-1234-1234-123456789012", + TenantId: "87654321-4321-4321-4321-210987654321", + SyncInterval: 300, + GroupPrefixes: []string{"eng-"}, + UserGroupPrefixes: []string{"dev-"}, + Host: "microsoft.com", + LastSyncedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), +} + +func TestAzureIDP_List_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal([]api.AzureIntegration{testAzureIntegration}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.List(context.Background()) + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testAzureIntegration, ret[0]) + }) +} + +func TestAzureIDP_List_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.List(context.Background()) + assert.Error(t, err) + assert.Equal(t, "No", err.Error()) + assert.Empty(t, ret) + }) +} + +func TestAzureIDP_Get_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal(testAzureIntegration) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.Get(context.Background(), "int-1") + require.NoError(t, err) + assert.Equal(t, testAzureIntegration, *ret) + }) +} + +func TestAzureIDP_Get_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.Get(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestAzureIDP_Create_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + reqBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var req api.CreateAzureIntegrationRequest + err = json.Unmarshal(reqBytes, &req) + require.NoError(t, err) + assert.Equal(t, "12345678-1234-1234-1234-123456789012", req.ClientId) + retBytes, _ := json.Marshal(testAzureIntegration) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.Create(context.Background(), api.CreateAzureIntegrationRequest{ + ClientId: "12345678-1234-1234-1234-123456789012", + ClientSecret: "secret", + TenantId: "87654321-4321-4321-4321-210987654321", + Host: api.CreateAzureIntegrationRequestHostMicrosoftCom, + GroupPrefixes: &[]string{"eng-"}, + }) + require.NoError(t, err) + assert.Equal(t, testAzureIntegration, *ret) + }) +} + +func TestAzureIDP_Create_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.Create(context.Background(), api.CreateAzureIntegrationRequest{}) + assert.Error(t, err) + assert.Equal(t, "No", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestAzureIDP_Update_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + reqBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var req api.UpdateAzureIntegrationRequest + err = json.Unmarshal(reqBytes, &req) + require.NoError(t, err) + assert.Equal(t, true, *req.Enabled) + retBytes, _ := json.Marshal(testAzureIntegration) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.Update(context.Background(), "int-1", api.UpdateAzureIntegrationRequest{ + Enabled: ptr(true), + }) + require.NoError(t, err) + assert.Equal(t, testAzureIntegration, *ret) + }) +} + +func TestAzureIDP_Update_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.Update(context.Background(), "int-1", api.UpdateAzureIntegrationRequest{}) + assert.Error(t, err) + assert.Equal(t, "No", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestAzureIDP_Delete_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(200) + }) + err := c.AzureIDP.Delete(context.Background(), "int-1") + require.NoError(t, err) + }) +} + +func TestAzureIDP_Delete_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + err := c.AzureIDP.Delete(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + }) +} + +func TestAzureIDP_Sync_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1/sync", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + retBytes, _ := json.Marshal(api.SyncResult{Result: ptr("ok")}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.Sync(context.Background(), "int-1") + require.NoError(t, err) + assert.Equal(t, "ok", *ret.Result) + }) +} + +func TestAzureIDP_Sync_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1/sync", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.Sync(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestAzureIDP_GetLogs_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1/logs", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal([]api.IdpIntegrationSyncLog{testSyncLog}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.GetLogs(context.Background(), "int-1") + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testSyncLog, ret[0]) + }) +} + +func TestAzureIDP_GetLogs_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/azure-idp/int-1/logs", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AzureIDP.GetLogs(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + assert.Empty(t, ret) + }) +} diff --git a/shared/management/client/rest/client.go b/shared/management/client/rest/client.go index f308761fb..f0cb4d2d1 100644 --- a/shared/management/client/rest/client.go +++ b/shared/management/client/rest/client.go @@ -110,6 +110,15 @@ type Client struct { // see more: https://docs.netbird.io/api/resources/scim SCIM *SCIMAPI + // GoogleIDP NetBird Google Workspace IDP integration APIs + GoogleIDP *GoogleIDPAPI + + // AzureIDP NetBird Azure AD IDP integration APIs + AzureIDP *AzureIDPAPI + + // OktaScimIDP NetBird Okta SCIM IDP integration APIs + OktaScimIDP *OktaScimIDPAPI + // EventStreaming NetBird Event Streaming integration APIs // see more: https://docs.netbird.io/api/resources/event-streaming EventStreaming *EventStreamingAPI @@ -185,6 +194,9 @@ func (c *Client) initialize() { c.MSP = &MSPAPI{c} c.EDR = &EDRAPI{c} c.SCIM = &SCIMAPI{c} + c.GoogleIDP = &GoogleIDPAPI{c} + c.AzureIDP = &AzureIDPAPI{c} + c.OktaScimIDP = &OktaScimIDPAPI{c} c.EventStreaming = &EventStreamingAPI{c} c.IdentityProviders = &IdentityProvidersAPI{c} c.Ingress = &IngressAPI{c} diff --git a/shared/management/client/rest/google_idp.go b/shared/management/client/rest/google_idp.go new file mode 100644 index 000000000..b86436503 --- /dev/null +++ b/shared/management/client/rest/google_idp.go @@ -0,0 +1,112 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// GoogleIDPAPI APIs for Google Workspace IDP integrations +type GoogleIDPAPI struct { + c *Client +} + +// List retrieves all Google Workspace IDP integrations +func (a *GoogleIDPAPI) List(ctx context.Context) ([]api.GoogleIntegration, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/integrations/google-idp", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.GoogleIntegration](resp) + return ret, err +} + +// Get retrieves a specific Google Workspace IDP integration by ID +func (a *GoogleIDPAPI) Get(ctx context.Context, integrationID string) (*api.GoogleIntegration, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/integrations/google-idp/"+integrationID, nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.GoogleIntegration](resp) + return &ret, err +} + +// Create creates a new Google Workspace IDP integration +func (a *GoogleIDPAPI) Create(ctx context.Context, request api.CreateGoogleIntegrationRequest) (*api.GoogleIntegration, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "POST", "/api/integrations/google-idp", bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.GoogleIntegration](resp) + return &ret, err +} + +// Update updates an existing Google Workspace IDP integration +func (a *GoogleIDPAPI) Update(ctx context.Context, integrationID string, request api.UpdateGoogleIntegrationRequest) (*api.GoogleIntegration, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "PUT", "/api/integrations/google-idp/"+integrationID, bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.GoogleIntegration](resp) + return &ret, err +} + +// Delete deletes a Google Workspace IDP integration +func (a *GoogleIDPAPI) Delete(ctx context.Context, integrationID string) error { + resp, err := a.c.NewRequest(ctx, "DELETE", "/api/integrations/google-idp/"+integrationID, nil, nil) + if err != nil { + return err + } + if resp.Body != nil { + defer resp.Body.Close() + } + return nil +} + +// Sync triggers a manual sync for a Google Workspace IDP integration +func (a *GoogleIDPAPI) Sync(ctx context.Context, integrationID string) (*api.SyncResult, error) { + resp, err := a.c.NewRequest(ctx, "POST", "/api/integrations/google-idp/"+integrationID+"/sync", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.SyncResult](resp) + return &ret, err +} + +// GetLogs retrieves synchronization logs for a Google Workspace IDP integration +func (a *GoogleIDPAPI) GetLogs(ctx context.Context, integrationID string) ([]api.IdpIntegrationSyncLog, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/integrations/google-idp/"+integrationID+"/logs", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.IdpIntegrationSyncLog](resp) + return ret, err +} diff --git a/shared/management/client/rest/google_idp_test.go b/shared/management/client/rest/google_idp_test.go new file mode 100644 index 000000000..03a6c161e --- /dev/null +++ b/shared/management/client/rest/google_idp_test.go @@ -0,0 +1,248 @@ +//go:build integration + +package rest_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +var testGoogleIntegration = api.GoogleIntegration{ + Id: 1, + Enabled: true, + CustomerId: "C01234567", + SyncInterval: 300, + GroupPrefixes: []string{"eng-"}, + UserGroupPrefixes: []string{"dev-"}, + LastSyncedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), +} + +func TestGoogleIDP_List_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal([]api.GoogleIntegration{testGoogleIntegration}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.List(context.Background()) + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testGoogleIntegration, ret[0]) + }) +} + +func TestGoogleIDP_List_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.List(context.Background()) + assert.Error(t, err) + assert.Equal(t, "No", err.Error()) + assert.Empty(t, ret) + }) +} + +func TestGoogleIDP_Get_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal(testGoogleIntegration) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.Get(context.Background(), "int-1") + require.NoError(t, err) + assert.Equal(t, testGoogleIntegration, *ret) + }) +} + +func TestGoogleIDP_Get_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.Get(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestGoogleIDP_Create_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + reqBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var req api.CreateGoogleIntegrationRequest + err = json.Unmarshal(reqBytes, &req) + require.NoError(t, err) + assert.Equal(t, "C01234567", req.CustomerId) + retBytes, _ := json.Marshal(testGoogleIntegration) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.Create(context.Background(), api.CreateGoogleIntegrationRequest{ + CustomerId: "C01234567", + ServiceAccountKey: "key-data", + GroupPrefixes: &[]string{"eng-"}, + }) + require.NoError(t, err) + assert.Equal(t, testGoogleIntegration, *ret) + }) +} + +func TestGoogleIDP_Create_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.Create(context.Background(), api.CreateGoogleIntegrationRequest{}) + assert.Error(t, err) + assert.Equal(t, "No", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestGoogleIDP_Update_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + reqBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var req api.UpdateGoogleIntegrationRequest + err = json.Unmarshal(reqBytes, &req) + require.NoError(t, err) + assert.Equal(t, true, *req.Enabled) + retBytes, _ := json.Marshal(testGoogleIntegration) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.Update(context.Background(), "int-1", api.UpdateGoogleIntegrationRequest{ + Enabled: ptr(true), + }) + require.NoError(t, err) + assert.Equal(t, testGoogleIntegration, *ret) + }) +} + +func TestGoogleIDP_Update_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.Update(context.Background(), "int-1", api.UpdateGoogleIntegrationRequest{}) + assert.Error(t, err) + assert.Equal(t, "No", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestGoogleIDP_Delete_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(200) + }) + err := c.GoogleIDP.Delete(context.Background(), "int-1") + require.NoError(t, err) + }) +} + +func TestGoogleIDP_Delete_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + err := c.GoogleIDP.Delete(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + }) +} + +func TestGoogleIDP_Sync_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1/sync", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + retBytes, _ := json.Marshal(api.SyncResult{Result: ptr("ok")}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.Sync(context.Background(), "int-1") + require.NoError(t, err) + assert.Equal(t, "ok", *ret.Result) + }) +} + +func TestGoogleIDP_Sync_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1/sync", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.Sync(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestGoogleIDP_GetLogs_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1/logs", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal([]api.IdpIntegrationSyncLog{testSyncLog}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.GetLogs(context.Background(), "int-1") + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testSyncLog, ret[0]) + }) +} + +func TestGoogleIDP_GetLogs_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/google-idp/int-1/logs", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.GoogleIDP.GetLogs(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + assert.Empty(t, ret) + }) +} diff --git a/shared/management/client/rest/okta_scim_idp.go b/shared/management/client/rest/okta_scim_idp.go new file mode 100644 index 000000000..eb677dae8 --- /dev/null +++ b/shared/management/client/rest/okta_scim_idp.go @@ -0,0 +1,112 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// OktaScimIDPAPI APIs for Okta SCIM IDP integrations +type OktaScimIDPAPI struct { + c *Client +} + +// List retrieves all Okta SCIM IDP integrations +func (a *OktaScimIDPAPI) List(ctx context.Context) ([]api.OktaScimIntegration, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/integrations/okta-scim-idp", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.OktaScimIntegration](resp) + return ret, err +} + +// Get retrieves a specific Okta SCIM IDP integration by ID +func (a *OktaScimIDPAPI) Get(ctx context.Context, integrationID string) (*api.OktaScimIntegration, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/integrations/okta-scim-idp/"+integrationID, nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.OktaScimIntegration](resp) + return &ret, err +} + +// Create creates a new Okta SCIM IDP integration +func (a *OktaScimIDPAPI) Create(ctx context.Context, request api.CreateOktaScimIntegrationRequest) (*api.OktaScimIntegration, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "POST", "/api/integrations/okta-scim-idp", bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.OktaScimIntegration](resp) + return &ret, err +} + +// Update updates an existing Okta SCIM IDP integration +func (a *OktaScimIDPAPI) Update(ctx context.Context, integrationID string, request api.UpdateOktaScimIntegrationRequest) (*api.OktaScimIntegration, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "PUT", "/api/integrations/okta-scim-idp/"+integrationID, bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.OktaScimIntegration](resp) + return &ret, err +} + +// Delete deletes an Okta SCIM IDP integration +func (a *OktaScimIDPAPI) Delete(ctx context.Context, integrationID string) error { + resp, err := a.c.NewRequest(ctx, "DELETE", "/api/integrations/okta-scim-idp/"+integrationID, nil, nil) + if err != nil { + return err + } + if resp.Body != nil { + defer resp.Body.Close() + } + return nil +} + +// RegenerateToken regenerates the SCIM API token for an Okta SCIM integration +func (a *OktaScimIDPAPI) RegenerateToken(ctx context.Context, integrationID string) (*api.ScimTokenResponse, error) { + resp, err := a.c.NewRequest(ctx, "POST", "/api/integrations/okta-scim-idp/"+integrationID+"/token", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.ScimTokenResponse](resp) + return &ret, err +} + +// GetLogs retrieves synchronization logs for an Okta SCIM IDP integration +func (a *OktaScimIDPAPI) GetLogs(ctx context.Context, integrationID string) ([]api.IdpIntegrationSyncLog, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/integrations/okta-scim-idp/"+integrationID+"/logs", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.IdpIntegrationSyncLog](resp) + return ret, err +} diff --git a/shared/management/client/rest/okta_scim_idp_test.go b/shared/management/client/rest/okta_scim_idp_test.go new file mode 100644 index 000000000..d8d1f2b51 --- /dev/null +++ b/shared/management/client/rest/okta_scim_idp_test.go @@ -0,0 +1,246 @@ +//go:build integration + +package rest_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +var testOktaScimIntegration = api.OktaScimIntegration{ + Id: 1, + AuthToken: "****", + Enabled: true, + GroupPrefixes: []string{"eng-"}, + UserGroupPrefixes: []string{"dev-"}, + LastSyncedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), +} + +func TestOktaScimIDP_List_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal([]api.OktaScimIntegration{testOktaScimIntegration}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.List(context.Background()) + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testOktaScimIntegration, ret[0]) + }) +} + +func TestOktaScimIDP_List_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.List(context.Background()) + assert.Error(t, err) + assert.Equal(t, "No", err.Error()) + assert.Empty(t, ret) + }) +} + +func TestOktaScimIDP_Get_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal(testOktaScimIntegration) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.Get(context.Background(), "int-1") + require.NoError(t, err) + assert.Equal(t, testOktaScimIntegration, *ret) + }) +} + +func TestOktaScimIDP_Get_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.Get(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestOktaScimIDP_Create_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + reqBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var req api.CreateOktaScimIntegrationRequest + err = json.Unmarshal(reqBytes, &req) + require.NoError(t, err) + assert.Equal(t, "my-okta-connection", req.ConnectionName) + retBytes, _ := json.Marshal(testOktaScimIntegration) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.Create(context.Background(), api.CreateOktaScimIntegrationRequest{ + ConnectionName: "my-okta-connection", + GroupPrefixes: &[]string{"eng-"}, + }) + require.NoError(t, err) + assert.Equal(t, testOktaScimIntegration, *ret) + }) +} + +func TestOktaScimIDP_Create_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.Create(context.Background(), api.CreateOktaScimIntegrationRequest{}) + assert.Error(t, err) + assert.Equal(t, "No", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestOktaScimIDP_Update_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + reqBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var req api.UpdateOktaScimIntegrationRequest + err = json.Unmarshal(reqBytes, &req) + require.NoError(t, err) + assert.Equal(t, true, *req.Enabled) + retBytes, _ := json.Marshal(testOktaScimIntegration) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.Update(context.Background(), "int-1", api.UpdateOktaScimIntegrationRequest{ + Enabled: ptr(true), + }) + require.NoError(t, err) + assert.Equal(t, testOktaScimIntegration, *ret) + }) +} + +func TestOktaScimIDP_Update_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.Update(context.Background(), "int-1", api.UpdateOktaScimIntegrationRequest{}) + assert.Error(t, err) + assert.Equal(t, "No", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestOktaScimIDP_Delete_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(200) + }) + err := c.OktaScimIDP.Delete(context.Background(), "int-1") + require.NoError(t, err) + }) +} + +func TestOktaScimIDP_Delete_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + err := c.OktaScimIDP.Delete(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + }) +} + +func TestOktaScimIDP_RegenerateToken_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1/token", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + retBytes, _ := json.Marshal(testScimToken) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.RegenerateToken(context.Background(), "int-1") + require.NoError(t, err) + assert.Equal(t, testScimToken, *ret) + }) +} + +func TestOktaScimIDP_RegenerateToken_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1/token", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.RegenerateToken(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + assert.Nil(t, ret) + }) +} + +func TestOktaScimIDP_GetLogs_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1/logs", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal([]api.IdpIntegrationSyncLog{testSyncLog}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.GetLogs(context.Background(), "int-1") + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testSyncLog, ret[0]) + }) +} + +func TestOktaScimIDP_GetLogs_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/integrations/okta-scim-idp/int-1/logs", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.OktaScimIDP.GetLogs(context.Background(), "int-1") + assert.Error(t, err) + assert.Equal(t, "Not found", err.Error()) + assert.Empty(t, ret) + }) +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index f6d7d661a..d35b32be0 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -68,8 +68,17 @@ tags: - name: MSP description: MSP portal for Tenant management. x-cloud-only: true - - name: IDP - description: Manage identity provider integrations for user and group sync. + - name: IDP SCIM Integrations + description: Manage generic SCIM identity provider integrations for user and group sync. + x-cloud-only: true + - name: IDP Google Integrations + description: Manage Google Workspace identity provider integrations for user and group sync. + x-cloud-only: true + - name: IDP Azure Integrations + description: Manage Azure AD identity provider integrations for user and group sync. + x-cloud-only: true + - name: IDP Okta SCIM Integrations + description: Manage Okta SCIM identity provider integrations for user and group sync. x-cloud-only: true - name: EDR Intune Integrations description: Manage Microsoft Intune EDR integrations. @@ -92,6 +101,10 @@ tags: - name: Event Streaming Integrations description: Manage event streaming integrations. x-cloud-only: true + - name: Notifications + description: Manage notification channels for account event alerts. + x-cloud-only: true + components: schemas: @@ -2829,6 +2842,10 @@ components: type: string description: "City name from geolocation" example: "San Francisco" + subdivision_code: + type: string + description: "First-level administrative subdivision ISO code (e.g. state/province)" + example: "CA" bytes_upload: type: integer format: int64 @@ -2955,26 +2972,32 @@ components: id: type: string description: Service ID + example: "cs8i4ug6lnn4g9hqv7mg" name: type: string description: Service name + example: "myapp.example.netbird.app" domain: type: string description: Domain for the service + example: "myapp.example.netbird.app" mode: type: string description: Service mode. "http" for L7 reverse proxy, "tcp"/"udp"/"tls" for L4 passthrough. enum: [http, tcp, udp, tls] default: http + example: "http" listen_port: type: integer minimum: 0 maximum: 65535 description: Port the proxy listens on (L4/TLS only) + example: 8443 port_auto_assigned: type: boolean description: Whether the listen port was auto-assigned readOnly: true + example: false proxy_cluster: type: string description: The proxy cluster handling this service (derived from domain) @@ -2987,14 +3010,24 @@ components: enabled: type: boolean description: Whether the service is enabled + example: true + terminated: + type: boolean + description: Whether the service has been terminated. Terminated services cannot be updated. Services that violate the Terms of Service will be terminated. + readOnly: true + example: false pass_host_header: type: boolean description: When true, the original client Host header is passed through to the backend instead of being rewritten to the backend's address + example: false rewrite_redirects: type: boolean description: When true, Location headers in backend responses are rewritten to replace the backend address with the public-facing domain + example: false auth: $ref: '#/components/schemas/ServiceAuthConfig' + access_restrictions: + $ref: '#/components/schemas/AccessRestrictions' meta: $ref: '#/components/schemas/ServiceMeta' required: @@ -3038,19 +3071,23 @@ components: name: type: string description: Service name + example: "myapp.example.netbird.app" domain: type: string description: Domain for the service + example: "myapp.example.netbird.app" mode: type: string description: Service mode. "http" for L7 reverse proxy, "tcp"/"udp"/"tls" for L4 passthrough. enum: [http, tcp, udp, tls] default: http + example: "http" listen_port: type: integer minimum: 0 maximum: 65535 description: Port the proxy listens on (L4/TLS only). Set to 0 for auto-assignment. + example: 5432 targets: type: array items: @@ -3060,14 +3097,19 @@ components: type: boolean description: Whether the service is enabled default: true + example: true pass_host_header: type: boolean description: When true, the original client Host header is passed through to the backend instead of being rewritten to the backend's address + example: false rewrite_redirects: type: boolean description: When true, Location headers in backend responses are rewritten to replace the backend address with the public-facing domain + example: false auth: $ref: '#/components/schemas/ServiceAuthConfig' + access_restrictions: + $ref: '#/components/schemas/AccessRestrictions' required: - name - domain @@ -3078,13 +3120,16 @@ components: skip_tls_verify: type: boolean description: Skip TLS certificate verification for this backend + example: false request_timeout: type: string description: Per-target response timeout as a Go duration string (e.g. "30s", "2m") + example: "30s" path_rewrite: type: string description: Controls how the request path is rewritten before forwarding to the backend. Default strips the matched prefix. "preserve" keeps the full original request path. enum: [preserve] + example: "preserve" custom_headers: type: object description: Extra headers sent to the backend. Hop-by-hop and proxy-managed headers (Host, Connection, Transfer-Encoding, etc.) are rejected. @@ -3094,40 +3139,50 @@ components: additionalProperties: type: string pattern: '^[^\r\n]*$' + example: {"X-Custom-Header": "value"} proxy_protocol: type: boolean description: Send PROXY Protocol v2 header to this backend (TCP/TLS only) + example: false session_idle_timeout: type: string - description: Idle timeout before a UDP session is reaped, as a Go duration string (e.g. "30s", "2m"). Maximum 10m. + description: Idle timeout before a UDP session is reaped, as a Go duration string (e.g. "30s", "2m"). + example: "2m" ServiceTarget: type: object properties: target_id: type: string description: Target ID + example: "cs8i4ug6lnn4g9hqv7mg" target_type: type: string description: Target type enum: [peer, host, domain, subnet] + example: "subnet" path: type: string description: URL path prefix for this target (HTTP only) + example: "/" protocol: type: string description: Protocol to use when connecting to the backend enum: [http, https, tcp, udp] + example: "http" host: type: string description: Backend ip or domain for this target + example: "10.10.0.1" port: type: integer minimum: 1 maximum: 65535 description: Backend port for this target + example: 8080 enabled: type: boolean description: Whether this target is enabled + example: true options: $ref: '#/components/schemas/ServiceTargetOptions' required: @@ -3147,15 +3202,73 @@ components: $ref: '#/components/schemas/BearerAuthConfig' link_auth: $ref: '#/components/schemas/LinkAuthConfig' + header_auths: + type: array + items: + $ref: '#/components/schemas/HeaderAuthConfig' + HeaderAuthConfig: + type: object + description: Static header-value authentication. The proxy checks that the named header matches the configured value. + properties: + enabled: + type: boolean + description: Whether header auth is enabled + example: true + header: + type: string + description: HTTP header name to check (e.g. "Authorization", "X-API-Key") + example: "X-API-Key" + value: + type: string + description: Expected header value. For Basic auth use "Basic base64(user:pass)". For Bearer use "Bearer token". Cleared in responses. + example: "my-secret-api-key" + required: + - enabled + - header + - value + AccessRestrictions: + type: object + description: Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services. + properties: + allowed_cidrs: + type: array + items: + type: string + format: cidr + example: "192.168.1.0/24" + description: CIDR allowlist. If non-empty, only IPs matching these CIDRs are allowed. + blocked_cidrs: + type: array + items: + type: string + format: cidr + example: "10.0.0.0/8" + description: CIDR blocklist. Connections from these CIDRs are rejected. Evaluated after allowed_cidrs. + allowed_countries: + type: array + items: + type: string + pattern: '^[a-zA-Z]{2}$' + example: "US" + description: ISO 3166-1 alpha-2 country codes to allow. If non-empty, only these countries are permitted. + blocked_countries: + type: array + items: + type: string + pattern: '^[a-zA-Z]{2}$' + example: "DE" + description: ISO 3166-1 alpha-2 country codes to block. PasswordAuthConfig: type: object properties: enabled: type: boolean description: Whether password auth is enabled + example: true password: type: string description: Auth password + example: "s3cret" required: - enabled - password @@ -3165,9 +3278,11 @@ components: enabled: type: boolean description: Whether PIN auth is enabled + example: false pin: type: string description: PIN value + example: "1234" required: - enabled - pin @@ -3177,10 +3292,12 @@ components: enabled: type: boolean description: Whether bearer auth is enabled + example: true distribution_groups: type: array items: type: string + example: "ch8i4ug6lnn4g9hqv7mg" description: List of group IDs that can use bearer auth required: - enabled @@ -3190,6 +3307,7 @@ components: enabled: type: boolean description: Whether link auth is enabled + example: false required: - enabled ProxyCluster: @@ -3220,20 +3338,29 @@ components: id: type: string description: Domain ID + example: "ds8i4ug6lnn4g9hqv7mg" domain: type: string description: Domain name + example: "example.netbird.app" validated: type: boolean description: Whether the domain has been validated + example: true type: $ref: '#/components/schemas/ReverseProxyDomainType' target_cluster: type: string description: The proxy cluster this domain is validated against (only for custom domains) + example: "eu.proxy.netbird.io" supports_custom_ports: type: boolean description: Whether the cluster supports binding arbitrary TCP/UDP ports + example: true + require_subdomain: + type: boolean + description: Whether a subdomain label is required in front of this domain. When true, the domain cannot be used bare. + example: false required: - id - domain @@ -3245,9 +3372,11 @@ components: domain: type: string description: Domain name + example: "myapp.example.com" target_cluster: type: string description: The proxy cluster this domain should be validated against + example: "eu.proxy.netbird.io" required: - domain - target_cluster @@ -4270,96 +4399,89 @@ components: type: integer example: [1, 5, 12] + IntegrationSyncFilters: + type: object + properties: + group_prefixes: + type: array + description: List of start_with string patterns for groups to sync + items: + type: string + example: [ "Engineering", "Sales" ] + user_group_prefixes: + type: array + description: List of start_with string patterns for groups which users to sync + items: + type: string + example: [ "Users" ] + IntegrationEnabled: + type: object + properties: + enabled: + type: boolean + description: Whether the integration is enabled + example: true CreateScimIntegrationRequest: - type: object - description: Request payload for creating an SCIM IDP integration - required: - - prefix - - provider - properties: - prefix: - type: string - description: The connection prefix used for the SCIM provider - provider: - type: string - description: Name of the SCIM identity provider - group_prefixes: - type: array - description: List of start_with string patterns for groups to sync - items: - type: string - example: [ "Engineering", "Sales" ] - user_group_prefixes: - type: array - description: List of start_with string patterns for groups which users to sync - items: - type: string - example: [ "Users" ] + allOf: + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Request payload for creating an SCIM IDP integration + required: + - prefix + - provider + properties: + prefix: + type: string + description: The connection prefix used for the SCIM provider + provider: + type: string + description: Name of the SCIM identity provider UpdateScimIntegrationRequest: - type: object - description: Request payload for updating an SCIM IDP integration - properties: - enabled: - type: boolean - description: Indicates whether the integration is enabled - example: true - group_prefixes: - type: array - description: List of start_with string patterns for groups to sync - items: - type: string - example: [ "Engineering", "Sales" ] - user_group_prefixes: - type: array - description: List of start_with string patterns for groups which users to sync - items: - type: string - example: [ "Users" ] + allOf: + - $ref: '#/components/schemas/IntegrationEnabled' + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Request payload for updating an SCIM IDP integration + properties: + prefix: + type: string + description: The connection prefix used for the SCIM provider ScimIntegration: - type: object - description: Represents a SCIM IDP integration - required: - - id - - enabled - - provider - - group_prefixes - - user_group_prefixes - - auth_token - - last_synced_at - properties: - id: - type: integer - format: int64 - description: The unique identifier for the integration - example: 123 - enabled: - type: boolean - description: Indicates whether the integration is enabled - example: true - provider: - type: string - description: Name of the SCIM identity provider - group_prefixes: - type: array - description: List of start_with string patterns for groups to sync - items: - type: string - example: [ "Engineering", "Sales" ] - user_group_prefixes: - type: array - description: List of start_with string patterns for groups which users to sync - items: - type: string - example: [ "Users" ] - auth_token: - type: string - description: SCIM API token (full on creation, masked otherwise) - example: "nbs_abc***********************************" - last_synced_at: - type: string - format: date-time - description: Timestamp of when the integration was last synced - example: "2023-05-15T10:30:00Z" + allOf: + - $ref: '#/components/schemas/IntegrationEnabled' + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Represents a SCIM IDP integration + required: + - id + - enabled + - prefix + - provider + - group_prefixes + - user_group_prefixes + - auth_token + - last_synced_at + properties: + id: + type: integer + format: int64 + description: The unique identifier for the integration + example: 123 + prefix: + type: string + description: The connection prefix used for the SCIM provider + provider: + type: string + description: Name of the SCIM identity provider + auth_token: + type: string + description: SCIM API token (full on creation, masked otherwise) + example: "nbs_abc***********************************" + last_synced_at: + type: string + format: date-time + description: Timestamp of when the integration was last synced + example: "2023-05-15T10:30:00Z" IdpIntegrationSyncLog: type: object description: Represents a synchronization log entry for an integration @@ -4397,6 +4519,346 @@ components: type: string description: The newly generated SCIM API token example: "nbs_F3f0d..." + CreateGoogleIntegrationRequest: + allOf: + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Request payload for creating a Google Workspace IDP integration + required: + - service_account_key + - customer_id + properties: + service_account_key: + type: string + description: Base64-encoded Google service account key + example: "eyJ0eXBlIjoic2VydmljZV9hY2NvdW50Ii..." + customer_id: + type: string + description: Customer ID from Google Workspace Account Settings + example: "C01234567" + sync_interval: + type: integer + description: Sync interval in seconds (minimum 300). Defaults to 300 if not specified. + minimum: 300 + example: 300 + UpdateGoogleIntegrationRequest: + allOf: + - $ref: '#/components/schemas/IntegrationEnabled' + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Request payload for updating a Google Workspace IDP integration. All fields are optional. + properties: + service_account_key: + type: string + description: Base64-encoded Google service account key + customer_id: + type: string + description: Customer ID from Google Workspace Account Settings + sync_interval: + type: integer + description: Sync interval in seconds (minimum 300) + minimum: 300 + GoogleIntegration: + allOf: + - $ref: '#/components/schemas/IntegrationEnabled' + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Represents a Google Workspace IDP integration + required: + - id + - customer_id + - sync_interval + - enabled + - group_prefixes + - user_group_prefixes + - last_synced_at + properties: + id: + type: integer + format: int64 + description: The unique identifier for the integration + example: 1 + customer_id: + type: string + description: Customer ID from Google Workspace + example: "C01234567" + sync_interval: + type: integer + description: Sync interval in seconds + example: 300 + last_synced_at: + type: string + format: date-time + description: Timestamp of the last synchronization + example: "2023-05-15T10:30:00Z" + CreateAzureIntegrationRequest: + allOf: + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Request payload for creating an Azure AD IDP integration + required: + - client_secret + - client_id + - tenant_id + - host + properties: + client_secret: + type: string + description: Base64-encoded Azure AD client secret + example: "c2VjcmV0..." + client_id: + type: string + description: Azure AD application (client) ID + example: "12345678-1234-1234-1234-123456789012" + tenant_id: + type: string + description: Azure AD tenant ID + example: "87654321-4321-4321-4321-210987654321" + sync_interval: + type: integer + description: Sync interval in seconds (minimum 300). Defaults to 300 if not specified. + minimum: 300 + example: 300 + host: + type: string + description: Azure host domain for the Graph API + enum: + - microsoft.com + - microsoft.us + example: "microsoft.com" + UpdateAzureIntegrationRequest: + allOf: + - $ref: '#/components/schemas/IntegrationEnabled' + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Request payload for updating an Azure AD IDP integration. All fields are optional. + properties: + client_secret: + type: string + description: Base64-encoded Azure AD client secret + client_id: + type: string + description: Azure AD application (client) ID + tenant_id: + type: string + description: Azure AD tenant ID + sync_interval: + type: integer + description: Sync interval in seconds (minimum 300) + minimum: 300 + AzureIntegration: + allOf: + - $ref: '#/components/schemas/IntegrationEnabled' + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Represents an Azure AD IDP integration + required: + - id + - client_id + - tenant_id + - sync_interval + - enabled + - group_prefixes + - user_group_prefixes + - host + - last_synced_at + properties: + id: + type: integer + format: int64 + description: The unique identifier for the integration + example: 1 + client_id: + type: string + description: Azure AD application (client) ID + example: "12345678-1234-1234-1234-123456789012" + tenant_id: + type: string + description: Azure AD tenant ID + example: "87654321-4321-4321-4321-210987654321" + sync_interval: + type: integer + description: Sync interval in seconds + example: 300 + host: + type: string + description: Azure host domain for the Graph API + example: "microsoft.com" + last_synced_at: + type: string + format: date-time + description: Timestamp of the last synchronization + example: "2023-05-15T10:30:00Z" + CreateOktaScimIntegrationRequest: + allOf: + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Request payload for creating an Okta SCIM IDP integration + required: + - connection_name + properties: + connection_name: + type: string + description: The Okta enterprise connection name on Auth0 + example: "my-okta-connection" + UpdateOktaScimIntegrationRequest: + allOf: + - $ref: '#/components/schemas/IntegrationEnabled' + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Request payload for updating an Okta SCIM IDP integration. All fields are optional. + OktaScimIntegration: + allOf: + - $ref: '#/components/schemas/IntegrationEnabled' + - $ref: '#/components/schemas/IntegrationSyncFilters' + - type: object + description: Represents an Okta SCIM IDP integration + required: + - id + - enabled + - group_prefixes + - user_group_prefixes + - auth_token + - last_synced_at + properties: + id: + type: integer + format: int64 + description: The unique identifier for the integration + example: 1 + auth_token: + type: string + description: SCIM API token (full on creation/regeneration, masked on retrieval) + example: "nbs_abc***********************************" + last_synced_at: + type: string + format: date-time + description: Timestamp of the last synchronization + example: "2023-05-15T10:30:00Z" + SyncResult: + type: object + description: Response for a manual sync trigger + properties: + result: + type: string + example: "ok" + NotificationChannelType: + type: string + description: The type of notification channel. + enum: + - email + - webhook + example: "email" + NotificationEventType: + type: string + description: | + An activity event type code. See `GET /api/integrations/notifications/types` for the full list + of supported event types and their human-readable descriptions. + example: "user.join" + EmailTarget: + type: object + description: Target configuration for email notification channels. + properties: + emails: + type: array + description: List of email addresses to send notifications to. + minItems: 1 + items: + type: string + format: email + example: [ "admin@example.com", "ops@example.com" ] + required: + - emails + WebhookTarget: + type: object + description: Target configuration for webhook notification channels. + properties: + url: + type: string + format: uri + description: The webhook endpoint URL to send notifications to. + example: "https://hooks.example.com/netbird" + headers: + type: object + additionalProperties: + type: string + description: | + Custom HTTP headers sent with each webhook request. + Values are write-only; in GET responses all values are masked. + example: + Authorization: "Bearer token" + X-Webhook-Secret: "secret" + required: + - url + NotificationChannelRequest: + type: object + description: Request body for creating or updating a notification channel. + properties: + type: + $ref: '#/components/schemas/NotificationChannelType' + target: + description: | + Channel-specific target configuration. The shape depends on the `type` field: + - `email`: requires an `EmailTarget` object + - `webhook`: requires a `WebhookTarget` object + oneOf: + - $ref: '#/components/schemas/EmailTarget' + - $ref: '#/components/schemas/WebhookTarget' + event_types: + type: array + description: List of activity event type codes this channel subscribes to. + items: + $ref: '#/components/schemas/NotificationEventType' + example: [ "user.join", "peer.user.add", "peer.login.expire" ] + enabled: + type: boolean + description: Whether this notification channel is active. + example: true + required: + - type + - event_types + - enabled + NotificationChannelResponse: + type: object + description: A notification channel configuration. + properties: + id: + type: string + description: Unique identifier of the notification channel. + readOnly: true + example: "ch8i4ug6lnn4g9hqv7m0" + type: + $ref: '#/components/schemas/NotificationChannelType' + target: + description: | + Channel-specific target configuration. The shape depends on the `type` field: + - `email`: an `EmailTarget` object + - `webhook`: a `WebhookTarget` object + oneOf: + - $ref: '#/components/schemas/EmailTarget' + - $ref: '#/components/schemas/WebhookTarget' + event_types: + type: array + description: List of activity event type codes this channel subscribes to. + items: + $ref: '#/components/schemas/NotificationEventType' + example: [ "user.join", "peer.user.add", "peer.login.expire" ] + enabled: + type: boolean + description: Whether this notification channel is active. + example: true + required: + - id + - type + - event_types + - enabled + NotificationTypeEntry: + type: object + description: A map of event type codes to their human-readable descriptions. + additionalProperties: + type: string + example: + user.join: "User joined" BypassResponse: type: object description: Response for bypassed peer operations. @@ -9033,10 +9495,877 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /api/integrations/google-idp: + post: + tags: + - IDP Google Integrations + summary: Create Google IDP Integration + description: Creates a new Google Workspace IDP integration + operationId: createGoogleIntegration + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateGoogleIntegrationRequest' + responses: + '200': + description: Integration created successfully. Returns the created integration. + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleIntegration' + '400': + description: Bad Request (e.g., invalid JSON, missing required fields, validation error). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized (e.g., missing or invalid authentication token). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - IDP Google Integrations + summary: Get All Google IDP Integrations + description: Retrieves all Google Workspace IDP integrations for the authenticated account + operationId: getAllGoogleIntegrations + responses: + '200': + description: A list of Google IDP integrations. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/GoogleIntegration' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/google-idp/{id}: + parameters: + - name: id + in: path + required: true + description: The unique identifier of the Google IDP integration. + schema: + type: integer + format: int64 + example: 1 + get: + tags: + - IDP Google Integrations + summary: Get Google IDP Integration + description: Retrieves a Google IDP integration by ID. + operationId: getGoogleIntegration + responses: + '200': + description: Successfully retrieved the integration details. + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleIntegration' + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found (e.g., integration with the given ID does not exist). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - IDP Google Integrations + summary: Update Google IDP Integration + description: Updates an existing Google Workspace IDP integration. + operationId: updateGoogleIntegration + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateGoogleIntegrationRequest' + responses: + '200': + description: Integration updated successfully. Returns the updated integration. + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleIntegration' + '400': + description: Bad Request (e.g., invalid JSON, validation error, invalid ID). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - IDP Google Integrations + summary: Delete Google IDP Integration + description: Deletes a Google IDP integration by ID. + operationId: deleteGoogleIntegration + responses: + '200': + description: Integration deleted successfully. Returns an empty object. + content: + application/json: + schema: + type: object + example: { } + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/google-idp/{id}/sync: + parameters: + - name: id + in: path + required: true + description: The unique identifier of the Google IDP integration. + schema: + type: integer + format: int64 + example: 1 + post: + tags: + - IDP Google Integrations + summary: Sync Google IDP Integration + description: Triggers a manual synchronization for a Google IDP integration. + operationId: syncGoogleIntegration + responses: + '200': + description: Sync triggered successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/SyncResult' + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/google-idp/{id}/logs: + parameters: + - name: id + in: path + required: true + description: The unique identifier of the Google IDP integration. + schema: + type: integer + format: int64 + example: 1 + get: + tags: + - IDP Google Integrations + summary: Get Google Integration Sync Logs + description: Retrieves synchronization logs for a Google IDP integration. + operationId: getGoogleIntegrationLogs + responses: + '200': + description: Successfully retrieved the integration sync logs. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/IdpIntegrationSyncLog' + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/azure-idp: + post: + tags: + - IDP Azure Integrations + summary: Create Azure IDP Integration + description: Creates a new Azure AD IDP integration + operationId: createAzureIntegration + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAzureIntegrationRequest' + responses: + '200': + description: Integration created successfully. Returns the created integration. + content: + application/json: + schema: + $ref: '#/components/schemas/AzureIntegration' + '400': + description: Bad Request (e.g., invalid JSON, missing required fields, validation error). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized (e.g., missing or invalid authentication token). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - IDP Azure Integrations + summary: Get All Azure IDP Integrations + description: Retrieves all Azure AD IDP integrations for the authenticated account + operationId: getAllAzureIntegrations + responses: + '200': + description: A list of Azure IDP integrations. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AzureIntegration' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/azure-idp/{id}: + parameters: + - name: id + in: path + required: true + description: The unique identifier of the Azure IDP integration. + schema: + type: integer + format: int64 + example: 1 + get: + tags: + - IDP Azure Integrations + summary: Get Azure IDP Integration + description: Retrieves an Azure IDP integration by ID. + operationId: getAzureIntegration + responses: + '200': + description: Successfully retrieved the integration details. + content: + application/json: + schema: + $ref: '#/components/schemas/AzureIntegration' + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found (e.g., integration with the given ID does not exist). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - IDP Azure Integrations + summary: Update Azure IDP Integration + description: Updates an existing Azure AD IDP integration. + operationId: updateAzureIntegration + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAzureIntegrationRequest' + responses: + '200': + description: Integration updated successfully. Returns the updated integration. + content: + application/json: + schema: + $ref: '#/components/schemas/AzureIntegration' + '400': + description: Bad Request (e.g., invalid JSON, validation error, invalid ID). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - IDP Azure Integrations + summary: Delete Azure IDP Integration + description: Deletes an Azure IDP integration by ID. + operationId: deleteAzureIntegration + responses: + '200': + description: Integration deleted successfully. Returns an empty object. + content: + application/json: + schema: + type: object + example: { } + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/azure-idp/{id}/sync: + parameters: + - name: id + in: path + required: true + description: The unique identifier of the Azure IDP integration. + schema: + type: integer + format: int64 + example: 1 + post: + tags: + - IDP Azure Integrations + summary: Sync Azure IDP Integration + description: Triggers a manual synchronization for an Azure IDP integration. + operationId: syncAzureIntegration + responses: + '200': + description: Sync triggered successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/SyncResult' + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/azure-idp/{id}/logs: + parameters: + - name: id + in: path + required: true + description: The unique identifier of the Azure IDP integration. + schema: + type: integer + format: int64 + example: 1 + get: + tags: + - IDP Azure Integrations + summary: Get Azure Integration Sync Logs + description: Retrieves synchronization logs for an Azure IDP integration. + operationId: getAzureIntegrationLogs + responses: + '200': + description: Successfully retrieved the integration sync logs. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/IdpIntegrationSyncLog' + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/okta-scim-idp: + post: + tags: + - IDP Okta SCIM Integrations + summary: Create Okta SCIM IDP Integration + description: Creates a new Okta SCIM IDP integration + operationId: createOktaScimIntegration + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateOktaScimIntegrationRequest' + responses: + '200': + description: Integration created successfully. Returns the created integration. + content: + application/json: + schema: + $ref: '#/components/schemas/OktaScimIntegration' + '400': + description: Bad Request (e.g., invalid JSON, missing required fields, validation error). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized (e.g., missing or invalid authentication token). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - IDP Okta SCIM Integrations + summary: Get All Okta SCIM IDP Integrations + description: Retrieves all Okta SCIM IDP integrations for the authenticated account + operationId: getAllOktaScimIntegrations + responses: + '200': + description: A list of Okta SCIM IDP integrations. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OktaScimIntegration' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/okta-scim-idp/{id}: + parameters: + - name: id + in: path + required: true + description: The unique identifier of the Okta SCIM IDP integration. + schema: + type: integer + format: int64 + example: 1 + get: + tags: + - IDP Okta SCIM Integrations + summary: Get Okta SCIM IDP Integration + description: Retrieves an Okta SCIM IDP integration by ID. + operationId: getOktaScimIntegration + responses: + '200': + description: Successfully retrieved the integration details. + content: + application/json: + schema: + $ref: '#/components/schemas/OktaScimIntegration' + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found (e.g., integration with the given ID does not exist). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - IDP Okta SCIM Integrations + summary: Update Okta SCIM IDP Integration + description: Updates an existing Okta SCIM IDP integration. + operationId: updateOktaScimIntegration + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateOktaScimIntegrationRequest' + responses: + '200': + description: Integration updated successfully. Returns the updated integration. + content: + application/json: + schema: + $ref: '#/components/schemas/OktaScimIntegration' + '400': + description: Bad Request (e.g., invalid JSON, validation error, invalid ID). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - IDP Okta SCIM Integrations + summary: Delete Okta SCIM IDP Integration + description: Deletes an Okta SCIM IDP integration by ID. + operationId: deleteOktaScimIntegration + responses: + '200': + description: Integration deleted successfully. Returns an empty object. + content: + application/json: + schema: + type: object + example: { } + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/okta-scim-idp/{id}/token: + parameters: + - name: id + in: path + required: true + description: The unique identifier of the Okta SCIM IDP integration. + schema: + type: integer + format: int64 + example: 1 + post: + tags: + - IDP Okta SCIM Integrations + summary: Regenerate Okta SCIM Token + description: Regenerates the SCIM API token for an Okta SCIM IDP integration. + operationId: regenerateOktaScimToken + responses: + '200': + description: Token regenerated successfully. Returns the new token. + content: + application/json: + schema: + $ref: '#/components/schemas/ScimTokenResponse' + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/integrations/okta-scim-idp/{id}/logs: + parameters: + - name: id + in: path + required: true + description: The unique identifier of the Okta SCIM IDP integration. + schema: + type: integer + format: int64 + example: 1 + get: + tags: + - IDP Okta SCIM Integrations + summary: Get Okta SCIM Integration Sync Logs + description: Retrieves synchronization logs for an Okta SCIM IDP integration. + operationId: getOktaScimIntegrationLogs + responses: + '200': + description: Successfully retrieved the integration sync logs. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/IdpIntegrationSyncLog' + '400': + description: Bad Request (e.g., invalid integration ID format). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/integrations/scim-idp: post: tags: - - IDP + - IDP SCIM Integrations summary: Create SCIM IDP Integration description: Creates a new SCIM integration operationId: createSCIMIntegration @@ -9073,7 +10402,7 @@ paths: $ref: '#/components/schemas/ErrorResponse' get: tags: - - IDP + - IDP SCIM Integrations summary: Get All SCIM IDP Integrations description: Retrieves all SCIM IDP integrations for the authenticated account operationId: getAllSCIMIntegrations @@ -9105,11 +10434,12 @@ paths: required: true description: The unique identifier of the SCIM IDP integration. schema: - type: string - example: "ch8i4ug6lnn4g9hqv7m0" + type: integer + format: int64 + example: 1 get: tags: - - IDP + - IDP SCIM Integrations summary: Get SCIM IDP Integration description: Retrieves an SCIM IDP integration by ID. operationId: getSCIMIntegration @@ -9146,7 +10476,7 @@ paths: $ref: '#/components/schemas/ErrorResponse' put: tags: - - IDP + - IDP SCIM Integrations summary: Update SCIM IDP Integration description: Updates an existing SCIM IDP Integration. operationId: updateSCIMIntegration @@ -9189,7 +10519,7 @@ paths: $ref: '#/components/schemas/ErrorResponse' delete: tags: - - IDP + - IDP SCIM Integrations summary: Delete SCIM IDP Integration description: Deletes an SCIM IDP integration by ID. operationId: deleteSCIMIntegration @@ -9232,11 +10562,12 @@ paths: required: true description: The unique identifier of the SCIM IDP integration. schema: - type: string - example: "ch8i4ug6lnn4g9hqv7m0" + type: integer + format: int64 + example: 1 post: tags: - - IDP + - IDP SCIM Integrations summary: Regenerate SCIM Token description: Regenerates the SCIM API token for an SCIM IDP integration. operationId: regenerateSCIMToken @@ -9278,11 +10609,12 @@ paths: required: true description: The unique identifier of the SCIM IDP integration. schema: - type: string - example: "ch8i4ug6lnn4g9hqv7m0" + type: integer + format: int64 + example: 1 get: tags: - - IDP + - IDP SCIM Integrations summary: Get SCIM Integration Sync Logs description: Retrieves synchronization logs for a SCIM IDP integration. operationId: getSCIMIntegrationLogs @@ -10229,3 +11561,172 @@ paths: "$ref": "#/components/responses/not_found" '500': "$ref": "#/components/responses/internal_error" + /api/integrations/notifications/types: + get: + tags: + - Notifications + summary: List Notification Event Types + description: | + Returns a map of all supported activity event type codes to their + human-readable descriptions. Use these codes when configuring + `event_types` on notification channels. + operationId: listNotificationEventTypes + responses: + '200': + description: A map of event type codes to descriptions. + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationTypeEntry' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + /api/integrations/notifications/channels: + get: + tags: + - Notifications + summary: List Notification Channels + description: Retrieves all notification channels configured for the authenticated account. + operationId: listNotificationChannels + responses: + '200': + description: A list of notification channels. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/NotificationChannelResponse' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + post: + tags: + - Notifications + summary: Create Notification Channel + description: | + Creates a new notification channel for the authenticated account. + Supported channel types are `email` and `webhook`. + operationId: createNotificationChannel + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationChannelRequest' + responses: + '200': + description: Notification channel created successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationChannelResponse' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + /api/integrations/notifications/channels/{channelId}: + parameters: + - name: channelId + in: path + required: true + description: The unique identifier of the notification channel. + schema: + type: string + example: "ch8i4ug6lnn4g9hqv7m0" + get: + tags: + - Notifications + summary: Get Notification Channel + description: Retrieves a specific notification channel by its ID. + operationId: getNotificationChannel + responses: + '200': + description: Successfully retrieved the notification channel. + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationChannelResponse' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + put: + tags: + - Notifications + summary: Update Notification Channel + description: Updates an existing notification channel. + operationId: updateNotificationChannel + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationChannelRequest' + responses: + '200': + description: Notification channel updated successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationChannelResponse' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + delete: + tags: + - Notifications + summary: Delete Notification Channel + description: Deletes a notification channel by its ID. + operationId: deleteNotificationChannel + responses: + '200': + description: Notification channel deleted successfully. + content: + application/json: + schema: + type: object + example: { } + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 87006d6f7..c47b77455 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -9,6 +9,7 @@ import ( "time" "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" ) const ( @@ -16,6 +17,24 @@ const ( TokenAuthScopes = "TokenAuth.Scopes" ) +// Defines values for CreateAzureIntegrationRequestHost. +const ( + CreateAzureIntegrationRequestHostMicrosoftCom CreateAzureIntegrationRequestHost = "microsoft.com" + CreateAzureIntegrationRequestHostMicrosoftUs CreateAzureIntegrationRequestHost = "microsoft.us" +) + +// Valid indicates whether the value is a known member of the CreateAzureIntegrationRequestHost enum. +func (e CreateAzureIntegrationRequestHost) Valid() bool { + switch e { + case CreateAzureIntegrationRequestHostMicrosoftCom: + return true + case CreateAzureIntegrationRequestHostMicrosoftUs: + return true + default: + return false + } +} + // Defines values for CreateIntegrationRequestPlatform. const ( CreateIntegrationRequestPlatformDatadog CreateIntegrationRequestPlatform = "datadog" @@ -664,6 +683,24 @@ func (e NetworkResourceType) Valid() bool { } } +// Defines values for NotificationChannelType. +const ( + NotificationChannelTypeEmail NotificationChannelType = "email" + NotificationChannelTypeWebhook NotificationChannelType = "webhook" +) + +// Valid indicates whether the value is a known member of the NotificationChannelType enum. +func (e NotificationChannelType) Valid() bool { + switch e { + case NotificationChannelTypeEmail: + return true + case NotificationChannelTypeWebhook: + return true + default: + return false + } +} + // Defines values for PeerNetworkRangeCheckAction. const ( PeerNetworkRangeCheckActionAllow PeerNetworkRangeCheckAction = "allow" @@ -1276,6 +1313,21 @@ func (e PutApiIntegrationsMspTenantsIdInviteJSONBodyValue) Valid() bool { } } +// AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services. +type AccessRestrictions struct { + // AllowedCidrs CIDR allowlist. If non-empty, only IPs matching these CIDRs are allowed. + AllowedCidrs *[]string `json:"allowed_cidrs,omitempty"` + + // AllowedCountries ISO 3166-1 alpha-2 country codes to allow. If non-empty, only these countries are permitted. + AllowedCountries *[]string `json:"allowed_countries,omitempty"` + + // BlockedCidrs CIDR blocklist. Connections from these CIDRs are rejected. Evaluated after allowed_cidrs. + BlockedCidrs *[]string `json:"blocked_cidrs,omitempty"` + + // BlockedCountries ISO 3166-1 alpha-2 country codes to block. + BlockedCountries *[]string `json:"blocked_countries,omitempty"` +} + // AccessiblePeer defines model for AccessiblePeer. type AccessiblePeer struct { // CityName Commonly used English name of the city @@ -1435,6 +1487,36 @@ type AvailablePorts struct { Udp int `json:"udp"` } +// AzureIntegration defines model for AzureIntegration. +type AzureIntegration struct { + // ClientId Azure AD application (client) ID + ClientId string `json:"client_id"` + + // Enabled Whether the integration is enabled + Enabled bool `json:"enabled"` + + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes []string `json:"group_prefixes"` + + // Host Azure host domain for the Graph API + Host string `json:"host"` + + // Id The unique identifier for the integration + Id int64 `json:"id"` + + // LastSyncedAt Timestamp of the last synchronization + LastSyncedAt time.Time `json:"last_synced_at"` + + // SyncInterval Sync interval in seconds + SyncInterval int `json:"sync_interval"` + + // TenantId Azure AD tenant ID + TenantId string `json:"tenant_id"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes []string `json:"user_group_prefixes"` +} + // BearerAuthConfig defines model for BearerAuthConfig. type BearerAuthConfig struct { // DistributionGroups List of group IDs that can use bearer auth @@ -1542,6 +1624,51 @@ type Country struct { // CountryCode 2-letter ISO 3166-1 alpha-2 code that represents the country type CountryCode = string +// CreateAzureIntegrationRequest defines model for CreateAzureIntegrationRequest. +type CreateAzureIntegrationRequest struct { + // ClientId Azure AD application (client) ID + ClientId string `json:"client_id"` + + // ClientSecret Base64-encoded Azure AD client secret + ClientSecret string `json:"client_secret"` + + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes *[]string `json:"group_prefixes,omitempty"` + + // Host Azure host domain for the Graph API + Host CreateAzureIntegrationRequestHost `json:"host"` + + // SyncInterval Sync interval in seconds (minimum 300). Defaults to 300 if not specified. + SyncInterval *int `json:"sync_interval,omitempty"` + + // TenantId Azure AD tenant ID + TenantId string `json:"tenant_id"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes *[]string `json:"user_group_prefixes,omitempty"` +} + +// CreateAzureIntegrationRequestHost Azure host domain for the Graph API +type CreateAzureIntegrationRequestHost string + +// CreateGoogleIntegrationRequest defines model for CreateGoogleIntegrationRequest. +type CreateGoogleIntegrationRequest struct { + // CustomerId Customer ID from Google Workspace Account Settings + CustomerId string `json:"customer_id"` + + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes *[]string `json:"group_prefixes,omitempty"` + + // ServiceAccountKey Base64-encoded Google service account key + ServiceAccountKey string `json:"service_account_key"` + + // SyncInterval Sync interval in seconds (minimum 300). Defaults to 300 if not specified. + SyncInterval *int `json:"sync_interval,omitempty"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes *[]string `json:"user_group_prefixes,omitempty"` +} + // CreateIntegrationRequest Request payload for creating a new event streaming integration. Also used as the structure for the PUT request body, but not all fields are applicable for updates (see PUT operation description). type CreateIntegrationRequest struct { // Config Platform-specific configuration as key-value pairs. For creation, all necessary credentials and settings must be provided. For updates, provide the fields to change or the entire new configuration. @@ -1557,7 +1684,19 @@ type CreateIntegrationRequest struct { // CreateIntegrationRequestPlatform The event streaming platform to integrate with (e.g., "datadog", "s3", "firehose"). This field is used for creation. For updates (PUT), this field, if sent, is ignored by the backend. type CreateIntegrationRequestPlatform string -// CreateScimIntegrationRequest Request payload for creating an SCIM IDP integration +// CreateOktaScimIntegrationRequest defines model for CreateOktaScimIntegrationRequest. +type CreateOktaScimIntegrationRequest struct { + // ConnectionName The Okta enterprise connection name on Auth0 + ConnectionName string `json:"connection_name"` + + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes *[]string `json:"group_prefixes,omitempty"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes *[]string `json:"user_group_prefixes,omitempty"` +} + +// CreateScimIntegrationRequest defines model for CreateScimIntegrationRequest. type CreateScimIntegrationRequest struct { // GroupPrefixes List of start_with string patterns for groups to sync GroupPrefixes *[]string `json:"group_prefixes,omitempty"` @@ -1935,6 +2074,12 @@ type EDRSentinelOneResponse struct { UpdatedAt time.Time `json:"updated_at"` } +// EmailTarget Target configuration for email notification channels. +type EmailTarget struct { + // Emails List of email addresses to send notifications to. + Emails []openapi_types.Email `json:"emails"` +} + // ErrorResponse Standard error response. Note: The exact structure of this error response is inferred from `util.WriteErrorResponse` and `util.WriteError` usage in the provided Go code, as a specific Go struct for errors was not provided. type ErrorResponse struct { // Message A human-readable error message. @@ -2007,6 +2152,30 @@ type GeoLocationCheckAction string // GetTenantsResponse defines model for GetTenantsResponse. type GetTenantsResponse = []TenantResponse +// GoogleIntegration defines model for GoogleIntegration. +type GoogleIntegration struct { + // CustomerId Customer ID from Google Workspace + CustomerId string `json:"customer_id"` + + // Enabled Whether the integration is enabled + Enabled bool `json:"enabled"` + + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes []string `json:"group_prefixes"` + + // Id The unique identifier for the integration + Id int64 `json:"id"` + + // LastSyncedAt Timestamp of the last synchronization + LastSyncedAt time.Time `json:"last_synced_at"` + + // SyncInterval Sync interval in seconds + SyncInterval int `json:"sync_interval"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes []string `json:"user_group_prefixes"` +} + // Group defines model for Group. type Group struct { // Id Group ID @@ -2063,6 +2232,18 @@ type GroupRequest struct { Resources *[]Resource `json:"resources,omitempty"` } +// HeaderAuthConfig Static header-value authentication. The proxy checks that the named header matches the configured value. +type HeaderAuthConfig struct { + // Enabled Whether header auth is enabled + Enabled bool `json:"enabled"` + + // Header HTTP header name to check (e.g. "Authorization", "X-API-Key") + Header string `json:"header"` + + // Value Expected header value. For Basic auth use "Basic base64(user:pass)". For Bearer use "Bearer token". Cleared in responses. + Value string `json:"value"` +} + // HuntressMatchAttributes Attribute conditions to match when approving agents type HuntressMatchAttributes struct { // DefenderPolicyStatus Policy status of Defender AV for Managed Antivirus. @@ -2286,6 +2467,12 @@ type InstanceVersionInfo struct { ManagementUpdateAvailable bool `json:"management_update_available"` } +// IntegrationEnabled defines model for IntegrationEnabled. +type IntegrationEnabled struct { + // Enabled Whether the integration is enabled + Enabled *bool `json:"enabled,omitempty"` +} + // IntegrationResponse Represents an event streaming integration. type IntegrationResponse struct { // AccountId The identifier of the account this integration belongs to. @@ -2313,6 +2500,15 @@ type IntegrationResponse struct { // IntegrationResponsePlatform The event streaming platform. type IntegrationResponsePlatform string +// IntegrationSyncFilters defines model for IntegrationSyncFilters. +type IntegrationSyncFilters struct { + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes *[]string `json:"group_prefixes,omitempty"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes *[]string `json:"user_group_prefixes,omitempty"` +} + // InvoicePDFResponse defines model for InvoicePDFResponse. type InvoicePDFResponse struct { // Url URL to redirect the user to invoice. @@ -2714,6 +2910,67 @@ type NetworkTrafficUser struct { Name string `json:"name"` } +// NotificationChannelRequest Request body for creating or updating a notification channel. +type NotificationChannelRequest struct { + // Enabled Whether this notification channel is active. + Enabled bool `json:"enabled"` + + // EventTypes List of activity event type codes this channel subscribes to. + EventTypes []NotificationEventType `json:"event_types"` + + // Target Channel-specific target configuration. The shape depends on the `type` field: + // - `email`: requires an `EmailTarget` object + // - `webhook`: requires a `WebhookTarget` object + Target *NotificationChannelRequest_Target `json:"target,omitempty"` + + // Type The type of notification channel. + Type NotificationChannelType `json:"type"` +} + +// NotificationChannelRequest_Target Channel-specific target configuration. The shape depends on the `type` field: +// - `email`: requires an `EmailTarget` object +// - `webhook`: requires a `WebhookTarget` object +type NotificationChannelRequest_Target struct { + union json.RawMessage +} + +// NotificationChannelResponse A notification channel configuration. +type NotificationChannelResponse struct { + // Enabled Whether this notification channel is active. + Enabled bool `json:"enabled"` + + // EventTypes List of activity event type codes this channel subscribes to. + EventTypes []NotificationEventType `json:"event_types"` + + // Id Unique identifier of the notification channel. + Id *string `json:"id,omitempty"` + + // Target Channel-specific target configuration. The shape depends on the `type` field: + // - `email`: an `EmailTarget` object + // - `webhook`: a `WebhookTarget` object + Target *NotificationChannelResponse_Target `json:"target,omitempty"` + + // Type The type of notification channel. + Type NotificationChannelType `json:"type"` +} + +// NotificationChannelResponse_Target Channel-specific target configuration. The shape depends on the `type` field: +// - `email`: an `EmailTarget` object +// - `webhook`: a `WebhookTarget` object +type NotificationChannelResponse_Target struct { + union json.RawMessage +} + +// NotificationChannelType The type of notification channel. +type NotificationChannelType string + +// NotificationEventType An activity event type code. See `GET /api/integrations/notifications/types` for the full list +// of supported event types and their human-readable descriptions. +type NotificationEventType = string + +// NotificationTypeEntry A map of event type codes to their human-readable descriptions. +type NotificationTypeEntry map[string]string + // OSVersionCheck Posture check for the version of operating system type OSVersionCheck struct { // Android Posture check for the version of operating system @@ -2732,6 +2989,27 @@ type OSVersionCheck struct { Windows *MinKernelVersionCheck `json:"windows,omitempty"` } +// OktaScimIntegration defines model for OktaScimIntegration. +type OktaScimIntegration struct { + // AuthToken SCIM API token (full on creation/regeneration, masked on retrieval) + AuthToken string `json:"auth_token"` + + // Enabled Whether the integration is enabled + Enabled bool `json:"enabled"` + + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes []string `json:"group_prefixes"` + + // Id The unique identifier for the integration + Id int64 `json:"id"` + + // LastSyncedAt Timestamp of the last synchronization + LastSyncedAt time.Time `json:"last_synced_at"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes []string `json:"user_group_prefixes"` +} + // PINAuthConfig defines model for PINAuthConfig. type PINAuthConfig struct { // Enabled Whether PIN auth is enabled @@ -3399,6 +3677,9 @@ type ProxyAccessLog struct { // StatusCode HTTP status code returned StatusCode int `json:"status_code"` + // SubdivisionCode First-level administrative subdivision ISO code (e.g. state/province) + SubdivisionCode *string `json:"subdivision_code,omitempty"` + // Timestamp Timestamp when the request was made Timestamp time.Time `json:"timestamp"` @@ -3451,6 +3732,9 @@ type ReverseProxyDomain struct { // Id Domain ID Id string `json:"id"` + // RequireSubdomain Whether a subdomain label is required in front of this domain. When true, the domain cannot be used bare. + RequireSubdomain *bool `json:"require_subdomain,omitempty"` + // SupportsCustomPorts Whether the cluster supports binding arbitrary TCP/UDP ports SupportsCustomPorts *bool `json:"supports_custom_ports,omitempty"` @@ -3575,12 +3859,12 @@ type RulePortRange struct { Start int `json:"start"` } -// ScimIntegration Represents a SCIM IDP integration +// ScimIntegration defines model for ScimIntegration. type ScimIntegration struct { // AuthToken SCIM API token (full on creation, masked otherwise) AuthToken string `json:"auth_token"` - // Enabled Indicates whether the integration is enabled + // Enabled Whether the integration is enabled Enabled bool `json:"enabled"` // GroupPrefixes List of start_with string patterns for groups to sync @@ -3592,6 +3876,9 @@ type ScimIntegration struct { // LastSyncedAt Timestamp of when the integration was last synced LastSyncedAt time.Time `json:"last_synced_at"` + // Prefix The connection prefix used for the SCIM provider + Prefix string `json:"prefix"` + // Provider Name of the SCIM identity provider Provider string `json:"provider"` @@ -3637,7 +3924,9 @@ type SentinelOneMatchAttributesNetworkStatus string // Service defines model for Service. type Service struct { - Auth ServiceAuthConfig `json:"auth"` + // AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services. + AccessRestrictions *AccessRestrictions `json:"access_restrictions,omitempty"` + Auth ServiceAuthConfig `json:"auth"` // Domain Domain for the service Domain string `json:"domain"` @@ -3672,6 +3961,9 @@ type Service struct { // Targets List of target backends for this service Targets []ServiceTarget `json:"targets"` + + // Terminated Whether the service has been terminated. Terminated services cannot be updated. Services that violate the Terms of Service will be terminated. + Terminated *bool `json:"terminated,omitempty"` } // ServiceMode Service mode. "http" for L7 reverse proxy, "tcp"/"udp"/"tls" for L4 passthrough. @@ -3680,6 +3972,7 @@ type ServiceMode string // ServiceAuthConfig defines model for ServiceAuthConfig. type ServiceAuthConfig struct { BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty"` + HeaderAuths *[]HeaderAuthConfig `json:"header_auths,omitempty"` LinkAuth *LinkAuthConfig `json:"link_auth,omitempty"` PasswordAuth *PasswordAuthConfig `json:"password_auth,omitempty"` PinAuth *PINAuthConfig `json:"pin_auth,omitempty"` @@ -3702,7 +3995,9 @@ type ServiceMetaStatus string // ServiceRequest defines model for ServiceRequest. type ServiceRequest struct { - Auth *ServiceAuthConfig `json:"auth,omitempty"` + // AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services. + AccessRestrictions *AccessRestrictions `json:"access_restrictions,omitempty"` + Auth *ServiceAuthConfig `json:"auth,omitempty"` // Domain Domain for the service Domain string `json:"domain"` @@ -3777,7 +4072,7 @@ type ServiceTargetOptions struct { // RequestTimeout Per-target response timeout as a Go duration string (e.g. "30s", "2m") RequestTimeout *string `json:"request_timeout,omitempty"` - // SessionIdleTimeout Idle timeout before a UDP session is reaped, as a Go duration string (e.g. "30s", "2m"). Maximum 10m. + // SessionIdleTimeout Idle timeout before a UDP session is reaped, as a Go duration string (e.g. "30s", "2m"). SessionIdleTimeout *string `json:"session_idle_timeout,omitempty"` // SkipTlsVerify Skip TLS certificate verification for this backend @@ -3988,6 +4283,11 @@ type Subscription struct { UpdatedAt time.Time `json:"updated_at"` } +// SyncResult Response for a manual sync trigger +type SyncResult struct { + Result *string `json:"result,omitempty"` +} + // TenantGroupResponse defines model for TenantGroupResponse. type TenantGroupResponse struct { // Id The Group ID @@ -4033,14 +4333,74 @@ type TenantResponse struct { // TenantResponseStatus The status of the tenant type TenantResponseStatus string -// UpdateScimIntegrationRequest Request payload for updating an SCIM IDP integration -type UpdateScimIntegrationRequest struct { - // Enabled Indicates whether the integration is enabled +// UpdateAzureIntegrationRequest defines model for UpdateAzureIntegrationRequest. +type UpdateAzureIntegrationRequest struct { + // ClientId Azure AD application (client) ID + ClientId *string `json:"client_id,omitempty"` + + // ClientSecret Base64-encoded Azure AD client secret + ClientSecret *string `json:"client_secret,omitempty"` + + // Enabled Whether the integration is enabled Enabled *bool `json:"enabled,omitempty"` // GroupPrefixes List of start_with string patterns for groups to sync GroupPrefixes *[]string `json:"group_prefixes,omitempty"` + // SyncInterval Sync interval in seconds (minimum 300) + SyncInterval *int `json:"sync_interval,omitempty"` + + // TenantId Azure AD tenant ID + TenantId *string `json:"tenant_id,omitempty"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes *[]string `json:"user_group_prefixes,omitempty"` +} + +// UpdateGoogleIntegrationRequest defines model for UpdateGoogleIntegrationRequest. +type UpdateGoogleIntegrationRequest struct { + // CustomerId Customer ID from Google Workspace Account Settings + CustomerId *string `json:"customer_id,omitempty"` + + // Enabled Whether the integration is enabled + Enabled *bool `json:"enabled,omitempty"` + + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes *[]string `json:"group_prefixes,omitempty"` + + // ServiceAccountKey Base64-encoded Google service account key + ServiceAccountKey *string `json:"service_account_key,omitempty"` + + // SyncInterval Sync interval in seconds (minimum 300) + SyncInterval *int `json:"sync_interval,omitempty"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes *[]string `json:"user_group_prefixes,omitempty"` +} + +// UpdateOktaScimIntegrationRequest defines model for UpdateOktaScimIntegrationRequest. +type UpdateOktaScimIntegrationRequest struct { + // Enabled Whether the integration is enabled + Enabled *bool `json:"enabled,omitempty"` + + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes *[]string `json:"group_prefixes,omitempty"` + + // UserGroupPrefixes List of start_with string patterns for groups which users to sync + UserGroupPrefixes *[]string `json:"user_group_prefixes,omitempty"` +} + +// UpdateScimIntegrationRequest defines model for UpdateScimIntegrationRequest. +type UpdateScimIntegrationRequest struct { + // Enabled Whether the integration is enabled + Enabled *bool `json:"enabled,omitempty"` + + // GroupPrefixes List of start_with string patterns for groups to sync + GroupPrefixes *[]string `json:"group_prefixes,omitempty"` + + // Prefix The connection prefix used for the SCIM provider + Prefix *string `json:"prefix,omitempty"` + // UserGroupPrefixes List of start_with string patterns for groups which users to sync UserGroupPrefixes *[]string `json:"user_group_prefixes,omitempty"` } @@ -4248,6 +4608,16 @@ type UserRequest struct { Role string `json:"role"` } +// WebhookTarget Target configuration for webhook notification channels. +type WebhookTarget struct { + // Headers Custom HTTP headers sent with each webhook request. + // Values are write-only; in GET responses all values are masked. + Headers *map[string]string `json:"headers,omitempty"` + + // Url The webhook endpoint URL to send notifications to. + Url string `json:"url"` +} + // WorkloadRequest defines model for WorkloadRequest. type WorkloadRequest struct { union json.RawMessage @@ -4550,6 +4920,12 @@ type PostApiIngressPeersJSONRequestBody = IngressPeerCreateRequest // PutApiIngressPeersIngressPeerIdJSONRequestBody defines body for PutApiIngressPeersIngressPeerId for application/json ContentType. type PutApiIngressPeersIngressPeerIdJSONRequestBody = IngressPeerUpdateRequest +// CreateAzureIntegrationJSONRequestBody defines body for CreateAzureIntegration for application/json ContentType. +type CreateAzureIntegrationJSONRequestBody = CreateAzureIntegrationRequest + +// UpdateAzureIntegrationJSONRequestBody defines body for UpdateAzureIntegration for application/json ContentType. +type UpdateAzureIntegrationJSONRequestBody = UpdateAzureIntegrationRequest + // PostApiIntegrationsBillingAwsMarketplaceActivateJSONRequestBody defines body for PostApiIntegrationsBillingAwsMarketplaceActivate for application/json ContentType. type PostApiIntegrationsBillingAwsMarketplaceActivateJSONRequestBody PostApiIntegrationsBillingAwsMarketplaceActivateJSONBody @@ -4592,6 +4968,12 @@ type CreateSentinelOneEDRIntegrationJSONRequestBody = EDRSentinelOneRequest // UpdateSentinelOneEDRIntegrationJSONRequestBody defines body for UpdateSentinelOneEDRIntegration for application/json ContentType. type UpdateSentinelOneEDRIntegrationJSONRequestBody = EDRSentinelOneRequest +// CreateGoogleIntegrationJSONRequestBody defines body for CreateGoogleIntegration for application/json ContentType. +type CreateGoogleIntegrationJSONRequestBody = CreateGoogleIntegrationRequest + +// UpdateGoogleIntegrationJSONRequestBody defines body for UpdateGoogleIntegration for application/json ContentType. +type UpdateGoogleIntegrationJSONRequestBody = UpdateGoogleIntegrationRequest + // PostApiIntegrationsMspTenantsJSONRequestBody defines body for PostApiIntegrationsMspTenants for application/json ContentType. type PostApiIntegrationsMspTenantsJSONRequestBody = CreateTenantRequest @@ -4607,6 +4989,18 @@ type PostApiIntegrationsMspTenantsIdSubscriptionJSONRequestBody PostApiIntegrati // PostApiIntegrationsMspTenantsIdUnlinkJSONRequestBody defines body for PostApiIntegrationsMspTenantsIdUnlink for application/json ContentType. type PostApiIntegrationsMspTenantsIdUnlinkJSONRequestBody PostApiIntegrationsMspTenantsIdUnlinkJSONBody +// CreateNotificationChannelJSONRequestBody defines body for CreateNotificationChannel for application/json ContentType. +type CreateNotificationChannelJSONRequestBody = NotificationChannelRequest + +// UpdateNotificationChannelJSONRequestBody defines body for UpdateNotificationChannel for application/json ContentType. +type UpdateNotificationChannelJSONRequestBody = NotificationChannelRequest + +// CreateOktaScimIntegrationJSONRequestBody defines body for CreateOktaScimIntegration for application/json ContentType. +type CreateOktaScimIntegrationJSONRequestBody = CreateOktaScimIntegrationRequest + +// UpdateOktaScimIntegrationJSONRequestBody defines body for UpdateOktaScimIntegration for application/json ContentType. +type UpdateOktaScimIntegrationJSONRequestBody = UpdateOktaScimIntegrationRequest + // CreateSCIMIntegrationJSONRequestBody defines body for CreateSCIMIntegration for application/json ContentType. type CreateSCIMIntegrationJSONRequestBody = CreateScimIntegrationRequest @@ -4703,6 +5097,130 @@ type PutApiUsersUserIdPasswordJSONRequestBody = PasswordChangeRequest // PostApiUsersUserIdTokensJSONRequestBody defines body for PostApiUsersUserIdTokens for application/json ContentType. type PostApiUsersUserIdTokensJSONRequestBody = PersonalAccessTokenRequest +// AsEmailTarget returns the union data inside the NotificationChannelRequest_Target as a EmailTarget +func (t NotificationChannelRequest_Target) AsEmailTarget() (EmailTarget, error) { + var body EmailTarget + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromEmailTarget overwrites any union data inside the NotificationChannelRequest_Target as the provided EmailTarget +func (t *NotificationChannelRequest_Target) FromEmailTarget(v EmailTarget) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeEmailTarget performs a merge with any union data inside the NotificationChannelRequest_Target, using the provided EmailTarget +func (t *NotificationChannelRequest_Target) MergeEmailTarget(v EmailTarget) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsWebhookTarget returns the union data inside the NotificationChannelRequest_Target as a WebhookTarget +func (t NotificationChannelRequest_Target) AsWebhookTarget() (WebhookTarget, error) { + var body WebhookTarget + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromWebhookTarget overwrites any union data inside the NotificationChannelRequest_Target as the provided WebhookTarget +func (t *NotificationChannelRequest_Target) FromWebhookTarget(v WebhookTarget) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeWebhookTarget performs a merge with any union data inside the NotificationChannelRequest_Target, using the provided WebhookTarget +func (t *NotificationChannelRequest_Target) MergeWebhookTarget(v WebhookTarget) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t NotificationChannelRequest_Target) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *NotificationChannelRequest_Target) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsEmailTarget returns the union data inside the NotificationChannelResponse_Target as a EmailTarget +func (t NotificationChannelResponse_Target) AsEmailTarget() (EmailTarget, error) { + var body EmailTarget + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromEmailTarget overwrites any union data inside the NotificationChannelResponse_Target as the provided EmailTarget +func (t *NotificationChannelResponse_Target) FromEmailTarget(v EmailTarget) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeEmailTarget performs a merge with any union data inside the NotificationChannelResponse_Target, using the provided EmailTarget +func (t *NotificationChannelResponse_Target) MergeEmailTarget(v EmailTarget) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsWebhookTarget returns the union data inside the NotificationChannelResponse_Target as a WebhookTarget +func (t NotificationChannelResponse_Target) AsWebhookTarget() (WebhookTarget, error) { + var body WebhookTarget + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromWebhookTarget overwrites any union data inside the NotificationChannelResponse_Target as the provided WebhookTarget +func (t *NotificationChannelResponse_Target) FromWebhookTarget(v WebhookTarget) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeWebhookTarget performs a merge with any union data inside the NotificationChannelResponse_Target, using the provided WebhookTarget +func (t *NotificationChannelResponse_Target) MergeWebhookTarget(v WebhookTarget) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t NotificationChannelResponse_Target) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *NotificationChannelResponse_Target) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // AsBundleWorkloadRequest returns the union data inside the WorkloadRequest as a BundleWorkloadRequest func (t WorkloadRequest) AsBundleWorkloadRequest() (BundleWorkloadRequest, error) { var body BundleWorkloadRequest diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go index 115ac5101..93295e857 100644 --- a/shared/management/proto/proxy_service.pb.go +++ b/shared/management/proto/proxy_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.26.0 +// protoc-gen-go v1.36.6 // protoc v6.33.3 // source: proxy_service.proto @@ -13,6 +13,7 @@ import ( timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" + unsafe "unsafe" ) const ( @@ -177,21 +178,21 @@ func (ProxyStatus) EnumDescriptor() ([]byte, []int) { // ProxyCapabilities describes what a proxy can handle. type ProxyCapabilities struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // Whether the proxy can bind arbitrary ports for TCP/UDP/TLS services. SupportsCustomPorts *bool `protobuf:"varint,1,opt,name=supports_custom_ports,json=supportsCustomPorts,proto3,oneof" json:"supports_custom_ports,omitempty"` + // Whether the proxy requires a subdomain label in front of its cluster domain. + // When true, tenants cannot use the cluster domain bare. + RequireSubdomain *bool `protobuf:"varint,2,opt,name=require_subdomain,json=requireSubdomain,proto3,oneof" json:"require_subdomain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProxyCapabilities) Reset() { *x = ProxyCapabilities{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ProxyCapabilities) String() string { @@ -202,7 +203,7 @@ func (*ProxyCapabilities) ProtoMessage() {} func (x *ProxyCapabilities) ProtoReflect() protoreflect.Message { mi := &file_proxy_service_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -224,26 +225,30 @@ func (x *ProxyCapabilities) GetSupportsCustomPorts() bool { return false } +func (x *ProxyCapabilities) GetRequireSubdomain() bool { + if x != nil && x.RequireSubdomain != nil { + return *x.RequireSubdomain + } + return false +} + // GetMappingUpdateRequest is sent to initialise a mapping stream. type GetMappingUpdateRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + ProxyId string `protobuf:"bytes,1,opt,name=proxy_id,json=proxyId,proto3" json:"proxy_id,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + StartedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + Address string `protobuf:"bytes,4,opt,name=address,proto3" json:"address,omitempty"` + Capabilities *ProxyCapabilities `protobuf:"bytes,5,opt,name=capabilities,proto3" json:"capabilities,omitempty"` unknownFields protoimpl.UnknownFields - - ProxyId string `protobuf:"bytes,1,opt,name=proxy_id,json=proxyId,proto3" json:"proxy_id,omitempty"` - Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - StartedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` - Address string `protobuf:"bytes,4,opt,name=address,proto3" json:"address,omitempty"` - Capabilities *ProxyCapabilities `protobuf:"bytes,5,opt,name=capabilities,proto3" json:"capabilities,omitempty"` + sizeCache protoimpl.SizeCache } func (x *GetMappingUpdateRequest) Reset() { *x = GetMappingUpdateRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *GetMappingUpdateRequest) String() string { @@ -254,7 +259,7 @@ func (*GetMappingUpdateRequest) ProtoMessage() {} func (x *GetMappingUpdateRequest) ProtoReflect() protoreflect.Message { mi := &file_proxy_service_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -308,23 +313,20 @@ func (x *GetMappingUpdateRequest) GetCapabilities() *ProxyCapabilities { // No mappings may be sent to test the liveness of the Proxy. // Mappings that are sent should be interpreted by the Proxy appropriately. type GetMappingUpdateResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Mapping []*ProxyMapping `protobuf:"bytes,1,rep,name=mapping,proto3" json:"mapping,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Mapping []*ProxyMapping `protobuf:"bytes,1,rep,name=mapping,proto3" json:"mapping,omitempty"` // initial_sync_complete is set on the last message of the initial snapshot. // The proxy uses this to signal that startup is complete. InitialSyncComplete bool `protobuf:"varint,2,opt,name=initial_sync_complete,json=initialSyncComplete,proto3" json:"initial_sync_complete,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetMappingUpdateResponse) Reset() { *x = GetMappingUpdateResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *GetMappingUpdateResponse) String() string { @@ -335,7 +337,7 @@ func (*GetMappingUpdateResponse) ProtoMessage() {} func (x *GetMappingUpdateResponse) ProtoReflect() protoreflect.Message { mi := &file_proxy_service_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -365,27 +367,24 @@ func (x *GetMappingUpdateResponse) GetInitialSyncComplete() bool { } type PathTargetOptions struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - SkipTlsVerify bool `protobuf:"varint,1,opt,name=skip_tls_verify,json=skipTlsVerify,proto3" json:"skip_tls_verify,omitempty"` - RequestTimeout *durationpb.Duration `protobuf:"bytes,2,opt,name=request_timeout,json=requestTimeout,proto3" json:"request_timeout,omitempty"` - PathRewrite PathRewriteMode `protobuf:"varint,3,opt,name=path_rewrite,json=pathRewrite,proto3,enum=management.PathRewriteMode" json:"path_rewrite,omitempty"` - CustomHeaders map[string]string `protobuf:"bytes,4,rep,name=custom_headers,json=customHeaders,proto3" json:"custom_headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + state protoimpl.MessageState `protogen:"open.v1"` + SkipTlsVerify bool `protobuf:"varint,1,opt,name=skip_tls_verify,json=skipTlsVerify,proto3" json:"skip_tls_verify,omitempty"` + RequestTimeout *durationpb.Duration `protobuf:"bytes,2,opt,name=request_timeout,json=requestTimeout,proto3" json:"request_timeout,omitempty"` + PathRewrite PathRewriteMode `protobuf:"varint,3,opt,name=path_rewrite,json=pathRewrite,proto3,enum=management.PathRewriteMode" json:"path_rewrite,omitempty"` + CustomHeaders map[string]string `protobuf:"bytes,4,rep,name=custom_headers,json=customHeaders,proto3" json:"custom_headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Send PROXY protocol v2 header to this backend. ProxyProtocol bool `protobuf:"varint,5,opt,name=proxy_protocol,json=proxyProtocol,proto3" json:"proxy_protocol,omitempty"` // Idle timeout before a UDP session is reaped. SessionIdleTimeout *durationpb.Duration `protobuf:"bytes,6,opt,name=session_idle_timeout,json=sessionIdleTimeout,proto3" json:"session_idle_timeout,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PathTargetOptions) Reset() { *x = PathTargetOptions{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *PathTargetOptions) String() string { @@ -396,7 +395,7 @@ func (*PathTargetOptions) ProtoMessage() {} func (x *PathTargetOptions) ProtoReflect() protoreflect.Message { mi := &file_proxy_service_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -454,22 +453,19 @@ func (x *PathTargetOptions) GetSessionIdleTimeout() *durationpb.Duration { } type PathMapping struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + Options *PathTargetOptions `protobuf:"bytes,3,opt,name=options,proto3" json:"options,omitempty"` unknownFields protoimpl.UnknownFields - - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` - Options *PathTargetOptions `protobuf:"bytes,3,opt,name=options,proto3" json:"options,omitempty"` + sizeCache protoimpl.SizeCache } func (x *PathMapping) Reset() { *x = PathMapping{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *PathMapping) String() string { @@ -480,7 +476,7 @@ func (*PathMapping) ProtoMessage() {} func (x *PathMapping) ProtoReflect() protoreflect.Message { mi := &file_proxy_service_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -516,25 +512,77 @@ func (x *PathMapping) GetOptions() *PathTargetOptions { return nil } -type Authentication struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache +type HeaderAuth struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Header name to check, e.g. "Authorization", "X-API-Key". + Header string `protobuf:"bytes,1,opt,name=header,proto3" json:"header,omitempty"` + // argon2id hash of the expected full header value. + HashedValue string `protobuf:"bytes,2,opt,name=hashed_value,json=hashedValue,proto3" json:"hashed_value,omitempty"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} - SessionKey string `protobuf:"bytes,1,opt,name=session_key,json=sessionKey,proto3" json:"session_key,omitempty"` - MaxSessionAgeSeconds int64 `protobuf:"varint,2,opt,name=max_session_age_seconds,json=maxSessionAgeSeconds,proto3" json:"max_session_age_seconds,omitempty"` - Password bool `protobuf:"varint,3,opt,name=password,proto3" json:"password,omitempty"` - Pin bool `protobuf:"varint,4,opt,name=pin,proto3" json:"pin,omitempty"` - Oidc bool `protobuf:"varint,5,opt,name=oidc,proto3" json:"oidc,omitempty"` +func (x *HeaderAuth) Reset() { + *x = HeaderAuth{} + mi := &file_proxy_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeaderAuth) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeaderAuth) ProtoMessage() {} + +func (x *HeaderAuth) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeaderAuth.ProtoReflect.Descriptor instead. +func (*HeaderAuth) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{5} +} + +func (x *HeaderAuth) GetHeader() string { + if x != nil { + return x.Header + } + return "" +} + +func (x *HeaderAuth) GetHashedValue() string { + if x != nil { + return x.HashedValue + } + return "" +} + +type Authentication struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionKey string `protobuf:"bytes,1,opt,name=session_key,json=sessionKey,proto3" json:"session_key,omitempty"` + MaxSessionAgeSeconds int64 `protobuf:"varint,2,opt,name=max_session_age_seconds,json=maxSessionAgeSeconds,proto3" json:"max_session_age_seconds,omitempty"` + Password bool `protobuf:"varint,3,opt,name=password,proto3" json:"password,omitempty"` + Pin bool `protobuf:"varint,4,opt,name=pin,proto3" json:"pin,omitempty"` + Oidc bool `protobuf:"varint,5,opt,name=oidc,proto3" json:"oidc,omitempty"` + HeaderAuths []*HeaderAuth `protobuf:"bytes,6,rep,name=header_auths,json=headerAuths,proto3" json:"header_auths,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Authentication) Reset() { *x = Authentication{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Authentication) String() string { @@ -544,8 +592,8 @@ func (x *Authentication) String() string { func (*Authentication) ProtoMessage() {} func (x *Authentication) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[6] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -557,7 +605,7 @@ func (x *Authentication) ProtoReflect() protoreflect.Message { // Deprecated: Use Authentication.ProtoReflect.Descriptor instead. func (*Authentication) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{5} + return file_proxy_service_proto_rawDescGZIP(), []int{6} } func (x *Authentication) GetSessionKey() string { @@ -595,11 +643,83 @@ func (x *Authentication) GetOidc() bool { return false } -type ProxyMapping struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields +func (x *Authentication) GetHeaderAuths() []*HeaderAuth { + if x != nil { + return x.HeaderAuths + } + return nil +} +type AccessRestrictions struct { + state protoimpl.MessageState `protogen:"open.v1"` + AllowedCidrs []string `protobuf:"bytes,1,rep,name=allowed_cidrs,json=allowedCidrs,proto3" json:"allowed_cidrs,omitempty"` + BlockedCidrs []string `protobuf:"bytes,2,rep,name=blocked_cidrs,json=blockedCidrs,proto3" json:"blocked_cidrs,omitempty"` + AllowedCountries []string `protobuf:"bytes,3,rep,name=allowed_countries,json=allowedCountries,proto3" json:"allowed_countries,omitempty"` + BlockedCountries []string `protobuf:"bytes,4,rep,name=blocked_countries,json=blockedCountries,proto3" json:"blocked_countries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccessRestrictions) Reset() { + *x = AccessRestrictions{} + mi := &file_proxy_service_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccessRestrictions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccessRestrictions) ProtoMessage() {} + +func (x *AccessRestrictions) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccessRestrictions.ProtoReflect.Descriptor instead. +func (*AccessRestrictions) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{7} +} + +func (x *AccessRestrictions) GetAllowedCidrs() []string { + if x != nil { + return x.AllowedCidrs + } + return nil +} + +func (x *AccessRestrictions) GetBlockedCidrs() []string { + if x != nil { + return x.BlockedCidrs + } + return nil +} + +func (x *AccessRestrictions) GetAllowedCountries() []string { + if x != nil { + return x.AllowedCountries + } + return nil +} + +func (x *AccessRestrictions) GetBlockedCountries() []string { + if x != nil { + return x.BlockedCountries + } + return nil +} + +type ProxyMapping struct { + state protoimpl.MessageState `protogen:"open.v1"` Type ProxyMappingUpdateType `protobuf:"varint,1,opt,name=type,proto3,enum=management.ProxyMappingUpdateType" json:"type,omitempty"` Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` AccountId string `protobuf:"bytes,3,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` @@ -616,16 +736,17 @@ type ProxyMapping struct { // Service mode: "http", "tcp", "udp", or "tls". Mode string `protobuf:"bytes,10,opt,name=mode,proto3" json:"mode,omitempty"` // For L4/TLS: the port the proxy listens on. - ListenPort int32 `protobuf:"varint,11,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"` + ListenPort int32 `protobuf:"varint,11,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"` + AccessRestrictions *AccessRestrictions `protobuf:"bytes,12,opt,name=access_restrictions,json=accessRestrictions,proto3" json:"access_restrictions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProxyMapping) Reset() { *x = ProxyMapping{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ProxyMapping) String() string { @@ -635,8 +756,8 @@ func (x *ProxyMapping) String() string { func (*ProxyMapping) ProtoMessage() {} func (x *ProxyMapping) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[8] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -648,7 +769,7 @@ func (x *ProxyMapping) ProtoReflect() protoreflect.Message { // Deprecated: Use ProxyMapping.ProtoReflect.Descriptor instead. func (*ProxyMapping) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{6} + return file_proxy_service_proto_rawDescGZIP(), []int{8} } func (x *ProxyMapping) GetType() ProxyMappingUpdateType { @@ -728,22 +849,26 @@ func (x *ProxyMapping) GetListenPort() int32 { return 0 } +func (x *ProxyMapping) GetAccessRestrictions() *AccessRestrictions { + if x != nil { + return x.AccessRestrictions + } + return nil +} + // SendAccessLogRequest consists of one or more AccessLogs from a Proxy. type SendAccessLogRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Log *AccessLog `protobuf:"bytes,1,opt,name=log,proto3" json:"log,omitempty"` unknownFields protoimpl.UnknownFields - - Log *AccessLog `protobuf:"bytes,1,opt,name=log,proto3" json:"log,omitempty"` + sizeCache protoimpl.SizeCache } func (x *SendAccessLogRequest) Reset() { *x = SendAccessLogRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *SendAccessLogRequest) String() string { @@ -753,8 +878,8 @@ func (x *SendAccessLogRequest) String() string { func (*SendAccessLogRequest) ProtoMessage() {} func (x *SendAccessLogRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[9] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -766,7 +891,7 @@ func (x *SendAccessLogRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendAccessLogRequest.ProtoReflect.Descriptor instead. func (*SendAccessLogRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{7} + return file_proxy_service_proto_rawDescGZIP(), []int{9} } func (x *SendAccessLogRequest) GetLog() *AccessLog { @@ -778,18 +903,16 @@ func (x *SendAccessLogRequest) GetLog() *AccessLog { // SendAccessLogResponse is intentionally empty to allow for future expansion. type SendAccessLogResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendAccessLogResponse) Reset() { *x = SendAccessLogResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *SendAccessLogResponse) String() string { @@ -799,8 +922,8 @@ func (x *SendAccessLogResponse) String() string { func (*SendAccessLogResponse) ProtoMessage() {} func (x *SendAccessLogResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[10] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -812,14 +935,11 @@ func (x *SendAccessLogResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendAccessLogResponse.ProtoReflect.Descriptor instead. func (*SendAccessLogResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{8} + return file_proxy_service_proto_rawDescGZIP(), []int{10} } type AccessLog struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` Timestamp *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"` LogId string `protobuf:"bytes,2,opt,name=log_id,json=logId,proto3" json:"log_id,omitempty"` AccountId string `protobuf:"bytes,3,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` @@ -836,15 +956,15 @@ type AccessLog struct { BytesUpload int64 `protobuf:"varint,14,opt,name=bytes_upload,json=bytesUpload,proto3" json:"bytes_upload,omitempty"` BytesDownload int64 `protobuf:"varint,15,opt,name=bytes_download,json=bytesDownload,proto3" json:"bytes_download,omitempty"` Protocol string `protobuf:"bytes,16,opt,name=protocol,proto3" json:"protocol,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AccessLog) Reset() { *x = AccessLog{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *AccessLog) String() string { @@ -854,8 +974,8 @@ func (x *AccessLog) String() string { func (*AccessLog) ProtoMessage() {} func (x *AccessLog) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[11] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -867,7 +987,7 @@ func (x *AccessLog) ProtoReflect() protoreflect.Message { // Deprecated: Use AccessLog.ProtoReflect.Descriptor instead. func (*AccessLog) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{9} + return file_proxy_service_proto_rawDescGZIP(), []int{11} } func (x *AccessLog) GetTimestamp() *timestamppb.Timestamp { @@ -983,26 +1103,24 @@ func (x *AccessLog) GetProtocol() string { } type AuthenticateRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` - // Types that are assignable to Request: + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + // Types that are valid to be assigned to Request: // // *AuthenticateRequest_Password // *AuthenticateRequest_Pin - Request isAuthenticateRequest_Request `protobuf_oneof:"request"` + // *AuthenticateRequest_HeaderAuth + Request isAuthenticateRequest_Request `protobuf_oneof:"request"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AuthenticateRequest) Reset() { *x = AuthenticateRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *AuthenticateRequest) String() string { @@ -1012,8 +1130,8 @@ func (x *AuthenticateRequest) String() string { func (*AuthenticateRequest) ProtoMessage() {} func (x *AuthenticateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[12] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1025,7 +1143,7 @@ func (x *AuthenticateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthenticateRequest.ProtoReflect.Descriptor instead. func (*AuthenticateRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{10} + return file_proxy_service_proto_rawDescGZIP(), []int{12} } func (x *AuthenticateRequest) GetId() string { @@ -1042,23 +1160,36 @@ func (x *AuthenticateRequest) GetAccountId() string { return "" } -func (m *AuthenticateRequest) GetRequest() isAuthenticateRequest_Request { - if m != nil { - return m.Request +func (x *AuthenticateRequest) GetRequest() isAuthenticateRequest_Request { + if x != nil { + return x.Request } return nil } func (x *AuthenticateRequest) GetPassword() *PasswordRequest { - if x, ok := x.GetRequest().(*AuthenticateRequest_Password); ok { - return x.Password + if x != nil { + if x, ok := x.Request.(*AuthenticateRequest_Password); ok { + return x.Password + } } return nil } func (x *AuthenticateRequest) GetPin() *PinRequest { - if x, ok := x.GetRequest().(*AuthenticateRequest_Pin); ok { - return x.Pin + if x != nil { + if x, ok := x.Request.(*AuthenticateRequest_Pin); ok { + return x.Pin + } + } + return nil +} + +func (x *AuthenticateRequest) GetHeaderAuth() *HeaderAuthRequest { + if x != nil { + if x, ok := x.Request.(*AuthenticateRequest_HeaderAuth); ok { + return x.HeaderAuth + } } return nil } @@ -1075,25 +1206,80 @@ type AuthenticateRequest_Pin struct { Pin *PinRequest `protobuf:"bytes,4,opt,name=pin,proto3,oneof"` } +type AuthenticateRequest_HeaderAuth struct { + HeaderAuth *HeaderAuthRequest `protobuf:"bytes,5,opt,name=header_auth,json=headerAuth,proto3,oneof"` +} + func (*AuthenticateRequest_Password) isAuthenticateRequest_Request() {} func (*AuthenticateRequest_Pin) isAuthenticateRequest_Request() {} -type PasswordRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields +func (*AuthenticateRequest_HeaderAuth) isAuthenticateRequest_Request() {} - Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` +type HeaderAuthRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + HeaderValue string `protobuf:"bytes,1,opt,name=header_value,json=headerValue,proto3" json:"header_value,omitempty"` + HeaderName string `protobuf:"bytes,2,opt,name=header_name,json=headerName,proto3" json:"header_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeaderAuthRequest) Reset() { + *x = HeaderAuthRequest{} + mi := &file_proxy_service_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeaderAuthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeaderAuthRequest) ProtoMessage() {} + +func (x *HeaderAuthRequest) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeaderAuthRequest.ProtoReflect.Descriptor instead. +func (*HeaderAuthRequest) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{13} +} + +func (x *HeaderAuthRequest) GetHeaderValue() string { + if x != nil { + return x.HeaderValue + } + return "" +} + +func (x *HeaderAuthRequest) GetHeaderName() string { + if x != nil { + return x.HeaderName + } + return "" +} + +type PasswordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PasswordRequest) Reset() { *x = PasswordRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *PasswordRequest) String() string { @@ -1103,8 +1289,8 @@ func (x *PasswordRequest) String() string { func (*PasswordRequest) ProtoMessage() {} func (x *PasswordRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[11] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[14] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1116,7 +1302,7 @@ func (x *PasswordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PasswordRequest.ProtoReflect.Descriptor instead. func (*PasswordRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{11} + return file_proxy_service_proto_rawDescGZIP(), []int{14} } func (x *PasswordRequest) GetPassword() string { @@ -1127,20 +1313,17 @@ func (x *PasswordRequest) GetPassword() string { } type PinRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Pin string `protobuf:"bytes,1,opt,name=pin,proto3" json:"pin,omitempty"` unknownFields protoimpl.UnknownFields - - Pin string `protobuf:"bytes,1,opt,name=pin,proto3" json:"pin,omitempty"` + sizeCache protoimpl.SizeCache } func (x *PinRequest) Reset() { *x = PinRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *PinRequest) String() string { @@ -1150,8 +1333,8 @@ func (x *PinRequest) String() string { func (*PinRequest) ProtoMessage() {} func (x *PinRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[12] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[15] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1163,7 +1346,7 @@ func (x *PinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PinRequest.ProtoReflect.Descriptor instead. func (*PinRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{12} + return file_proxy_service_proto_rawDescGZIP(), []int{15} } func (x *PinRequest) GetPin() string { @@ -1174,21 +1357,18 @@ func (x *PinRequest) GetPin() string { } type AuthenticateResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + SessionToken string `protobuf:"bytes,2,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` unknownFields protoimpl.UnknownFields - - Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` - SessionToken string `protobuf:"bytes,2,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` + sizeCache protoimpl.SizeCache } func (x *AuthenticateResponse) Reset() { *x = AuthenticateResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *AuthenticateResponse) String() string { @@ -1198,8 +1378,8 @@ func (x *AuthenticateResponse) String() string { func (*AuthenticateResponse) ProtoMessage() {} func (x *AuthenticateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[16] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1211,7 +1391,7 @@ func (x *AuthenticateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthenticateResponse.ProtoReflect.Descriptor instead. func (*AuthenticateResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{13} + return file_proxy_service_proto_rawDescGZIP(), []int{16} } func (x *AuthenticateResponse) GetSuccess() bool { @@ -1230,24 +1410,21 @@ func (x *AuthenticateResponse) GetSessionToken() string { // SendStatusUpdateRequest is sent by the proxy to update its status type SendStatusUpdateRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - ServiceId string `protobuf:"bytes,1,opt,name=service_id,json=serviceId,proto3" json:"service_id,omitempty"` - AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` - Status ProxyStatus `protobuf:"varint,3,opt,name=status,proto3,enum=management.ProxyStatus" json:"status,omitempty"` - CertificateIssued bool `protobuf:"varint,4,opt,name=certificate_issued,json=certificateIssued,proto3" json:"certificate_issued,omitempty"` - ErrorMessage *string `protobuf:"bytes,5,opt,name=error_message,json=errorMessage,proto3,oneof" json:"error_message,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ServiceId string `protobuf:"bytes,1,opt,name=service_id,json=serviceId,proto3" json:"service_id,omitempty"` + AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + Status ProxyStatus `protobuf:"varint,3,opt,name=status,proto3,enum=management.ProxyStatus" json:"status,omitempty"` + CertificateIssued bool `protobuf:"varint,4,opt,name=certificate_issued,json=certificateIssued,proto3" json:"certificate_issued,omitempty"` + ErrorMessage *string `protobuf:"bytes,5,opt,name=error_message,json=errorMessage,proto3,oneof" json:"error_message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendStatusUpdateRequest) Reset() { *x = SendStatusUpdateRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *SendStatusUpdateRequest) String() string { @@ -1257,8 +1434,8 @@ func (x *SendStatusUpdateRequest) String() string { func (*SendStatusUpdateRequest) ProtoMessage() {} func (x *SendStatusUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[14] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[17] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1270,7 +1447,7 @@ func (x *SendStatusUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendStatusUpdateRequest.ProtoReflect.Descriptor instead. func (*SendStatusUpdateRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{14} + return file_proxy_service_proto_rawDescGZIP(), []int{17} } func (x *SendStatusUpdateRequest) GetServiceId() string { @@ -1310,18 +1487,16 @@ func (x *SendStatusUpdateRequest) GetErrorMessage() string { // SendStatusUpdateResponse is intentionally empty to allow for future expansion type SendStatusUpdateResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendStatusUpdateResponse) Reset() { *x = SendStatusUpdateResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *SendStatusUpdateResponse) String() string { @@ -1331,8 +1506,8 @@ func (x *SendStatusUpdateResponse) String() string { func (*SendStatusUpdateResponse) ProtoMessage() {} func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[15] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[18] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1344,30 +1519,27 @@ func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendStatusUpdateResponse.ProtoReflect.Descriptor instead. func (*SendStatusUpdateResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{15} + return file_proxy_service_proto_rawDescGZIP(), []int{18} } // CreateProxyPeerRequest is sent by the proxy to create a peer connection // The token is a one-time authentication token sent via ProxyMapping type CreateProxyPeerRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - ServiceId string `protobuf:"bytes,1,opt,name=service_id,json=serviceId,proto3" json:"service_id,omitempty"` - AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` - Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"` - WireguardPublicKey string `protobuf:"bytes,4,opt,name=wireguard_public_key,json=wireguardPublicKey,proto3" json:"wireguard_public_key,omitempty"` - Cluster string `protobuf:"bytes,5,opt,name=cluster,proto3" json:"cluster,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ServiceId string `protobuf:"bytes,1,opt,name=service_id,json=serviceId,proto3" json:"service_id,omitempty"` + AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"` + WireguardPublicKey string `protobuf:"bytes,4,opt,name=wireguard_public_key,json=wireguardPublicKey,proto3" json:"wireguard_public_key,omitempty"` + Cluster string `protobuf:"bytes,5,opt,name=cluster,proto3" json:"cluster,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateProxyPeerRequest) Reset() { *x = CreateProxyPeerRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreateProxyPeerRequest) String() string { @@ -1377,8 +1549,8 @@ func (x *CreateProxyPeerRequest) String() string { func (*CreateProxyPeerRequest) ProtoMessage() {} func (x *CreateProxyPeerRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[16] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[19] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1390,7 +1562,7 @@ func (x *CreateProxyPeerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateProxyPeerRequest.ProtoReflect.Descriptor instead. func (*CreateProxyPeerRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{16} + return file_proxy_service_proto_rawDescGZIP(), []int{19} } func (x *CreateProxyPeerRequest) GetServiceId() string { @@ -1430,21 +1602,18 @@ func (x *CreateProxyPeerRequest) GetCluster() string { // CreateProxyPeerResponse contains the result of peer creation type CreateProxyPeerResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + ErrorMessage *string `protobuf:"bytes,2,opt,name=error_message,json=errorMessage,proto3,oneof" json:"error_message,omitempty"` unknownFields protoimpl.UnknownFields - - Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` - ErrorMessage *string `protobuf:"bytes,2,opt,name=error_message,json=errorMessage,proto3,oneof" json:"error_message,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CreateProxyPeerResponse) Reset() { *x = CreateProxyPeerResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreateProxyPeerResponse) String() string { @@ -1454,8 +1623,8 @@ func (x *CreateProxyPeerResponse) String() string { func (*CreateProxyPeerResponse) ProtoMessage() {} func (x *CreateProxyPeerResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[17] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[20] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1467,7 +1636,7 @@ func (x *CreateProxyPeerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateProxyPeerResponse.ProtoReflect.Descriptor instead. func (*CreateProxyPeerResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{17} + return file_proxy_service_proto_rawDescGZIP(), []int{20} } func (x *CreateProxyPeerResponse) GetSuccess() bool { @@ -1485,22 +1654,19 @@ func (x *CreateProxyPeerResponse) GetErrorMessage() string { } type GetOIDCURLRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + RedirectUrl string `protobuf:"bytes,3,opt,name=redirect_url,json=redirectUrl,proto3" json:"redirect_url,omitempty"` unknownFields protoimpl.UnknownFields - - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` - RedirectUrl string `protobuf:"bytes,3,opt,name=redirect_url,json=redirectUrl,proto3" json:"redirect_url,omitempty"` + sizeCache protoimpl.SizeCache } func (x *GetOIDCURLRequest) Reset() { *x = GetOIDCURLRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *GetOIDCURLRequest) String() string { @@ -1510,8 +1676,8 @@ func (x *GetOIDCURLRequest) String() string { func (*GetOIDCURLRequest) ProtoMessage() {} func (x *GetOIDCURLRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[18] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[21] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1523,7 +1689,7 @@ func (x *GetOIDCURLRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetOIDCURLRequest.ProtoReflect.Descriptor instead. func (*GetOIDCURLRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{18} + return file_proxy_service_proto_rawDescGZIP(), []int{21} } func (x *GetOIDCURLRequest) GetId() string { @@ -1548,20 +1714,17 @@ func (x *GetOIDCURLRequest) GetRedirectUrl() string { } type GetOIDCURLResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` unknownFields protoimpl.UnknownFields - - Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + sizeCache protoimpl.SizeCache } func (x *GetOIDCURLResponse) Reset() { *x = GetOIDCURLResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *GetOIDCURLResponse) String() string { @@ -1571,8 +1734,8 @@ func (x *GetOIDCURLResponse) String() string { func (*GetOIDCURLResponse) ProtoMessage() {} func (x *GetOIDCURLResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[19] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[22] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1584,7 +1747,7 @@ func (x *GetOIDCURLResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetOIDCURLResponse.ProtoReflect.Descriptor instead. func (*GetOIDCURLResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{19} + return file_proxy_service_proto_rawDescGZIP(), []int{22} } func (x *GetOIDCURLResponse) GetUrl() string { @@ -1595,21 +1758,18 @@ func (x *GetOIDCURLResponse) GetUrl() string { } type ValidateSessionRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + SessionToken string `protobuf:"bytes,2,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` unknownFields protoimpl.UnknownFields - - Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` - SessionToken string `protobuf:"bytes,2,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` + sizeCache protoimpl.SizeCache } func (x *ValidateSessionRequest) Reset() { *x = ValidateSessionRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ValidateSessionRequest) String() string { @@ -1619,8 +1779,8 @@ func (x *ValidateSessionRequest) String() string { func (*ValidateSessionRequest) ProtoMessage() {} func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[20] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[23] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1632,7 +1792,7 @@ func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateSessionRequest.ProtoReflect.Descriptor instead. func (*ValidateSessionRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{20} + return file_proxy_service_proto_rawDescGZIP(), []int{23} } func (x *ValidateSessionRequest) GetDomain() string { @@ -1650,23 +1810,20 @@ func (x *ValidateSessionRequest) GetSessionToken() string { } type ValidateSessionResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + UserEmail string `protobuf:"bytes,3,opt,name=user_email,json=userEmail,proto3" json:"user_email,omitempty"` + DeniedReason string `protobuf:"bytes,4,opt,name=denied_reason,json=deniedReason,proto3" json:"denied_reason,omitempty"` unknownFields protoimpl.UnknownFields - - Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` - UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - UserEmail string `protobuf:"bytes,3,opt,name=user_email,json=userEmail,proto3" json:"user_email,omitempty"` - DeniedReason string `protobuf:"bytes,4,opt,name=denied_reason,json=deniedReason,proto3" json:"denied_reason,omitempty"` + sizeCache protoimpl.SizeCache } func (x *ValidateSessionResponse) Reset() { *x = ValidateSessionResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_proxy_service_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ValidateSessionResponse) String() string { @@ -1676,8 +1833,8 @@ func (x *ValidateSessionResponse) String() string { func (*ValidateSessionResponse) ProtoMessage() {} func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[21] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_proxy_service_proto_msgTypes[24] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1689,7 +1846,7 @@ func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateSessionResponse.ProtoReflect.Descriptor instead. func (*ValidateSessionResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{21} + return file_proxy_service_proto_rawDescGZIP(), []int{24} } func (x *ValidateSessionResponse) GetValid() bool { @@ -1722,317 +1879,195 @@ func (x *ValidateSessionResponse) GetDeniedReason() string { var File_proxy_service_proto protoreflect.FileDescriptor -var file_proxy_service_proto_rawDesc = []byte{ - 0x0a, 0x13, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x22, 0x66, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, - 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x37, 0x0a, 0x15, 0x73, 0x75, 0x70, 0x70, 0x6f, - 0x72, 0x74, 0x73, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x73, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x13, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, - 0x74, 0x73, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x6f, 0x72, 0x74, 0x73, 0x88, 0x01, 0x01, - 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x63, 0x75, - 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x22, 0xe6, 0x01, 0x0a, 0x17, 0x47, - 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, - 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, - 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, - 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, - 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, - 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, - 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, - 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, - 0x69, 0x65, 0x73, 0x22, 0x82, 0x01, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, - 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, - 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, - 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, - 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, - 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xce, 0x03, 0x0a, 0x11, 0x50, 0x61, 0x74, - 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, - 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x74, 0x6c, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x54, 0x6c, 0x73, - 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x12, 0x42, 0x0a, 0x0f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, - 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0e, 0x72, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x3e, 0x0a, 0x0c, 0x70, 0x61, - 0x74, 0x68, 0x5f, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, - 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x0b, 0x70, - 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x63, 0x75, - 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, - 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x64, - 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x70, 0x72, 0x6f, - 0x78, 0x79, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x4b, 0x0a, 0x14, 0x73, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x6c, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, - 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x52, 0x12, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x6c, 0x65, - 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x1a, 0x40, 0x0a, 0x12, 0x43, 0x75, 0x73, 0x74, 0x6f, - 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x72, 0x0a, 0x0b, 0x50, 0x61, 0x74, - 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, - 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, - 0x72, 0x67, 0x65, 0x74, 0x12, 0x37, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xaa, 0x01, - 0x0a, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4b, 0x65, - 0x79, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x78, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x5f, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x14, 0x6d, 0x61, 0x78, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x41, 0x67, - 0x65, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x22, 0x95, 0x03, 0x0a, 0x0c, 0x50, - 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x36, 0x0a, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, - 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2b, 0x0a, 0x04, 0x70, 0x61, - 0x74, 0x68, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, - 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x18, 0x07, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x61, 0x73, 0x73, 0x5f, 0x68, - 0x6f, 0x73, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0e, 0x70, 0x61, 0x73, 0x73, 0x48, 0x6f, 0x73, 0x74, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, - 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x72, 0x65, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x65, 0x77, - 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x73, 0x12, 0x12, 0x0a, - 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, - 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, - 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, - 0x72, 0x74, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, - 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f, - 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, - 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, - 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x86, 0x04, 0x0a, - 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, - 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, - 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a, - 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, - 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, - 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, - 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, - 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, - 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, - 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, - 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f, - 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e, - 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, - 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0xb6, 0x01, 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, - 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, - 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, - 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, 0x70, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x03, - 0x70, 0x69, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x2d, - 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, - 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, - 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, - 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, - 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xf3, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, - 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, - 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, - 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, - 0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, - 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, - 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, - 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, - 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, - 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, - 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, - 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x75, - 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, - 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, - 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, - 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, - 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, - 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, - 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, - 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, - 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, - 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, - 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, - 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x8c, 0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c, - 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, - 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, - 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, - 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, - 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, - 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, - 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, - 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, - 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, - 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, - 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, - 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, - 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, - 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, - 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, - 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, - 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, - 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, - 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, - 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, - 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, - 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, - 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, - 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, - 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, - 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, - 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, - 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, - 0x32, 0xfc, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, - 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, - 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, - 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, - 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, - 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, - 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, - 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, - 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, -} +const file_proxy_service_proto_rawDesc = "" + + "\n" + + "\x13proxy_service.proto\x12\n" + + "management\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xae\x01\n" + + "\x11ProxyCapabilities\x127\n" + + "\x15supports_custom_ports\x18\x01 \x01(\bH\x00R\x13supportsCustomPorts\x88\x01\x01\x120\n" + + "\x11require_subdomain\x18\x02 \x01(\bH\x01R\x10requireSubdomain\x88\x01\x01B\x18\n" + + "\x16_supports_custom_portsB\x14\n" + + "\x12_require_subdomain\"\xe6\x01\n" + + "\x17GetMappingUpdateRequest\x12\x19\n" + + "\bproxy_id\x18\x01 \x01(\tR\aproxyId\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x129\n" + + "\n" + + "started_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12\x18\n" + + "\aaddress\x18\x04 \x01(\tR\aaddress\x12A\n" + + "\fcapabilities\x18\x05 \x01(\v2\x1d.management.ProxyCapabilitiesR\fcapabilities\"\x82\x01\n" + + "\x18GetMappingUpdateResponse\x122\n" + + "\amapping\x18\x01 \x03(\v2\x18.management.ProxyMappingR\amapping\x122\n" + + "\x15initial_sync_complete\x18\x02 \x01(\bR\x13initialSyncComplete\"\xce\x03\n" + + "\x11PathTargetOptions\x12&\n" + + "\x0fskip_tls_verify\x18\x01 \x01(\bR\rskipTlsVerify\x12B\n" + + "\x0frequest_timeout\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\x0erequestTimeout\x12>\n" + + "\fpath_rewrite\x18\x03 \x01(\x0e2\x1b.management.PathRewriteModeR\vpathRewrite\x12W\n" + + "\x0ecustom_headers\x18\x04 \x03(\v20.management.PathTargetOptions.CustomHeadersEntryR\rcustomHeaders\x12%\n" + + "\x0eproxy_protocol\x18\x05 \x01(\bR\rproxyProtocol\x12K\n" + + "\x14session_idle_timeout\x18\x06 \x01(\v2\x19.google.protobuf.DurationR\x12sessionIdleTimeout\x1a@\n" + + "\x12CustomHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"r\n" + + "\vPathMapping\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n" + + "\x06target\x18\x02 \x01(\tR\x06target\x127\n" + + "\aoptions\x18\x03 \x01(\v2\x1d.management.PathTargetOptionsR\aoptions\"G\n" + + "\n" + + "HeaderAuth\x12\x16\n" + + "\x06header\x18\x01 \x01(\tR\x06header\x12!\n" + + "\fhashed_value\x18\x02 \x01(\tR\vhashedValue\"\xe5\x01\n" + + "\x0eAuthentication\x12\x1f\n" + + "\vsession_key\x18\x01 \x01(\tR\n" + + "sessionKey\x125\n" + + "\x17max_session_age_seconds\x18\x02 \x01(\x03R\x14maxSessionAgeSeconds\x12\x1a\n" + + "\bpassword\x18\x03 \x01(\bR\bpassword\x12\x10\n" + + "\x03pin\x18\x04 \x01(\bR\x03pin\x12\x12\n" + + "\x04oidc\x18\x05 \x01(\bR\x04oidc\x129\n" + + "\fheader_auths\x18\x06 \x03(\v2\x16.management.HeaderAuthR\vheaderAuths\"\xb8\x01\n" + + "\x12AccessRestrictions\x12#\n" + + "\rallowed_cidrs\x18\x01 \x03(\tR\fallowedCidrs\x12#\n" + + "\rblocked_cidrs\x18\x02 \x03(\tR\fblockedCidrs\x12+\n" + + "\x11allowed_countries\x18\x03 \x03(\tR\x10allowedCountries\x12+\n" + + "\x11blocked_countries\x18\x04 \x03(\tR\x10blockedCountries\"\xe6\x03\n" + + "\fProxyMapping\x126\n" + + "\x04type\x18\x01 \x01(\x0e2\".management.ProxyMappingUpdateTypeR\x04type\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id\x12\x1d\n" + + "\n" + + "account_id\x18\x03 \x01(\tR\taccountId\x12\x16\n" + + "\x06domain\x18\x04 \x01(\tR\x06domain\x12+\n" + + "\x04path\x18\x05 \x03(\v2\x17.management.PathMappingR\x04path\x12\x1d\n" + + "\n" + + "auth_token\x18\x06 \x01(\tR\tauthToken\x12.\n" + + "\x04auth\x18\a \x01(\v2\x1a.management.AuthenticationR\x04auth\x12(\n" + + "\x10pass_host_header\x18\b \x01(\bR\x0epassHostHeader\x12+\n" + + "\x11rewrite_redirects\x18\t \x01(\bR\x10rewriteRedirects\x12\x12\n" + + "\x04mode\x18\n" + + " \x01(\tR\x04mode\x12\x1f\n" + + "\vlisten_port\x18\v \x01(\x05R\n" + + "listenPort\x12O\n" + + "\x13access_restrictions\x18\f \x01(\v2\x1e.management.AccessRestrictionsR\x12accessRestrictions\"?\n" + + "\x14SendAccessLogRequest\x12'\n" + + "\x03log\x18\x01 \x01(\v2\x15.management.AccessLogR\x03log\"\x17\n" + + "\x15SendAccessLogResponse\"\x86\x04\n" + + "\tAccessLog\x128\n" + + "\ttimestamp\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12\x15\n" + + "\x06log_id\x18\x02 \x01(\tR\x05logId\x12\x1d\n" + + "\n" + + "account_id\x18\x03 \x01(\tR\taccountId\x12\x1d\n" + + "\n" + + "service_id\x18\x04 \x01(\tR\tserviceId\x12\x12\n" + + "\x04host\x18\x05 \x01(\tR\x04host\x12\x12\n" + + "\x04path\x18\x06 \x01(\tR\x04path\x12\x1f\n" + + "\vduration_ms\x18\a \x01(\x03R\n" + + "durationMs\x12\x16\n" + + "\x06method\x18\b \x01(\tR\x06method\x12#\n" + + "\rresponse_code\x18\t \x01(\x05R\fresponseCode\x12\x1b\n" + + "\tsource_ip\x18\n" + + " \x01(\tR\bsourceIp\x12%\n" + + "\x0eauth_mechanism\x18\v \x01(\tR\rauthMechanism\x12\x17\n" + + "\auser_id\x18\f \x01(\tR\x06userId\x12!\n" + + "\fauth_success\x18\r \x01(\bR\vauthSuccess\x12!\n" + + "\fbytes_upload\x18\x0e \x01(\x03R\vbytesUpload\x12%\n" + + "\x0ebytes_download\x18\x0f \x01(\x03R\rbytesDownload\x12\x1a\n" + + "\bprotocol\x18\x10 \x01(\tR\bprotocol\"\xf8\x01\n" + + "\x13AuthenticateRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n" + + "\n" + + "account_id\x18\x02 \x01(\tR\taccountId\x129\n" + + "\bpassword\x18\x03 \x01(\v2\x1b.management.PasswordRequestH\x00R\bpassword\x12*\n" + + "\x03pin\x18\x04 \x01(\v2\x16.management.PinRequestH\x00R\x03pin\x12@\n" + + "\vheader_auth\x18\x05 \x01(\v2\x1d.management.HeaderAuthRequestH\x00R\n" + + "headerAuthB\t\n" + + "\arequest\"W\n" + + "\x11HeaderAuthRequest\x12!\n" + + "\fheader_value\x18\x01 \x01(\tR\vheaderValue\x12\x1f\n" + + "\vheader_name\x18\x02 \x01(\tR\n" + + "headerName\"-\n" + + "\x0fPasswordRequest\x12\x1a\n" + + "\bpassword\x18\x01 \x01(\tR\bpassword\"\x1e\n" + + "\n" + + "PinRequest\x12\x10\n" + + "\x03pin\x18\x01 \x01(\tR\x03pin\"U\n" + + "\x14AuthenticateResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12#\n" + + "\rsession_token\x18\x02 \x01(\tR\fsessionToken\"\xf3\x01\n" + + "\x17SendStatusUpdateRequest\x12\x1d\n" + + "\n" + + "service_id\x18\x01 \x01(\tR\tserviceId\x12\x1d\n" + + "\n" + + "account_id\x18\x02 \x01(\tR\taccountId\x12/\n" + + "\x06status\x18\x03 \x01(\x0e2\x17.management.ProxyStatusR\x06status\x12-\n" + + "\x12certificate_issued\x18\x04 \x01(\bR\x11certificateIssued\x12(\n" + + "\rerror_message\x18\x05 \x01(\tH\x00R\ferrorMessage\x88\x01\x01B\x10\n" + + "\x0e_error_message\"\x1a\n" + + "\x18SendStatusUpdateResponse\"\xb8\x01\n" + + "\x16CreateProxyPeerRequest\x12\x1d\n" + + "\n" + + "service_id\x18\x01 \x01(\tR\tserviceId\x12\x1d\n" + + "\n" + + "account_id\x18\x02 \x01(\tR\taccountId\x12\x14\n" + + "\x05token\x18\x03 \x01(\tR\x05token\x120\n" + + "\x14wireguard_public_key\x18\x04 \x01(\tR\x12wireguardPublicKey\x12\x18\n" + + "\acluster\x18\x05 \x01(\tR\acluster\"o\n" + + "\x17CreateProxyPeerResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12(\n" + + "\rerror_message\x18\x02 \x01(\tH\x00R\ferrorMessage\x88\x01\x01B\x10\n" + + "\x0e_error_message\"e\n" + + "\x11GetOIDCURLRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n" + + "\n" + + "account_id\x18\x02 \x01(\tR\taccountId\x12!\n" + + "\fredirect_url\x18\x03 \x01(\tR\vredirectUrl\"&\n" + + "\x12GetOIDCURLResponse\x12\x10\n" + + "\x03url\x18\x01 \x01(\tR\x03url\"U\n" + + "\x16ValidateSessionRequest\x12\x16\n" + + "\x06domain\x18\x01 \x01(\tR\x06domain\x12#\n" + + "\rsession_token\x18\x02 \x01(\tR\fsessionToken\"\x8c\x01\n" + + "\x17ValidateSessionResponse\x12\x14\n" + + "\x05valid\x18\x01 \x01(\bR\x05valid\x12\x17\n" + + "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x1d\n" + + "\n" + + "user_email\x18\x03 \x01(\tR\tuserEmail\x12#\n" + + "\rdenied_reason\x18\x04 \x01(\tR\fdeniedReason*d\n" + + "\x16ProxyMappingUpdateType\x12\x17\n" + + "\x13UPDATE_TYPE_CREATED\x10\x00\x12\x18\n" + + "\x14UPDATE_TYPE_MODIFIED\x10\x01\x12\x17\n" + + "\x13UPDATE_TYPE_REMOVED\x10\x02*F\n" + + "\x0fPathRewriteMode\x12\x18\n" + + "\x14PATH_REWRITE_DEFAULT\x10\x00\x12\x19\n" + + "\x15PATH_REWRITE_PRESERVE\x10\x01*\xc8\x01\n" + + "\vProxyStatus\x12\x18\n" + + "\x14PROXY_STATUS_PENDING\x10\x00\x12\x17\n" + + "\x13PROXY_STATUS_ACTIVE\x10\x01\x12#\n" + + "\x1fPROXY_STATUS_TUNNEL_NOT_CREATED\x10\x02\x12$\n" + + " PROXY_STATUS_CERTIFICATE_PENDING\x10\x03\x12#\n" + + "\x1fPROXY_STATUS_CERTIFICATE_FAILED\x10\x04\x12\x16\n" + + "\x12PROXY_STATUS_ERROR\x10\x052\xfc\x04\n" + + "\fProxyService\x12_\n" + + "\x10GetMappingUpdate\x12#.management.GetMappingUpdateRequest\x1a$.management.GetMappingUpdateResponse0\x01\x12T\n" + + "\rSendAccessLog\x12 .management.SendAccessLogRequest\x1a!.management.SendAccessLogResponse\x12Q\n" + + "\fAuthenticate\x12\x1f.management.AuthenticateRequest\x1a .management.AuthenticateResponse\x12]\n" + + "\x10SendStatusUpdate\x12#.management.SendStatusUpdateRequest\x1a$.management.SendStatusUpdateResponse\x12Z\n" + + "\x0fCreateProxyPeer\x12\".management.CreateProxyPeerRequest\x1a#.management.CreateProxyPeerResponse\x12K\n" + + "\n" + + "GetOIDCURL\x12\x1d.management.GetOIDCURLRequest\x1a\x1e.management.GetOIDCURLResponse\x12Z\n" + + "\x0fValidateSession\x12\".management.ValidateSessionRequest\x1a#.management.ValidateSessionResponseB\bZ\x06/protob\x06proto3" var ( file_proxy_service_proto_rawDescOnce sync.Once - file_proxy_service_proto_rawDescData = file_proxy_service_proto_rawDesc + file_proxy_service_proto_rawDescData []byte ) func file_proxy_service_proto_rawDescGZIP() []byte { file_proxy_service_proto_rawDescOnce.Do(func() { - file_proxy_service_proto_rawDescData = protoimpl.X.CompressGZIP(file_proxy_service_proto_rawDescData) + file_proxy_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proxy_service_proto_rawDesc), len(file_proxy_service_proto_rawDesc))) }) return file_proxy_service_proto_rawDescData } var file_proxy_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 23) -var file_proxy_service_proto_goTypes = []interface{}{ +var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_proxy_service_proto_goTypes = []any{ (ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType (PathRewriteMode)(0), // 1: management.PathRewriteMode (ProxyStatus)(0), // 2: management.ProxyStatus @@ -2041,63 +2076,69 @@ var file_proxy_service_proto_goTypes = []interface{}{ (*GetMappingUpdateResponse)(nil), // 5: management.GetMappingUpdateResponse (*PathTargetOptions)(nil), // 6: management.PathTargetOptions (*PathMapping)(nil), // 7: management.PathMapping - (*Authentication)(nil), // 8: management.Authentication - (*ProxyMapping)(nil), // 9: management.ProxyMapping - (*SendAccessLogRequest)(nil), // 10: management.SendAccessLogRequest - (*SendAccessLogResponse)(nil), // 11: management.SendAccessLogResponse - (*AccessLog)(nil), // 12: management.AccessLog - (*AuthenticateRequest)(nil), // 13: management.AuthenticateRequest - (*PasswordRequest)(nil), // 14: management.PasswordRequest - (*PinRequest)(nil), // 15: management.PinRequest - (*AuthenticateResponse)(nil), // 16: management.AuthenticateResponse - (*SendStatusUpdateRequest)(nil), // 17: management.SendStatusUpdateRequest - (*SendStatusUpdateResponse)(nil), // 18: management.SendStatusUpdateResponse - (*CreateProxyPeerRequest)(nil), // 19: management.CreateProxyPeerRequest - (*CreateProxyPeerResponse)(nil), // 20: management.CreateProxyPeerResponse - (*GetOIDCURLRequest)(nil), // 21: management.GetOIDCURLRequest - (*GetOIDCURLResponse)(nil), // 22: management.GetOIDCURLResponse - (*ValidateSessionRequest)(nil), // 23: management.ValidateSessionRequest - (*ValidateSessionResponse)(nil), // 24: management.ValidateSessionResponse - nil, // 25: management.PathTargetOptions.CustomHeadersEntry - (*timestamppb.Timestamp)(nil), // 26: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 27: google.protobuf.Duration + (*HeaderAuth)(nil), // 8: management.HeaderAuth + (*Authentication)(nil), // 9: management.Authentication + (*AccessRestrictions)(nil), // 10: management.AccessRestrictions + (*ProxyMapping)(nil), // 11: management.ProxyMapping + (*SendAccessLogRequest)(nil), // 12: management.SendAccessLogRequest + (*SendAccessLogResponse)(nil), // 13: management.SendAccessLogResponse + (*AccessLog)(nil), // 14: management.AccessLog + (*AuthenticateRequest)(nil), // 15: management.AuthenticateRequest + (*HeaderAuthRequest)(nil), // 16: management.HeaderAuthRequest + (*PasswordRequest)(nil), // 17: management.PasswordRequest + (*PinRequest)(nil), // 18: management.PinRequest + (*AuthenticateResponse)(nil), // 19: management.AuthenticateResponse + (*SendStatusUpdateRequest)(nil), // 20: management.SendStatusUpdateRequest + (*SendStatusUpdateResponse)(nil), // 21: management.SendStatusUpdateResponse + (*CreateProxyPeerRequest)(nil), // 22: management.CreateProxyPeerRequest + (*CreateProxyPeerResponse)(nil), // 23: management.CreateProxyPeerResponse + (*GetOIDCURLRequest)(nil), // 24: management.GetOIDCURLRequest + (*GetOIDCURLResponse)(nil), // 25: management.GetOIDCURLResponse + (*ValidateSessionRequest)(nil), // 26: management.ValidateSessionRequest + (*ValidateSessionResponse)(nil), // 27: management.ValidateSessionResponse + nil, // 28: management.PathTargetOptions.CustomHeadersEntry + (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 30: google.protobuf.Duration } var file_proxy_service_proto_depIdxs = []int32{ - 26, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp + 29, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp 3, // 1: management.GetMappingUpdateRequest.capabilities:type_name -> management.ProxyCapabilities - 9, // 2: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping - 27, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration + 11, // 2: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping + 30, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration 1, // 4: management.PathTargetOptions.path_rewrite:type_name -> management.PathRewriteMode - 25, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry - 27, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration + 28, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry + 30, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration 6, // 7: management.PathMapping.options:type_name -> management.PathTargetOptions - 0, // 8: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType - 7, // 9: management.ProxyMapping.path:type_name -> management.PathMapping - 8, // 10: management.ProxyMapping.auth:type_name -> management.Authentication - 12, // 11: management.SendAccessLogRequest.log:type_name -> management.AccessLog - 26, // 12: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp - 14, // 13: management.AuthenticateRequest.password:type_name -> management.PasswordRequest - 15, // 14: management.AuthenticateRequest.pin:type_name -> management.PinRequest - 2, // 15: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus - 4, // 16: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest - 10, // 17: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest - 13, // 18: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest - 17, // 19: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest - 19, // 20: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest - 21, // 21: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest - 23, // 22: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest - 5, // 23: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse - 11, // 24: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse - 16, // 25: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse - 18, // 26: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse - 20, // 27: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse - 22, // 28: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse - 24, // 29: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse - 23, // [23:30] is the sub-list for method output_type - 16, // [16:23] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 8, // 8: management.Authentication.header_auths:type_name -> management.HeaderAuth + 0, // 9: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType + 7, // 10: management.ProxyMapping.path:type_name -> management.PathMapping + 9, // 11: management.ProxyMapping.auth:type_name -> management.Authentication + 10, // 12: management.ProxyMapping.access_restrictions:type_name -> management.AccessRestrictions + 14, // 13: management.SendAccessLogRequest.log:type_name -> management.AccessLog + 29, // 14: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp + 17, // 15: management.AuthenticateRequest.password:type_name -> management.PasswordRequest + 18, // 16: management.AuthenticateRequest.pin:type_name -> management.PinRequest + 16, // 17: management.AuthenticateRequest.header_auth:type_name -> management.HeaderAuthRequest + 2, // 18: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus + 4, // 19: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest + 12, // 20: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest + 15, // 21: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest + 20, // 22: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest + 22, // 23: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest + 24, // 24: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest + 26, // 25: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest + 5, // 26: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse + 13, // 27: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse + 19, // 28: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse + 21, // 29: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse + 23, // 30: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse + 25, // 31: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse + 27, // 32: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse + 26, // [26:33] is the sub-list for method output_type + 19, // [19:26] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_proxy_service_proto_init() } @@ -2105,286 +2146,21 @@ func file_proxy_service_proto_init() { if File_proxy_service_proto != nil { return } - if !protoimpl.UnsafeEnabled { - file_proxy_service_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ProxyCapabilities); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetMappingUpdateRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetMappingUpdateResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PathTargetOptions); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PathMapping); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Authentication); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ProxyMapping); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SendAccessLogRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SendAccessLogResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AccessLog); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AuthenticateRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PasswordRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PinRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AuthenticateResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SendStatusUpdateRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SendStatusUpdateResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateProxyPeerRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateProxyPeerResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetOIDCURLRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetOIDCURLResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ValidateSessionRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ValidateSessionResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - file_proxy_service_proto_msgTypes[0].OneofWrappers = []interface{}{} - file_proxy_service_proto_msgTypes[10].OneofWrappers = []interface{}{ + file_proxy_service_proto_msgTypes[0].OneofWrappers = []any{} + file_proxy_service_proto_msgTypes[12].OneofWrappers = []any{ (*AuthenticateRequest_Password)(nil), (*AuthenticateRequest_Pin)(nil), + (*AuthenticateRequest_HeaderAuth)(nil), } - file_proxy_service_proto_msgTypes[14].OneofWrappers = []interface{}{} - file_proxy_service_proto_msgTypes[17].OneofWrappers = []interface{}{} + file_proxy_service_proto_msgTypes[17].OneofWrappers = []any{} + file_proxy_service_proto_msgTypes[20].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_proxy_service_proto_rawDesc, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proxy_service_proto_rawDesc), len(file_proxy_service_proto_rawDesc)), NumEnums: 3, - NumMessages: 23, + NumMessages: 26, NumExtensions: 0, NumServices: 1, }, @@ -2394,7 +2170,6 @@ func file_proxy_service_proto_init() { MessageInfos: file_proxy_service_proto_msgTypes, }.Build() File_proxy_service_proto = out.File - file_proxy_service_proto_rawDesc = nil file_proxy_service_proto_goTypes = nil file_proxy_service_proto_depIdxs = nil } diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto index 457d12e85..f77071eb0 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -31,6 +31,9 @@ service ProxyService { message ProxyCapabilities { // Whether the proxy can bind arbitrary ports for TCP/UDP/TLS services. optional bool supports_custom_ports = 1; + // Whether the proxy requires a subdomain label in front of its cluster domain. + // When true, accounts cannot use the cluster domain bare. + optional bool require_subdomain = 2; } // GetMappingUpdateRequest is sent to initialise a mapping stream. @@ -80,12 +83,27 @@ message PathMapping { PathTargetOptions options = 3; } +message HeaderAuth { + // Header name to check, e.g. "Authorization", "X-API-Key". + string header = 1; + // argon2id hash of the expected full header value. + string hashed_value = 2; +} + message Authentication { string session_key = 1; int64 max_session_age_seconds = 2; bool password = 3; bool pin = 4; bool oidc = 5; + repeated HeaderAuth header_auths = 6; +} + +message AccessRestrictions { + repeated string allowed_cidrs = 1; + repeated string blocked_cidrs = 2; + repeated string allowed_countries = 3; + repeated string blocked_countries = 4; } message ProxyMapping { @@ -106,6 +124,7 @@ message ProxyMapping { string mode = 10; // For L4/TLS: the port the proxy listens on. int32 listen_port = 11; + AccessRestrictions access_restrictions = 12; } // SendAccessLogRequest consists of one or more AccessLogs from a Proxy. @@ -141,9 +160,15 @@ message AuthenticateRequest { oneof request { PasswordRequest password = 3; PinRequest pin = 4; + HeaderAuthRequest header_auth = 5; } } +message HeaderAuthRequest { + string header_value = 1; + string header_name = 2; +} + message PasswordRequest { string password = 1; } diff --git a/shared/relay/client/early_msg_buffer.go b/shared/relay/client/early_msg_buffer.go index 3ead94de1..52ff4d42e 100644 --- a/shared/relay/client/early_msg_buffer.go +++ b/shared/relay/client/early_msg_buffer.go @@ -65,8 +65,8 @@ func (b *earlyMsgBuffer) put(peerID messages.PeerID, msg Msg) bool { } entry := earlyMsg{ - peerID: peerID, - msg: msg, + peerID: peerID, + msg: msg, createdAt: time.Now(), } elem := b.order.PushBack(entry) diff --git a/upload-server/server/s3_test.go b/upload-server/server/s3_test.go index 26b0ecd09..7ab1bb379 100644 --- a/upload-server/server/s3_test.go +++ b/upload-server/server/s3_test.go @@ -5,13 +5,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "os" "runtime" "testing" "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" @@ -20,45 +19,55 @@ import ( ) func Test_S3HandlerGetUploadURL(t *testing.T) { - if runtime.GOOS != "linux" && os.Getenv("CI") == "true" { - t.Skip("Skipping test on non-Linux and CI environment due to docker dependency") - } - if runtime.GOOS == "windows" { - t.Skip("Skipping test on Windows due to potential docker dependency") + if runtime.GOOS != "linux" { + t.Skip("Skipping test on non-Linux due to docker dependency") } - awsEndpoint := "http://127.0.0.1:4566" awsRegion := "us-east-1" ctx := context.Background() - containerRequest := testcontainers.ContainerRequest{ - Image: "localstack/localstack:s3-latest", - ExposedPorts: []string{"4566:4566/tcp"}, - WaitingFor: wait.ForLog("Ready"), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: containerRequest, - Started: true, + ContainerRequest: testcontainers.ContainerRequest{ + Image: "minio/minio:RELEASE.2025-04-22T22-12-26Z", + ExposedPorts: []string{"9000/tcp"}, + Env: map[string]string{ + "MINIO_ROOT_USER": "minioadmin", + "MINIO_ROOT_PASSWORD": "minioadmin", + }, + Cmd: []string{"server", "/data"}, + WaitingFor: wait.ForHTTP("/minio/health/ready").WithPort("9000"), + }, + Started: true, }) - if err != nil { - t.Error(err) - } - defer func(c testcontainers.Container, ctx context.Context) { + require.NoError(t, err) + t.Cleanup(func() { if err := c.Terminate(ctx); err != nil { t.Log(err) } - }(c, ctx) + }) + + mappedPort, err := c.MappedPort(ctx, "9000") + require.NoError(t, err) + + hostIP, err := c.Host(ctx) + require.NoError(t, err) + + awsEndpoint := "http://" + hostIP + ":" + mappedPort.Port() t.Setenv("AWS_REGION", awsRegion) t.Setenv("AWS_ENDPOINT_URL", awsEndpoint) - t.Setenv("AWS_ACCESS_KEY_ID", "test") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test") + t.Setenv("AWS_ACCESS_KEY_ID", "minioadmin") + t.Setenv("AWS_SECRET_ACCESS_KEY", "minioadmin") + t.Setenv("AWS_CONFIG_FILE", "") + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "") + t.Setenv("AWS_PROFILE", "") - cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(awsRegion), config.WithBaseEndpoint(awsEndpoint)) - if err != nil { - t.Error(err) - } + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion(awsRegion), + config.WithBaseEndpoint(awsEndpoint), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("minioadmin", "minioadmin", "")), + ) + require.NoError(t, err) client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true @@ -66,19 +75,16 @@ func Test_S3HandlerGetUploadURL(t *testing.T) { }) bucketName := "test" - if _, err := client.CreateBucket(ctx, &s3.CreateBucketInput{ + _, err = client.CreateBucket(ctx, &s3.CreateBucketInput{ Bucket: &bucketName, - }); err != nil { - t.Error(err) - } + }) + require.NoError(t, err) list, err := client.ListBuckets(ctx, &s3.ListBucketsInput{}) - if err != nil { - t.Error(err) - } + require.NoError(t, err) - assert.Equal(t, len(list.Buckets), 1) - assert.Equal(t, *list.Buckets[0].Name, bucketName) + require.Len(t, list.Buckets, 1) + require.Equal(t, bucketName, *list.Buckets[0].Name) t.Setenv(bucketVar, bucketName) diff --git a/util/log.go b/util/log.go index 03547024a..b1de2d999 100644 --- a/util/log.go +++ b/util/log.go @@ -43,7 +43,13 @@ func InitLogger(logger *log.Logger, logLevel string, logs ...string) error { var writers []io.Writer logFmt := os.Getenv("NB_LOG_FORMAT") + seen := make(map[string]bool, len(logs)) for _, logPath := range logs { + if seen[logPath] { + continue + } + seen[logPath] = true + switch logPath { case LogSyslog: AddSyslogHookToLogger(logger)