diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 9ce779dbb..19a3a01e0 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -19,7 +19,7 @@ jobs:
- name: codespell
uses: codespell-project/actions-codespell@v2
with:
- ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros
+ ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans
skip: go.mod,go.sum
golangci:
strategy:
diff --git a/README.md b/README.md
index 28b53d5b6..8f4c04641 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,11 @@
+
+ 🚀 We are hiring! Join us at careers.netbird.io
+
+
+
New: NetBird terraform provider
diff --git a/client/Dockerfile b/client/Dockerfile
index 5cd459357..2ff0cca19 100644
--- a/client/Dockerfile
+++ b/client/Dockerfile
@@ -4,7 +4,7 @@
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
-FROM alpine:3.22.2
+FROM alpine:3.23.2
# iproute2: busybox doesn't display ip rules properly
RUN apk add --no-cache \
bash \
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index 7ca56857b..bbb0ef0d6 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -16,7 +16,6 @@ import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server"
- nbstatus "github.com/netbirdio/netbird/client/status"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/upload-server/types"
)
@@ -98,7 +97,6 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
client := proto.NewDaemonServiceClient(conn)
request := &proto.DebugBundleRequest{
Anonymize: anonymizeFlag,
- Status: getStatusOutput(cmd, anonymizeFlag),
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
}
@@ -221,9 +219,6 @@ func runForDuration(cmd *cobra.Command, args []string) error {
time.Sleep(3 * time.Second)
- headerPostUp := fmt.Sprintf("----- NetBird post-up - Timestamp: %s", time.Now().Format(time.RFC3339))
- statusOutput := fmt.Sprintf("%s\n%s", headerPostUp, getStatusOutput(cmd, anonymizeFlag))
-
if waitErr := waitForDurationOrCancel(cmd.Context(), duration, cmd); waitErr != nil {
return waitErr
}
@@ -231,11 +226,8 @@ func runForDuration(cmd *cobra.Command, args []string) error {
cmd.Println("Creating debug bundle...")
- headerPreDown := fmt.Sprintf("----- NetBird pre-down - Timestamp: %s - Duration: %s", time.Now().Format(time.RFC3339), duration)
- statusOutput = fmt.Sprintf("%s\n%s\n%s", statusOutput, headerPreDown, getStatusOutput(cmd, anonymizeFlag))
request := &proto.DebugBundleRequest{
Anonymize: anonymizeFlag,
- Status: statusOutput,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
}
@@ -302,25 +294,6 @@ func setSyncResponsePersistence(cmd *cobra.Command, args []string) error {
return nil
}
-func getStatusOutput(cmd *cobra.Command, anon bool) string {
- var statusOutputString string
- statusResp, err := getStatus(cmd.Context(), true)
- if err != nil {
- cmd.PrintErrf("Failed to get status: %v\n", err)
- } else {
- pm := profilemanager.NewProfileManager()
- var profName string
- if activeProf, err := pm.GetActiveProfile(); err == nil {
- profName = activeProf.Name
- }
-
- statusOutputString = nbstatus.ParseToFullDetailSummary(
- nbstatus.ConvertToStatusOutputOverview(statusResp, anon, "", nil, nil, nil, "", profName),
- )
- }
- return statusOutputString
-}
-
func waitForDurationOrCancel(ctx context.Context, duration time.Duration, cmd *cobra.Command) error {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
@@ -379,7 +352,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
InternalConfig: config,
StatusRecorder: recorder,
SyncResponse: syncResponse,
- LogFile: logFilePath,
+ LogPath: logFilePath,
},
debug.BundleConfig{
IncludeSystemInfo: true,
diff --git a/client/cmd/status.go b/client/cmd/status.go
index 99d47cd1a..f09c35c2c 100644
--- a/client/cmd/status.go
+++ b/client/cmd/status.go
@@ -99,17 +99,17 @@ func statusFunc(cmd *cobra.Command, args []string) error {
profName = activeProf.Name
}
- var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp, anonymizeFlag, statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilterMap, connectionTypeFilter, profName)
+ var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), anonymizeFlag, resp.GetDaemonVersion(), statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilterMap, connectionTypeFilter, profName)
var statusOutputString string
switch {
case detailFlag:
- statusOutputString = nbstatus.ParseToFullDetailSummary(outputInformationHolder)
+ statusOutputString = outputInformationHolder.FullDetailSummary()
case jsonFlag:
- statusOutputString, err = nbstatus.ParseToJSON(outputInformationHolder)
+ statusOutputString, err = outputInformationHolder.JSON()
case yamlFlag:
- statusOutputString, err = nbstatus.ParseToYAML(outputInformationHolder)
+ statusOutputString, err = outputInformationHolder.YAML()
default:
- statusOutputString = nbstatus.ParseGeneralSummary(outputInformationHolder, false, false, false, false)
+ statusOutputString = outputInformationHolder.GeneralSummary(false, false, false, false)
}
if err != nil {
diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go
index 2650d6225..4bda33e65 100644
--- a/client/cmd/testutil_test.go
+++ b/client/cmd/testutil_test.go
@@ -18,6 +18,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server/job"
clientProto "github.com/netbirdio/netbird/client/proto"
client "github.com/netbirdio/netbird/client/server"
@@ -97,6 +98,8 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
peersmanager := peers.NewManager(store, permissionsManagerMock)
settingsManagerMock := settings.NewMockManager(ctrl)
+ jobManager := job.NewJobManager(nil, store, peersmanager)
+
iv, _ := integrations.NewIntegratedValidator(context.Background(), peersmanager, settingsManagerMock, eventStore)
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
@@ -115,7 +118,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config)
- accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
+ accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
if err != nil {
t.Fatal(err)
}
@@ -124,7 +127,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
if err != nil {
t.Fatal(err)
}
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil)
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil)
if err != nil {
t.Fatal(err)
}
diff --git a/client/cmd/up.go b/client/cmd/up.go
index 057d35268..9559287d5 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -200,7 +200,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
connectClient := internal.NewConnectClient(ctx, config, r, false)
SetupDebugHandler(ctx, config, r, connectClient, "")
- return connectClient.Run(nil)
+ return connectClient.Run(nil, util.FindFirstLogPath(logFiles))
}
func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager.ProfileManager, activeProf *profilemanager.Profile, profileSwitched bool) error {
diff --git a/client/embed/embed.go b/client/embed/embed.go
index 353c5438f..8bbbef0f2 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -20,6 +20,7 @@ import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
sshcommon "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/system"
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
var (
@@ -38,6 +39,7 @@ type Client struct {
setupKey string
jwtToken string
connect *internal.ConnectClient
+ recorder *peer.Status
}
// Options configures a new Client.
@@ -161,11 +163,17 @@ func New(opts Options) (*Client, error) {
func (c *Client) Start(startCtx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
- if c.cancel != nil {
+ if c.connect != nil {
return ErrClientAlreadyStarted
}
- ctx := internal.CtxInitState(context.Background())
+ ctx, cancel := context.WithCancel(internal.CtxInitState(context.Background()))
+ defer func() {
+ if c.connect == nil {
+ cancel()
+ }
+ }()
+
// nolint:staticcheck
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, c.deviceName)
if err := internal.Login(ctx, c.config, c.setupKey, c.jwtToken); err != nil {
@@ -173,14 +181,16 @@ func (c *Client) Start(startCtx context.Context) error {
}
recorder := peer.NewRecorder(c.config.ManagementURL.String())
+ c.recorder = recorder
client := internal.NewConnectClient(ctx, c.config, recorder, false)
+ client.SetSyncResponsePersistence(true)
// either startup error (permanent backoff err) or nil err (successful engine up)
// TODO: make after-startup backoff err available
run := make(chan struct{})
clientErr := make(chan error, 1)
go func() {
- if err := client.Run(run); err != nil {
+ if err := client.Run(run, ""); err != nil {
clientErr <- err
}
}()
@@ -197,6 +207,7 @@ func (c *Client) Start(startCtx context.Context) error {
}
c.connect = client
+ c.cancel = cancel
return nil
}
@@ -211,17 +222,23 @@ func (c *Client) Stop(ctx context.Context) error {
return ErrClientNotStarted
}
+ if c.cancel != nil {
+ c.cancel()
+ c.cancel = nil
+ }
+
done := make(chan error, 1)
+ connect := c.connect
go func() {
- done <- c.connect.Stop()
+ done <- connect.Stop()
}()
select {
case <-ctx.Done():
- c.cancel = nil
+ c.connect = nil
return ctx.Err()
case err := <-done:
- c.cancel = nil
+ c.connect = nil
if err != nil {
return fmt.Errorf("stop: %w", err)
}
@@ -315,6 +332,62 @@ func (c *Client) NewHTTPClient() *http.Client {
}
}
+// Status returns the current status of the client.
+func (c *Client) Status() (peer.FullStatus, error) {
+ c.mu.Lock()
+ recorder := c.recorder
+ connect := c.connect
+ c.mu.Unlock()
+
+ if recorder == nil {
+ return peer.FullStatus{}, errors.New("client not started")
+ }
+
+ if connect != nil {
+ engine := connect.Engine()
+ if engine != nil {
+ _ = engine.RunHealthProbes(false)
+ }
+ }
+
+ return recorder.GetFullStatus(), nil
+}
+
+// GetLatestSyncResponse returns the latest sync response from the management server.
+func (c *Client) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
+ engine, err := c.getEngine()
+ if err != nil {
+ return nil, err
+ }
+
+ syncResp, err := engine.GetLatestSyncResponse()
+ if err != nil {
+ return nil, fmt.Errorf("get sync response: %w", err)
+ }
+
+ return syncResp, nil
+}
+
+// SetLogLevel sets the logging level for the client and its components.
+func (c *Client) SetLogLevel(levelStr string) error {
+ level, err := logrus.ParseLevel(levelStr)
+ if err != nil {
+ return fmt.Errorf("parse log level: %w", err)
+ }
+
+ logrus.SetLevel(level)
+
+ c.mu.Lock()
+ connect := c.connect
+ c.mu.Unlock()
+
+ if connect != nil {
+ connect.SetLogLevel(level)
+ }
+
+ return nil
+}
+
// VerifySSHHostKey verifies an SSH host key against stored peer keys.
// Returns nil if the key matches, ErrPeerNotFound if peer is not in network,
// ErrNoStoredKey if peer has no stored key, or an error for verification failures.
diff --git a/client/iface/configurer/common.go b/client/iface/configurer/common.go
index 088cff69d..10162d703 100644
--- a/client/iface/configurer/common.go
+++ b/client/iface/configurer/common.go
@@ -3,8 +3,22 @@ package configurer
import (
"net"
"net/netip"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
+// buildPresharedKeyConfig creates a wgtypes.Config for setting a preshared key on a peer.
+// This is a shared helper used by both kernel and userspace configurers.
+func buildPresharedKeyConfig(peerKey wgtypes.Key, psk wgtypes.Key, updateOnly bool) wgtypes.Config {
+ return wgtypes.Config{
+ Peers: []wgtypes.PeerConfig{{
+ PublicKey: peerKey,
+ PresharedKey: &psk,
+ UpdateOnly: updateOnly,
+ }},
+ }
+}
+
func prefixesToIPNets(prefixes []netip.Prefix) []net.IPNet {
ipNets := make([]net.IPNet, len(prefixes))
for i, prefix := range prefixes {
diff --git a/client/iface/configurer/kernel_unix.go b/client/iface/configurer/kernel_unix.go
index 96b286175..a29fe181a 100644
--- a/client/iface/configurer/kernel_unix.go
+++ b/client/iface/configurer/kernel_unix.go
@@ -15,8 +15,6 @@ import (
"github.com/netbirdio/netbird/monotime"
)
-var zeroKey wgtypes.Key
-
type KernelConfigurer struct {
deviceName string
}
@@ -48,6 +46,18 @@ func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error
return nil
}
+// SetPresharedKey sets the preshared key for a peer.
+// If updateOnly is true, only updates the existing peer; if false, creates or updates.
+func (c *KernelConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
+ parsedPeerKey, err := wgtypes.ParseKey(peerKey)
+ if err != nil {
+ return err
+ }
+
+ cfg := buildPresharedKeyConfig(parsedPeerKey, psk, updateOnly)
+ return c.configure(cfg)
+}
+
func (c *KernelConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error {
peerKeyParsed, err := wgtypes.ParseKey(peerKey)
if err != nil {
@@ -279,7 +289,7 @@ func (c *KernelConfigurer) FullStats() (*Stats, error) {
TxBytes: p.TransmitBytes,
RxBytes: p.ReceiveBytes,
LastHandshake: p.LastHandshakeTime,
- PresharedKey: p.PresharedKey != zeroKey,
+ PresharedKey: [32]byte(p.PresharedKey),
}
if p.Endpoint != nil {
peer.Endpoint = *p.Endpoint
diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go
index bc875b73c..c4ea349df 100644
--- a/client/iface/configurer/usp.go
+++ b/client/iface/configurer/usp.go
@@ -22,17 +22,16 @@ import (
)
const (
- privateKey = "private_key"
- ipcKeyLastHandshakeTimeSec = "last_handshake_time_sec"
- ipcKeyLastHandshakeTimeNsec = "last_handshake_time_nsec"
- ipcKeyTxBytes = "tx_bytes"
- ipcKeyRxBytes = "rx_bytes"
- allowedIP = "allowed_ip"
- endpoint = "endpoint"
- fwmark = "fwmark"
- listenPort = "listen_port"
- publicKey = "public_key"
- presharedKey = "preshared_key"
+ privateKey = "private_key"
+ ipcKeyLastHandshakeTimeSec = "last_handshake_time_sec"
+ ipcKeyTxBytes = "tx_bytes"
+ ipcKeyRxBytes = "rx_bytes"
+ allowedIP = "allowed_ip"
+ endpoint = "endpoint"
+ fwmark = "fwmark"
+ listenPort = "listen_port"
+ publicKey = "public_key"
+ presharedKey = "preshared_key"
)
var ErrAllowedIPNotFound = fmt.Errorf("allowed IP not found")
@@ -72,6 +71,18 @@ func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error
return c.device.IpcSet(toWgUserspaceString(config))
}
+// SetPresharedKey sets the preshared key for a peer.
+// If updateOnly is true, only updates the existing peer; if false, creates or updates.
+func (c *WGUSPConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
+ parsedPeerKey, err := wgtypes.ParseKey(peerKey)
+ if err != nil {
+ return err
+ }
+
+ cfg := buildPresharedKeyConfig(parsedPeerKey, psk, updateOnly)
+ return c.device.IpcSet(toWgUserspaceString(cfg))
+}
+
func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error {
peerKeyParsed, err := wgtypes.ParseKey(peerKey)
if err != nil {
@@ -422,23 +433,19 @@ func toWgUserspaceString(wgCfg wgtypes.Config) string {
hexKey := hex.EncodeToString(p.PublicKey[:])
sb.WriteString(fmt.Sprintf("public_key=%s\n", hexKey))
+ if p.Remove {
+ sb.WriteString("remove=true\n")
+ }
+
+ if p.UpdateOnly {
+ sb.WriteString("update_only=true\n")
+ }
+
if p.PresharedKey != nil {
preSharedHexKey := hex.EncodeToString(p.PresharedKey[:])
sb.WriteString(fmt.Sprintf("preshared_key=%s\n", preSharedHexKey))
}
- if p.Remove {
- sb.WriteString("remove=true")
- }
-
- if p.ReplaceAllowedIPs {
- sb.WriteString("replace_allowed_ips=true\n")
- }
-
- for _, aip := range p.AllowedIPs {
- sb.WriteString(fmt.Sprintf("allowed_ip=%s\n", aip.String()))
- }
-
if p.Endpoint != nil {
sb.WriteString(fmt.Sprintf("endpoint=%s\n", p.Endpoint.String()))
}
@@ -446,6 +453,14 @@ func toWgUserspaceString(wgCfg wgtypes.Config) string {
if p.PersistentKeepaliveInterval != nil {
sb.WriteString(fmt.Sprintf("persistent_keepalive_interval=%d\n", int(p.PersistentKeepaliveInterval.Seconds())))
}
+
+ if p.ReplaceAllowedIPs {
+ sb.WriteString("replace_allowed_ips=true\n")
+ }
+
+ for _, aip := range p.AllowedIPs {
+ sb.WriteString(fmt.Sprintf("allowed_ip=%s\n", aip.String()))
+ }
}
return sb.String()
}
@@ -599,7 +614,9 @@ func parseStatus(deviceName, ipcStr string) (*Stats, error) {
continue
}
if val != "" && val != "0000000000000000000000000000000000000000000000000000000000000000" {
- currentPeer.PresharedKey = true
+ if pskKey, err := hexToWireguardKey(val); err == nil {
+ currentPeer.PresharedKey = [32]byte(pskKey)
+ }
}
}
}
diff --git a/client/iface/configurer/wgshow.go b/client/iface/configurer/wgshow.go
index 604264026..4a5c31160 100644
--- a/client/iface/configurer/wgshow.go
+++ b/client/iface/configurer/wgshow.go
@@ -12,7 +12,7 @@ type Peer struct {
TxBytes int64
RxBytes int64
LastHandshake time.Time
- PresharedKey bool
+ PresharedKey [32]byte
}
type Stats struct {
diff --git a/client/iface/device/interface.go b/client/iface/device/interface.go
index db53d9c3a..7bab7b757 100644
--- a/client/iface/device/interface.go
+++ b/client/iface/device/interface.go
@@ -17,6 +17,7 @@ type WGConfigurer interface {
RemovePeer(peerKey string) error
AddAllowedIP(peerKey string, allowedIP netip.Prefix) error
RemoveAllowedIP(peerKey string, allowedIP netip.Prefix) error
+ SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
Close()
GetStats() (map[string]configurer.WGStats, error)
FullStats() (*configurer.Stats, error)
diff --git a/client/iface/iface.go b/client/iface/iface.go
index 07235a995..71fd433ad 100644
--- a/client/iface/iface.go
+++ b/client/iface/iface.go
@@ -297,6 +297,19 @@ func (w *WGIface) FullStats() (*configurer.Stats, error) {
return w.configurer.FullStats()
}
+// SetPresharedKey sets or updates the preshared key for a peer.
+// If updateOnly is true, only updates existing peer; if false, creates or updates.
+func (w *WGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ if w.configurer == nil {
+ return ErrIfaceNotFound
+ }
+
+ return w.configurer.SetPresharedKey(peerKey, psk, updateOnly)
+}
+
func (w *WGIface) waitUntilRemoved() error {
maxWaitTime := 5 * time.Second
timeout := time.NewTimer(maxWaitTime)
diff --git a/client/internal/connect.go b/client/internal/connect.go
index dad2e6b6b..7fc3c9a96 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -70,8 +70,8 @@ func NewConnectClient(
}
// Run with main logic.
-func (c *ConnectClient) Run(runningChan chan struct{}) error {
- return c.run(MobileDependency{}, runningChan)
+func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error {
+ return c.run(MobileDependency{}, runningChan, logPath)
}
// RunOnAndroid with main logic on mobile system
@@ -92,7 +92,7 @@ func (c *ConnectClient) RunOnAndroid(
DnsReadyListener: dnsReadyListener,
StateFilePath: stateFilePath,
}
- return c.run(mobileDependency, nil)
+ return c.run(mobileDependency, nil, "")
}
func (c *ConnectClient) RunOniOS(
@@ -110,10 +110,10 @@ func (c *ConnectClient) RunOniOS(
DnsManager: dnsManager,
StateFilePath: stateFilePath,
}
- return c.run(mobileDependency, nil)
+ return c.run(mobileDependency, nil, "")
}
-func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}) error {
+func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error {
defer func() {
if r := recover(); r != nil {
rec := c.statusRecorder
@@ -283,7 +283,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
relayURLs, token := parseRelayInfo(loginResp)
peerConfig := loginResp.GetPeerConfig()
- engineConfig, err := createEngineConfig(myPrivateKey, c.config, peerConfig)
+ engineConfig, err := createEngineConfig(myPrivateKey, c.config, peerConfig, logPath)
if err != nil {
log.Error(err)
return wrapErr(err)
@@ -419,6 +419,19 @@ func (c *ConnectClient) GetLatestSyncResponse() (*mgmProto.SyncResponse, error)
return syncResponse, nil
}
+// SetLogLevel sets the log level for the firewall manager if the engine is running.
+func (c *ConnectClient) SetLogLevel(level log.Level) {
+ engine := c.Engine()
+ if engine == nil {
+ return
+ }
+
+ fwManager := engine.GetFirewallManager()
+ if fwManager != nil {
+ fwManager.SetLogLevel(level)
+ }
+}
+
// Status returns the current client status
func (c *ConnectClient) Status() StatusType {
if c == nil {
@@ -458,7 +471,7 @@ func (c *ConnectClient) SetSyncResponsePersistence(enabled bool) {
}
// createEngineConfig converts configuration received from Management Service to EngineConfig
-func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConfig *mgmProto.PeerConfig) (*EngineConfig, error) {
+func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConfig *mgmProto.PeerConfig, logPath string) (*EngineConfig, error) {
nm := false
if config.NetworkMonitor != nil {
nm = *config.NetworkMonitor
@@ -493,7 +506,10 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
LazyConnectionEnabled: config.LazyConnectionEnabled,
- MTU: selectMTU(config.MTU, peerConfig.Mtu),
+ MTU: selectMTU(config.MTU, peerConfig.Mtu),
+ LogPath: logPath,
+
+ ProfileConfig: config,
}
if config.PreSharedKey != "" {
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index 25390fe65..d7a24fa38 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -28,8 +28,10 @@ import (
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/updatemanager/installer"
+ 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
@@ -51,7 +53,6 @@ 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: Client metrics in Prometheus format including connection statistics, reliability metrics, and performance indicators.
mutex.prof: Mutex profiling information.
goroutine.prof: Goroutine profiling information.
block.prof: Block profiling information.
@@ -229,11 +230,10 @@ type BundleGenerator struct {
internalConfig *profilemanager.Config
statusRecorder *peer.Status
syncResponse *mgmProto.SyncResponse
- logFile string
+ logPath string
clientMetrics MetricsExporter
anonymize bool
- clientStatus string
includeSystemInfo bool
logFileCount uint32
@@ -242,7 +242,6 @@ type BundleGenerator struct {
type BundleConfig struct {
Anonymize bool
- ClientStatus string
IncludeSystemInfo bool
LogFileCount uint32
}
@@ -251,7 +250,7 @@ type GeneratorDependencies struct {
InternalConfig *profilemanager.Config
StatusRecorder *peer.Status
SyncResponse *mgmProto.SyncResponse
- LogFile string
+ LogPath string
ClientMetrics MetricsExporter
}
@@ -268,11 +267,10 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
internalConfig: deps.InternalConfig,
statusRecorder: deps.StatusRecorder,
syncResponse: deps.SyncResponse,
- logFile: deps.LogFile,
+ logPath: deps.LogPath,
clientMetrics: deps.ClientMetrics,
anonymize: cfg.Anonymize,
- clientStatus: cfg.ClientStatus,
includeSystemInfo: cfg.IncludeSystemInfo,
logFileCount: logFileCount,
}
@@ -318,13 +316,6 @@ func (g *BundleGenerator) createArchive() error {
return fmt.Errorf("add status: %w", err)
}
- if g.statusRecorder != nil {
- status := g.statusRecorder.GetFullStatus()
- seedFromStatus(g.anonymizer, &status)
- } else {
- log.Debugf("no status recorder available for seeding")
- }
-
if err := g.addConfig(); err != nil {
log.Errorf("failed to add config to debug bundle: %v", err)
}
@@ -365,7 +356,7 @@ func (g *BundleGenerator) createArchive() error {
log.Errorf("failed to add wg show output: %v", err)
}
- if g.logFile != "" && !slices.Contains(util.SpecialLogs, g.logFile) {
+ if g.logPath != "" && !slices.Contains(util.SpecialLogs, g.logPath) {
if err := g.addLogfile(); err != nil {
log.Errorf("failed to add log file to debug bundle: %v", err)
if err := g.trySystemdLogFallback(); err != nil {
@@ -414,11 +405,26 @@ func (g *BundleGenerator) addReadme() error {
}
func (g *BundleGenerator) addStatus() error {
- if status := g.clientStatus; status != "" {
- statusReader := strings.NewReader(status)
+ if g.statusRecorder != nil {
+ pm := profilemanager.NewProfileManager()
+ var profName string
+ if activeProf, err := pm.GetActiveProfile(); err == nil {
+ profName = activeProf.Name
+ }
+
+ 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)
+ statusOutput := overview.FullDetailSummary()
+
+ statusReader := strings.NewReader(statusOutput)
if err := g.addFileToZip(statusReader, "status.txt"); err != nil {
return fmt.Errorf("add status file to zip: %w", err)
}
+ seedFromStatus(g.anonymizer, &fullStatus)
+ } else {
+ log.Debugf("no status recorder available for seeding")
}
return nil
}
@@ -668,25 +674,6 @@ func (g *BundleGenerator) addStateFile() 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 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) addUpdateLogs() error {
inst := installer.New()
logFiles := inst.LogFiles()
@@ -741,15 +728,34 @@ 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 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.logFile == "" {
+ if g.logPath == "" {
log.Debugf("skipping empty log file in debug bundle")
return nil
}
- logDir := filepath.Dir(g.logFile)
+ logDir := filepath.Dir(g.logPath)
- if err := g.addSingleLogfile(g.logFile, clientLogFile); err != nil {
+ if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil {
return fmt.Errorf("add client log file to zip: %w", err)
}
diff --git a/client/internal/debug/upload.go b/client/internal/debug/upload.go
new file mode 100644
index 000000000..cdf52409d
--- /dev/null
+++ b/client/internal/debug/upload.go
@@ -0,0 +1,101 @@
+package debug
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/netbirdio/netbird/upload-server/types"
+)
+
+const maxBundleUploadSize = 50 * 1024 * 1024
+
+func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) {
+ response, err := getUploadURL(ctx, url, managementURL)
+ if err != nil {
+ return "", err
+ }
+
+ err = upload(ctx, filePath, response)
+ if err != nil {
+ return "", err
+ }
+ return response.Key, nil
+}
+
+func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error {
+ fileData, err := os.Open(filePath)
+ if err != nil {
+ return fmt.Errorf("open file: %w", err)
+ }
+
+ defer fileData.Close()
+
+ stat, err := fileData.Stat()
+ if err != nil {
+ return fmt.Errorf("stat file: %w", err)
+ }
+
+ if stat.Size() > maxBundleUploadSize {
+ return fmt.Errorf("file size exceeds maximum limit of %d bytes", maxBundleUploadSize)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "PUT", response.URL, fileData)
+ if err != nil {
+ return fmt.Errorf("create PUT request: %w", err)
+ }
+
+ req.ContentLength = stat.Size()
+ req.Header.Set("Content-Type", "application/octet-stream")
+
+ putResp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("upload failed: %v", err)
+ }
+ defer putResp.Body.Close()
+
+ if putResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(putResp.Body)
+ return fmt.Errorf("upload status %d: %s", putResp.StatusCode, string(body))
+ }
+ return nil
+}
+
+func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) {
+ id := getURLHash(managementURL)
+ getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create GET request: %w", err)
+ }
+
+ getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue)
+
+ resp, err := http.DefaultClient.Do(getReq)
+ if err != nil {
+ return nil, fmt.Errorf("get presigned URL: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("get presigned URL status %d: %s", resp.StatusCode, string(body))
+ }
+
+ urlBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response body: %w", err)
+ }
+ var response types.GetURLResponse
+ if err := json.Unmarshal(urlBytes, &response); err != nil {
+ return nil, fmt.Errorf("unmarshal response: %w", err)
+ }
+ return &response, nil
+}
+
+func getURLHash(url string) string {
+ return fmt.Sprintf("%x", sha256.Sum256([]byte(url)))
+}
diff --git a/client/server/debug_test.go b/client/internal/debug/upload_test.go
similarity index 93%
rename from client/server/debug_test.go
rename to client/internal/debug/upload_test.go
index 53d9ac8ed..e833c196d 100644
--- a/client/server/debug_test.go
+++ b/client/internal/debug/upload_test.go
@@ -1,4 +1,4 @@
-package server
+package debug
import (
"context"
@@ -38,7 +38,7 @@ func TestUpload(t *testing.T) {
fileContent := []byte("test file content")
err := os.WriteFile(file, fileContent, 0640)
require.NoError(t, err)
- key, err := uploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file)
+ key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file)
require.NoError(t, err)
id := getURLHash(testURL)
require.Contains(t, key, id+"/")
diff --git a/client/internal/debug/wgshow.go b/client/internal/debug/wgshow.go
index 8233ca510..1e8a8a6cc 100644
--- a/client/internal/debug/wgshow.go
+++ b/client/internal/debug/wgshow.go
@@ -60,7 +60,7 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
}
sb.WriteString(fmt.Sprintf(" latest handshake: %s\n", peer.LastHandshake.Format(time.RFC1123)))
sb.WriteString(fmt.Sprintf(" transfer: %d B received, %d B sent\n", peer.RxBytes, peer.TxBytes))
- if peer.PresharedKey {
+ if peer.PresharedKey != [32]byte{} {
sb.WriteString(" preshared key: (hidden)\n")
}
}
diff --git a/client/internal/dns.go b/client/internal/dns.go
index 3c68e4d00..f5040ee49 100644
--- a/client/internal/dns.go
+++ b/client/internal/dns.go
@@ -76,7 +76,7 @@ func collectPTRRecords(config *nbdns.Config, prefix netip.Prefix) []nbdns.Simple
var records []nbdns.SimpleRecord
for _, zone := range config.CustomZones {
- if zone.SkipPTRProcess {
+ if zone.NonAuthoritative {
continue
}
for _, record := range zone.Records {
diff --git a/client/internal/dns/handler_chain.go b/client/internal/dns/handler_chain.go
index 2e54bffd9..06a2056b1 100644
--- a/client/internal/dns/handler_chain.go
+++ b/client/internal/dns/handler_chain.go
@@ -3,17 +3,21 @@ package dns
import (
"fmt"
"slices"
+ "strconv"
"strings"
"sync"
+ "time"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/dns/resutil"
)
const (
PriorityMgmtCache = 150
- PriorityLocal = 100
- PriorityDNSRoute = 75
+ PriorityDNSRoute = 100
+ PriorityLocal = 75
PriorityUpstream = 50
PriorityDefault = 1
PriorityFallback = -100
@@ -43,7 +47,23 @@ type HandlerChain struct {
type ResponseWriterChain struct {
dns.ResponseWriter
origPattern string
+ requestID string
shouldContinue bool
+ response *dns.Msg
+ meta map[string]string
+}
+
+// RequestID returns the request ID for tracing
+func (w *ResponseWriterChain) RequestID() string {
+ return w.requestID
+}
+
+// SetMeta sets a metadata key-value pair for logging
+func (w *ResponseWriterChain) SetMeta(key, value string) {
+ if w.meta == nil {
+ w.meta = make(map[string]string)
+ }
+ w.meta[key] = value
}
func (w *ResponseWriterChain) WriteMsg(m *dns.Msg) error {
@@ -52,6 +72,7 @@ func (w *ResponseWriterChain) WriteMsg(m *dns.Msg) error {
w.shouldContinue = true
return nil
}
+ w.response = m
return w.ResponseWriter.WriteMsg(m)
}
@@ -101,6 +122,8 @@ func (c *HandlerChain) AddHandler(pattern string, handler dns.Handler, priority
pos := c.findHandlerPosition(entry)
c.handlers = append(c.handlers[:pos], append([]HandlerEntry{entry}, c.handlers[pos:]...)...)
+
+ c.logHandlers()
}
// findHandlerPosition determines where to insert a new handler based on priority and specificity
@@ -140,68 +163,109 @@ func (c *HandlerChain) removeEntry(pattern string, priority int) {
for i := len(c.handlers) - 1; i >= 0; i-- {
entry := c.handlers[i]
if strings.EqualFold(entry.OrigPattern, pattern) && entry.Priority == priority {
+ log.Debugf("removing handler pattern: domain=%s priority=%d", entry.OrigPattern, priority)
c.handlers = append(c.handlers[:i], c.handlers[i+1:]...)
+ c.logHandlers()
break
}
}
}
+// logHandlers logs the current handler chain state. Caller must hold the lock.
+func (c *HandlerChain) logHandlers() {
+ if !log.IsLevelEnabled(log.TraceLevel) {
+ return
+ }
+
+ var b strings.Builder
+ b.WriteString("handler chain (" + strconv.Itoa(len(c.handlers)) + "):\n")
+ for _, h := range c.handlers {
+ b.WriteString(" - pattern: domain=" + h.Pattern + " original: domain=" + h.OrigPattern +
+ " wildcard=" + strconv.FormatBool(h.IsWildcard) +
+ " match_subdomain=" + strconv.FormatBool(h.MatchSubdomains) +
+ " priority=" + strconv.Itoa(h.Priority) + "\n")
+ }
+ log.Trace(strings.TrimSuffix(b.String(), "\n"))
+}
+
func (c *HandlerChain) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
- qname := strings.ToLower(r.Question[0].Name)
+ startTime := time.Now()
+ requestID := resutil.GenerateRequestID()
+ logger := log.WithFields(log.Fields{
+ "request_id": requestID,
+ "dns_id": fmt.Sprintf("%04x", r.Id),
+ })
+
+ question := r.Question[0]
+ qname := strings.ToLower(question.Name)
c.mu.RLock()
handlers := slices.Clone(c.handlers)
c.mu.RUnlock()
- if log.IsLevelEnabled(log.TraceLevel) {
- var b strings.Builder
- b.WriteString(fmt.Sprintf("DNS request domain=%s, handlers (%d):\n", qname, len(handlers)))
- for _, h := range handlers {
- b.WriteString(fmt.Sprintf(" - pattern: domain=%s original: domain=%s wildcard=%v match_subdomain=%v priority=%d\n",
- h.Pattern, h.OrigPattern, h.IsWildcard, h.MatchSubdomains, h.Priority))
- }
- log.Trace(strings.TrimSuffix(b.String(), "\n"))
- }
-
// Try handlers in priority order
for _, entry := range handlers {
- matched := c.isHandlerMatch(qname, entry)
-
- if matched {
- log.Tracef("handler matched: domain=%s -> pattern=%s wildcard=%v match_subdomain=%v priority=%d",
- qname, entry.OrigPattern, entry.IsWildcard, entry.MatchSubdomains, entry.Priority)
-
- chainWriter := &ResponseWriterChain{
- ResponseWriter: w,
- origPattern: entry.OrigPattern,
- }
- entry.Handler.ServeDNS(chainWriter, r)
-
- // If handler wants to continue, try next handler
- if chainWriter.shouldContinue {
- // Only log continue for non-management cache handlers to reduce noise
- if entry.Priority != PriorityMgmtCache {
- log.Tracef("handler requested continue to next handler for domain=%s", qname)
- }
- continue
- }
- return
+ if !c.isHandlerMatch(qname, entry) {
+ continue
}
+
+ handlerName := entry.OrigPattern
+ if s, ok := entry.Handler.(interface{ String() string }); ok {
+ handlerName = s.String()
+ }
+
+ logger.Tracef("question: domain=%s type=%s class=%s -> handler=%s pattern=%s wildcard=%v match_subdomain=%v priority=%d",
+ qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass],
+ handlerName, entry.OrigPattern, entry.IsWildcard, entry.MatchSubdomains, entry.Priority)
+
+ chainWriter := &ResponseWriterChain{
+ ResponseWriter: w,
+ origPattern: entry.OrigPattern,
+ requestID: requestID,
+ }
+ entry.Handler.ServeDNS(chainWriter, r)
+
+ // If handler wants to continue, try next handler
+ if chainWriter.shouldContinue {
+ if entry.Priority != PriorityMgmtCache {
+ logger.Tracef("handler requested continue for domain=%s", qname)
+ }
+ continue
+ }
+
+ c.logResponse(logger, chainWriter, qname, startTime)
+ return
}
// No handler matched or all handlers passed
- log.Tracef("no handler found for domain=%s", qname)
+ logger.Tracef("no handler found for domain=%s type=%s class=%s",
+ qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
resp := &dns.Msg{}
resp.SetRcode(r, dns.RcodeRefused)
if err := w.WriteMsg(resp); err != nil {
- log.Errorf("failed to write DNS response: %v", err)
+ logger.Errorf("failed to write DNS response: %v", err)
}
}
+func (c *HandlerChain) logResponse(logger *log.Entry, cw *ResponseWriterChain, qname string, startTime time.Time) {
+ if cw.response == nil {
+ return
+ }
+
+ var meta string
+ for k, v := range cw.meta {
+ meta += " " + k + "=" + v
+ }
+
+ logger.Tracef("response: domain=%s rcode=%s answers=%s%s took=%s",
+ qname, dns.RcodeToString[cw.response.Rcode], resutil.FormatAnswers(cw.response.Answer),
+ meta, time.Since(startTime))
+}
+
func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool {
switch {
case entry.Pattern == ".":
diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go
index bac7875ec..cbdc64997 100644
--- a/client/internal/dns/local/local.go
+++ b/client/internal/dns/local/local.go
@@ -1,30 +1,52 @@
package local
import (
+ "context"
+ "errors"
"fmt"
+ "net"
+ "net/netip"
"slices"
"strings"
"sync"
+ "time"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
+ "github.com/netbirdio/netbird/client/internal/dns/resutil"
"github.com/netbirdio/netbird/client/internal/dns/types"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/shared/management/domain"
)
+const externalResolutionTimeout = 4 * time.Second
+
+type resolver interface {
+ LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
+}
+
type Resolver struct {
mu sync.RWMutex
records map[dns.Question][]dns.RR
domains map[domain.Domain]struct{}
+ // zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
+ zones map[domain.Domain]bool
+ resolver resolver
+
+ ctx context.Context
+ cancel context.CancelFunc
}
func NewResolver() *Resolver {
+ ctx, cancel := context.WithCancel(context.Background())
return &Resolver{
records: make(map[dns.Question][]dns.RR),
domains: make(map[domain.Domain]struct{}),
+ zones: make(map[domain.Domain]bool),
+ ctx: ctx,
+ cancel: cancel,
}
}
@@ -37,7 +59,18 @@ func (d *Resolver) String() string {
return fmt.Sprintf("LocalResolver [%d records]", len(d.records))
}
-func (d *Resolver) Stop() {}
+func (d *Resolver) Stop() {
+ if d.cancel != nil {
+ d.cancel()
+ }
+
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ maps.Clear(d.records)
+ maps.Clear(d.domains)
+ maps.Clear(d.zones)
+}
// ID returns the unique handler ID
func (d *Resolver) ID() types.HandlerID {
@@ -48,60 +81,147 @@ func (d *Resolver) ProbeAvailability() {}
// ServeDNS handles a DNS request
func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
+ logger := log.WithField("request_id", resutil.GetRequestID(w))
+
if len(r.Question) == 0 {
- log.Debugf("received local resolver request with no question")
+ logger.Debug("received local resolver request with no question")
return
}
question := r.Question[0]
question.Name = strings.ToLower(dns.Fqdn(question.Name))
- log.Tracef("received local question: domain=%s type=%v class=%v", r.Question[0].Name, question.Qtype, question.Qclass)
-
replyMessage := &dns.Msg{}
replyMessage.SetReply(r)
replyMessage.RecursionAvailable = true
- // lookup all records matching the question
- records := d.lookupRecords(question)
- if len(records) > 0 {
- replyMessage.Rcode = dns.RcodeSuccess
- replyMessage.Answer = append(replyMessage.Answer, records...)
- } else {
- // Check if we have any records for this domain name with different types
- if d.hasRecordsForDomain(domain.Domain(question.Name)) {
- replyMessage.Rcode = dns.RcodeSuccess // NOERROR with 0 records
- } else {
- replyMessage.Rcode = dns.RcodeNameError // NXDOMAIN
- }
+ result := d.lookupRecords(logger, question)
+ replyMessage.Authoritative = !result.hasExternalData
+ replyMessage.Answer = result.records
+ replyMessage.Rcode = d.determineRcode(question, result)
+
+ if replyMessage.Rcode == dns.RcodeNameError && d.shouldFallthrough(question.Name) {
+ d.continueToNext(logger, w, r)
+ return
}
if err := w.WriteMsg(replyMessage); err != nil {
- log.Warnf("failed to write the local resolver response: %v", err)
+ logger.Warnf("failed to write the local resolver response: %v", err)
+ }
+}
+
+// determineRcode returns the appropriate DNS response code.
+// Per RFC 6604, CNAME chains should return the rcode of the final target resolution,
+// even if CNAME records are included in the answer.
+func (d *Resolver) determineRcode(question dns.Question, result lookupResult) int {
+ // Use the rcode from lookup - this properly handles CNAME chains where
+ // the target may be NXDOMAIN or SERVFAIL even though we have CNAME records
+ if result.rcode != 0 {
+ return result.rcode
+ }
+
+ // No records found, but domain exists with different record types (NODATA)
+ if d.hasRecordsForDomain(domain.Domain(question.Name), question.Qtype) {
+ return dns.RcodeSuccess
+ }
+
+ return dns.RcodeNameError
+}
+
+// findZone finds the matching zone for a query name using reverse suffix lookup.
+// Returns (nonAuthoritative, found). This is O(k) where k = number of labels in qname.
+func (d *Resolver) findZone(qname string) (nonAuthoritative bool, found bool) {
+ qname = strings.ToLower(dns.Fqdn(qname))
+ for {
+ if nonAuth, ok := d.zones[domain.Domain(qname)]; ok {
+ return nonAuth, true
+ }
+ // Move to parent domain
+ idx := strings.Index(qname, ".")
+ if idx == -1 || idx == len(qname)-1 {
+ return false, false
+ }
+ qname = qname[idx+1:]
+ }
+}
+
+// shouldFallthrough checks if the query should fallthrough to the next handler.
+// Returns true if the queried name belongs to a non-authoritative zone.
+func (d *Resolver) shouldFallthrough(qname string) bool {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+
+ nonAuth, found := d.findZone(qname)
+ return found && nonAuth
+}
+
+func (d *Resolver) continueToNext(logger *log.Entry, w dns.ResponseWriter, r *dns.Msg) {
+ resp := &dns.Msg{}
+ resp.SetRcode(r, dns.RcodeNameError)
+ resp.MsgHdr.Zero = true
+ if err := w.WriteMsg(resp); err != nil {
+ logger.Warnf("failed to write continue signal: %v", err)
}
}
// hasRecordsForDomain checks if any records exist for the given domain name regardless of type
-func (d *Resolver) hasRecordsForDomain(domainName domain.Domain) bool {
+func (d *Resolver) hasRecordsForDomain(domainName domain.Domain, qType uint16) bool {
d.mu.RLock()
defer d.mu.RUnlock()
_, exists := d.domains[domainName]
+ if !exists && supportsWildcard(qType) {
+ testWild := transformDomainToWildcard(string(domainName))
+ _, exists = d.domains[domain.Domain(testWild)]
+ }
return exists
}
+// isInManagedZone checks if the given name falls within any of our managed zones.
+// This is used to avoid unnecessary external resolution for CNAME targets that
+// are within zones we manage - if we don't have a record for it, it doesn't exist.
+// Caller must NOT hold the lock.
+func (d *Resolver) isInManagedZone(name string) bool {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+
+ _, found := d.findZone(name)
+ return found
+}
+
+// lookupResult contains the result of a DNS lookup operation.
+type lookupResult struct {
+ records []dns.RR
+ rcode int
+ hasExternalData bool
+}
+
// lookupRecords fetches *all* DNS records matching the first question in r.
-func (d *Resolver) lookupRecords(question dns.Question) []dns.RR {
+func (d *Resolver) lookupRecords(logger *log.Entry, question dns.Question) lookupResult {
d.mu.RLock()
records, found := d.records[question]
+ usingWildcard := false
+ wildQuestion := transformToWildcard(question)
+ // RFC 4592 section 2.2.1: wildcard only matches if the name does NOT exist in the zone.
+ // If the domain exists with any record type, return NODATA instead of wildcard match.
+ if !found && supportsWildcard(question.Qtype) {
+ if _, domainExists := d.domains[domain.Domain(question.Name)]; !domainExists {
+ records, found = d.records[wildQuestion]
+ usingWildcard = found
+ }
+ }
if !found {
d.mu.RUnlock()
// alternatively check if we have a cname
if question.Qtype != dns.TypeCNAME {
- question.Qtype = dns.TypeCNAME
- return d.lookupRecords(question)
+ cnameQuestion := dns.Question{
+ Name: question.Name,
+ Qtype: dns.TypeCNAME,
+ Qclass: question.Qclass,
+ }
+ return d.lookupCNAMEChain(logger, cnameQuestion, question.Qtype)
}
- return nil
+ return lookupResult{rcode: dns.RcodeNameError}
}
recordsCopy := slices.Clone(records)
@@ -110,29 +230,229 @@ func (d *Resolver) lookupRecords(question dns.Question) []dns.RR {
// if there's more than one record, rotate them (round-robin)
if len(recordsCopy) > 1 {
d.mu.Lock()
- records = d.records[question]
+ q := question
+ if usingWildcard {
+ q = wildQuestion
+ }
+ records = d.records[q]
if len(records) > 1 {
first := records[0]
records = append(records[1:], first)
- d.records[question] = records
+ d.records[q] = records
}
d.mu.Unlock()
}
- return recordsCopy
+ if usingWildcard {
+ return responseFromWildRecords(question.Name, wildQuestion.Name, recordsCopy)
+ }
+
+ return lookupResult{records: recordsCopy, rcode: dns.RcodeSuccess}
}
-func (d *Resolver) Update(update []nbdns.SimpleRecord) {
+func transformToWildcard(question dns.Question) dns.Question {
+ wildQuestion := question
+ wildQuestion.Name = transformDomainToWildcard(wildQuestion.Name)
+ return wildQuestion
+}
+
+func transformDomainToWildcard(domain string) string {
+ s := strings.Split(domain, ".")
+ s[0] = "*"
+ return strings.Join(s, ".")
+}
+
+func supportsWildcard(queryType uint16) bool {
+ return queryType != dns.TypeNS && queryType != dns.TypeSOA
+}
+
+func responseFromWildRecords(originalName, wildName string, wildRecords []dns.RR) lookupResult {
+ records := make([]dns.RR, len(wildRecords))
+ for i, record := range wildRecords {
+ copiedRecord := dns.Copy(record)
+ copiedRecord.Header().Name = originalName
+ records[i] = copiedRecord
+ }
+
+ return lookupResult{records: records, rcode: dns.RcodeSuccess}
+}
+
+// lookupCNAMEChain follows a CNAME chain and returns the CNAME records along with
+// the final resolved record of the requested type. This is required for musl libc
+// compatibility, which expects the full answer chain rather than just the CNAME.
+func (d *Resolver) lookupCNAMEChain(logger *log.Entry, cnameQuestion dns.Question, targetType uint16) lookupResult {
+ const maxDepth = 8
+ var chain []dns.RR
+
+ for range maxDepth {
+ cnameRecords := d.getRecords(cnameQuestion)
+ if len(cnameRecords) == 0 && supportsWildcard(targetType) {
+ wildQuestion := transformToWildcard(cnameQuestion)
+ if wildRecords := d.getRecords(wildQuestion); len(wildRecords) > 0 {
+ cnameRecords = responseFromWildRecords(cnameQuestion.Name, wildQuestion.Name, wildRecords).records
+ }
+ }
+
+ if len(cnameRecords) == 0 {
+ break
+ }
+
+ chain = append(chain, cnameRecords...)
+
+ cname, ok := cnameRecords[0].(*dns.CNAME)
+ if !ok {
+ break
+ }
+
+ targetName := strings.ToLower(cname.Target)
+ targetResult := d.resolveCNAMETarget(logger, targetName, targetType, cnameQuestion.Qclass)
+
+ // keep following chain
+ if targetResult.rcode == -1 {
+ cnameQuestion = dns.Question{Name: targetName, Qtype: dns.TypeCNAME, Qclass: cnameQuestion.Qclass}
+ continue
+ }
+
+ return d.buildChainResult(chain, targetResult)
+ }
+
+ if len(chain) > 0 {
+ return lookupResult{records: chain, rcode: dns.RcodeSuccess}
+ }
+ return lookupResult{rcode: dns.RcodeSuccess}
+}
+
+// buildChainResult combines CNAME chain records with the target resolution result.
+// Per RFC 6604, the final rcode is propagated through the chain.
+func (d *Resolver) buildChainResult(chain []dns.RR, target lookupResult) lookupResult {
+ records := chain
+ if len(target.records) > 0 {
+ records = append(records, target.records...)
+ }
+
+ // preserve hasExternalData for SERVFAIL so caller knows the error came from upstream
+ if target.hasExternalData && target.rcode == dns.RcodeServerFailure {
+ return lookupResult{
+ records: records,
+ rcode: dns.RcodeServerFailure,
+ hasExternalData: true,
+ }
+ }
+
+ return lookupResult{
+ records: records,
+ rcode: target.rcode,
+ hasExternalData: target.hasExternalData,
+ }
+}
+
+// resolveCNAMETarget attempts to resolve a CNAME target name.
+// Returns rcode=-1 to signal "keep following the chain".
+func (d *Resolver) resolveCNAMETarget(logger *log.Entry, targetName string, targetType uint16, qclass uint16) lookupResult {
+ if records := d.getRecords(dns.Question{Name: targetName, Qtype: targetType, Qclass: qclass}); len(records) > 0 {
+ return lookupResult{records: records, rcode: dns.RcodeSuccess}
+ }
+
+ // another CNAME, keep following
+ if d.hasRecord(dns.Question{Name: targetName, Qtype: dns.TypeCNAME, Qclass: qclass}) {
+ return lookupResult{rcode: -1}
+ }
+
+ // domain exists locally but not this record type (NODATA)
+ if d.hasRecordsForDomain(domain.Domain(targetName), targetType) {
+ return lookupResult{rcode: dns.RcodeSuccess}
+ }
+
+ // in our zone but doesn't exist (NXDOMAIN)
+ if d.isInManagedZone(targetName) {
+ return lookupResult{rcode: dns.RcodeNameError}
+ }
+
+ return d.resolveExternal(logger, targetName, targetType)
+}
+
+func (d *Resolver) getRecords(q dns.Question) []dns.RR {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+ return d.records[q]
+}
+
+func (d *Resolver) hasRecord(q dns.Question) bool {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+ _, ok := d.records[q]
+ return ok
+}
+
+// resolveExternal resolves a domain name using the system resolver.
+// This is used to resolve CNAME targets that point outside our local zone,
+// which is required for musl libc compatibility (musl expects complete answers).
+func (d *Resolver) resolveExternal(logger *log.Entry, name string, qtype uint16) lookupResult {
+ network := resutil.NetworkForQtype(qtype)
+ if network == "" {
+ return lookupResult{rcode: dns.RcodeNotImplemented}
+ }
+
+ resolver := d.resolver
+ if resolver == nil {
+ resolver = net.DefaultResolver
+ }
+
+ ctx, cancel := context.WithTimeout(d.ctx, externalResolutionTimeout)
+ defer cancel()
+
+ result := resutil.LookupIP(ctx, resolver, network, name, qtype)
+ if result.Err != nil {
+ d.logDNSError(logger, name, qtype, result.Err)
+ return lookupResult{rcode: result.Rcode, hasExternalData: true}
+ }
+
+ return lookupResult{
+ records: resutil.IPsToRRs(name, result.IPs, 60),
+ rcode: dns.RcodeSuccess,
+ hasExternalData: true,
+ }
+}
+
+// logDNSError logs DNS resolution errors for debugging.
+func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16, err error) {
+ qtypeName := dns.TypeToString[qtype]
+
+ var dnsErr *net.DNSError
+ if !errors.As(err, &dnsErr) {
+ logger.Debugf("DNS resolution failed for %s type %s: %v", hostname, qtypeName, err)
+ return
+ }
+
+ if dnsErr.IsNotFound {
+ logger.Tracef("DNS target not found: %s type %s", hostname, qtypeName)
+ return
+ }
+
+ if dnsErr.Server != "" {
+ logger.Debugf("DNS resolution failed for %s type %s server=%s: %v", hostname, qtypeName, dnsErr.Server, err)
+ } else {
+ logger.Debugf("DNS resolution failed for %s type %s: %v", hostname, qtypeName, err)
+ }
+}
+
+// Update replaces all zones and their records
+func (d *Resolver) Update(customZones []nbdns.CustomZone) {
d.mu.Lock()
defer d.mu.Unlock()
maps.Clear(d.records)
maps.Clear(d.domains)
+ maps.Clear(d.zones)
- for _, rec := range update {
- if err := d.registerRecord(rec); err != nil {
- log.Warnf("failed to register the record (%s): %v", rec, err)
- continue
+ for _, zone := range customZones {
+ zoneDomain := domain.Domain(strings.ToLower(dns.Fqdn(zone.Domain)))
+ d.zones[zoneDomain] = zone.NonAuthoritative
+
+ for _, rec := range zone.Records {
+ if err := d.registerRecord(rec); err != nil {
+ log.Warnf("failed to register the record (%s): %v", rec, err)
+ }
}
}
}
diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go
index 8b13b69ff..73f70035f 100644
--- a/client/internal/dns/local/local_test.go
+++ b/client/internal/dns/local/local_test.go
@@ -1,8 +1,14 @@
package local
import (
+ "context"
+ "fmt"
+ "net"
+ "net/netip"
"strings"
+ "sync"
"testing"
+ "time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
@@ -12,6 +18,18 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
)
+// mockResolver implements resolver for testing
+type mockResolver struct {
+ lookupFunc func(ctx context.Context, network, host string) ([]netip.Addr, error)
+}
+
+func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) {
+ if m.lookupFunc != nil {
+ return m.lookupFunc(ctx, network, host)
+ }
+ return nil, nil
+}
+
func TestLocalResolver_ServeDNS(t *testing.T) {
recordA := nbdns.SimpleRecord{
Name: "peera.netbird.cloud.",
@@ -29,6 +47,24 @@ func TestLocalResolver_ServeDNS(t *testing.T) {
RData: "www.netbird.io",
}
+ wild := "wild.netbird.cloud."
+
+ recordWild := nbdns.SimpleRecord{
+ Name: "*." + wild,
+ Type: 1,
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "1.2.3.4",
+ }
+
+ specificRecord := nbdns.SimpleRecord{
+ Name: "existing." + wild,
+ Type: 1,
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "5.6.7.8",
+ }
+
testCases := []struct {
name string
inputRecord nbdns.SimpleRecord
@@ -51,12 +87,23 @@ func TestLocalResolver_ServeDNS(t *testing.T) {
inputMSG: new(dns.Msg).SetQuestion("not.found.com", dns.TypeA),
responseShouldBeNil: true,
},
+ {
+ name: "Should Resolve A Wild Record",
+ inputRecord: recordWild,
+ inputMSG: new(dns.Msg).SetQuestion("test."+wild, dns.TypeA),
+ },
+ {
+ name: "Should Resolve A more specific Record",
+ inputRecord: specificRecord,
+ inputMSG: new(dns.Msg).SetQuestion(specificRecord.Name, dns.TypeA),
+ },
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
resolver := NewResolver()
_ = resolver.RegisterRecord(testCase.inputRecord)
+ _ = resolver.RegisterRecord(recordWild)
var responseMSG *dns.Msg
responseWriter := &test.MockResponseWriter{
WriteMsgFunc: func(m *dns.Msg) error {
@@ -75,7 +122,7 @@ func TestLocalResolver_ServeDNS(t *testing.T) {
}
answerString := responseMSG.Answer[0].String()
- if !strings.Contains(answerString, testCase.inputRecord.Name) {
+ if !strings.Contains(answerString, testCase.inputMSG.Question[0].Name) {
t.Fatalf("answer doesn't contain the same domain name: \nWant: %s\nGot:%s", testCase.name, answerString)
}
if !strings.Contains(answerString, dns.Type(testCase.inputRecord.Type).String()) {
@@ -106,11 +153,11 @@ func TestLocalResolver_Update_StaleRecord(t *testing.T) {
resolver := NewResolver()
- update1 := []nbdns.SimpleRecord{record1}
- update2 := []nbdns.SimpleRecord{record2}
+ zone1 := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1}}}
+ zone2 := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record2}}}
// Apply first update
- resolver.Update(update1)
+ resolver.Update(zone1)
// Verify first update
resolver.mu.RLock()
@@ -122,7 +169,7 @@ func TestLocalResolver_Update_StaleRecord(t *testing.T) {
assert.Contains(t, rrSlice1[0].String(), record1.RData, "Record after first update should be %s", record1.RData)
// Apply second update
- resolver.Update(update2)
+ resolver.Update(zone2)
// Verify second update
resolver.mu.RLock()
@@ -151,10 +198,10 @@ func TestLocalResolver_MultipleRecords_SameQuestion(t *testing.T) {
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2",
}
- update := []nbdns.SimpleRecord{record1, record2}
+ zones := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1, record2}}}
// Apply update with both records
- resolver.Update(update)
+ resolver.Update(zones)
// Create question that matches both records
question := dns.Question{
@@ -195,10 +242,10 @@ func TestLocalResolver_RecordRotation(t *testing.T) {
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.3",
}
- update := []nbdns.SimpleRecord{record1, record2, record3}
+ zones := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1, record2, record3}}}
// Apply update with all three records
- resolver.Update(update)
+ resolver.Update(zones)
msg := new(dns.Msg).SetQuestion(recordName, recordType)
@@ -264,7 +311,7 @@ func TestLocalResolver_CaseInsensitiveMatching(t *testing.T) {
}
// Update resolver with the records
- resolver.Update([]nbdns.SimpleRecord{lowerCaseRecord, mixedCaseRecord})
+ resolver.Update([]nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{lowerCaseRecord, mixedCaseRecord}}})
testCases := []struct {
name string
@@ -379,7 +426,7 @@ func TestLocalResolver_CNAMEFallback(t *testing.T) {
}
// Update resolver with both records
- resolver.Update([]nbdns.SimpleRecord{cnameRecord, targetRecord})
+ resolver.Update([]nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{cnameRecord, targetRecord}}})
testCases := []struct {
name string
@@ -476,6 +523,20 @@ func TestLocalResolver_CNAMEFallback(t *testing.T) {
// with 0 records instead of NXDOMAIN
func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) {
resolver := NewResolver()
+ // Mock external resolver for CNAME target resolution
+ resolver.resolver = &mockResolver{
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ if host == "target.example.com." {
+ if network == "ip4" {
+ return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
+ }
+ if network == "ip6" {
+ return []netip.Addr{netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")}, nil
+ }
+ }
+ return nil, &net.DNSError{IsNotFound: true, Name: host}
+ },
+ }
recordA := nbdns.SimpleRecord{
Name: "example.netbird.cloud.",
@@ -493,7 +554,7 @@ func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) {
RData: "target.example.com.",
}
- resolver.Update([]nbdns.SimpleRecord{recordA, recordCNAME})
+ resolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud.", Records: []nbdns.SimpleRecord{recordA, recordCNAME}}})
testCases := []struct {
name string
@@ -582,3 +643,2012 @@ func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) {
})
}
}
+
+// TestLocalResolver_CNAMEChainResolution tests comprehensive CNAME chain following
+func TestLocalResolver_CNAMEChainResolution(t *testing.T) {
+ t.Run("simple internal CNAME chain", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 2)
+
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "target.example.com.", cname.Target)
+
+ a, ok := resp.Answer[1].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "192.168.1.1", a.A.String())
+ })
+
+ t.Run("multi-hop CNAME chain", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "hop1.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop2.test."},
+ {Name: "hop2.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop3.test."},
+ {Name: "hop3.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("hop1.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 3)
+ })
+
+ t.Run("CNAME to non-existent internal target returns only CNAME", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.test."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Len(t, resp.Answer, 1)
+ _, ok := resp.Answer[0].(*dns.CNAME)
+ assert.True(t, ok)
+ })
+}
+
+// TestLocalResolver_CNAMEMaxDepth tests the maximum depth limit for CNAME chains
+func TestLocalResolver_CNAMEMaxDepth(t *testing.T) {
+ t.Run("chain at max depth resolves", func(t *testing.T) {
+ resolver := NewResolver()
+ var records []nbdns.SimpleRecord
+ // Create chain of 7 CNAMEs (under max of 8)
+ for i := 1; i <= 7; i++ {
+ records = append(records, nbdns.SimpleRecord{
+ Name: fmt.Sprintf("hop%d.test.", i),
+ Type: int(dns.TypeCNAME),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: fmt.Sprintf("hop%d.test.", i+1),
+ })
+ }
+ records = append(records, nbdns.SimpleRecord{
+ Name: "hop8.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.10.10",
+ })
+
+ resolver.Update([]nbdns.CustomZone{{Domain: "test.", Records: records}})
+
+ msg := new(dns.Msg).SetQuestion("hop1.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 8)
+ })
+
+ t.Run("chain exceeding max depth stops", func(t *testing.T) {
+ resolver := NewResolver()
+ var records []nbdns.SimpleRecord
+ // Create chain of 10 CNAMEs (exceeds max of 8)
+ for i := 1; i <= 10; i++ {
+ records = append(records, nbdns.SimpleRecord{
+ Name: fmt.Sprintf("deep%d.test.", i),
+ Type: int(dns.TypeCNAME),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: fmt.Sprintf("deep%d.test.", i+1),
+ })
+ }
+ records = append(records, nbdns.SimpleRecord{
+ Name: "deep11.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.10.10",
+ })
+
+ resolver.Update([]nbdns.CustomZone{{Domain: "test.", Records: records}})
+
+ msg := new(dns.Msg).SetQuestion("deep1.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ // Should NOT have the final A record (chain too deep)
+ assert.LessOrEqual(t, len(resp.Answer), 8)
+ })
+
+ t.Run("circular CNAME is protected by max depth", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "loop1.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "loop2.test."},
+ {Name: "loop2.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "loop1.test."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("loop1.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.LessOrEqual(t, len(resp.Answer), 8)
+ })
+}
+
+// TestLocalResolver_ExternalCNAMEResolution tests CNAME resolution to external domains
+func TestLocalResolver_ExternalCNAMEResolution(t *testing.T) {
+ t.Run("CNAME to external domain resolves via external resolver", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.resolver = &mockResolver{
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ if host == "external.example.com." && network == "ip4" {
+ return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
+ }
+ return nil, nil
+ },
+ }
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Len(t, resp.Answer, 2, "Should have CNAME + A record")
+
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "external.example.com.", cname.Target)
+
+ a, ok := resp.Answer[1].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "93.184.216.34", a.A.String())
+ })
+
+ t.Run("CNAME to external domain resolves IPv6", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.resolver = &mockResolver{
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ if host == "external.example.com." && network == "ip6" {
+ return []netip.Addr{netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")}, nil
+ }
+ return nil, nil
+ },
+ }
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Len(t, resp.Answer, 2, "Should have CNAME + AAAA record")
+
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "external.example.com.", cname.Target)
+
+ aaaa, ok := resp.Answer[1].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2606:2800:220:1:248:1893:25c8:1946", aaaa.AAAA.String())
+ })
+
+ t.Run("concurrent external resolution", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.resolver = &mockResolver{
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ if host == "external.example.com." && network == "ip4" {
+ return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
+ }
+ return nil, nil
+ },
+ }
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "concurrent.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
+ },
+ }})
+
+ var wg sync.WaitGroup
+ results := make([]*dns.Msg, 10)
+
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ msg := new(dns.Msg).SetQuestion("concurrent.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+ results[idx] = resp
+ }(i)
+ }
+ wg.Wait()
+
+ for i, resp := range results {
+ require.NotNil(t, resp, "Response %d should not be nil", i)
+ require.Len(t, resp.Answer, 2, "Response %d should have CNAME + A", i)
+ }
+ })
+}
+
+// TestLocalResolver_ZoneManagement tests zone-aware CNAME resolution
+func TestLocalResolver_ZoneManagement(t *testing.T) {
+ t.Run("Update sets zones correctly", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{
+ {Domain: "example.com.", Records: []nbdns.SimpleRecord{
+ {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ }},
+ {Domain: "test.local."},
+ })
+
+ assert.True(t, resolver.isInManagedZone("host.example.com."))
+ assert.True(t, resolver.isInManagedZone("other.example.com."))
+ assert.True(t, resolver.isInManagedZone("sub.test.local."))
+ assert.False(t, resolver.isInManagedZone("external.com."))
+ })
+
+ t.Run("isInManagedZone case insensitive", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{Domain: "Example.COM."}})
+
+ assert.True(t, resolver.isInManagedZone("host.example.com."))
+ assert.True(t, resolver.isInManagedZone("HOST.EXAMPLE.COM."))
+ })
+
+ t.Run("Update clears zones", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{Domain: "example.com."}})
+ assert.True(t, resolver.isInManagedZone("host.example.com."))
+
+ resolver.Update(nil)
+ assert.False(t, resolver.isInManagedZone("host.example.com."))
+ })
+}
+
+// TestLocalResolver_CNAMEZoneAwareResolution tests CNAME resolution with zone awareness
+func TestLocalResolver_CNAMEZoneAwareResolution(t *testing.T) {
+ t.Run("CNAME target in managed zone returns NXDOMAIN per RFC 6604", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "myzone.test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.myzone.test."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeNameError, resp.Rcode, "Should return NXDOMAIN")
+ require.Len(t, resp.Answer, 1, "Should include CNAME in answer")
+ })
+
+ t.Run("CNAME to external domain skips zone check", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.resolver = &mockResolver{
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ if host == "external.other.com." && network == "ip4" {
+ return []netip.Addr{netip.MustParseAddr("203.0.113.1")}, nil
+ }
+ return nil, nil
+ },
+ }
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "myzone.test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.other.com."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 2, "Should have CNAME + A from external resolution")
+ })
+
+ t.Run("CNAME target exists with different type returns NODATA not NXDOMAIN", func(t *testing.T) {
+ resolver := NewResolver()
+ // CNAME points to target that has A but no AAAA - query for AAAA should be NODATA
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "myzone.test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.myzone.test."},
+ {Name: "target.myzone.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "1.1.1.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success), not NXDOMAIN")
+ require.Len(t, resp.Answer, 1, "Should have only CNAME, no AAAA")
+ _, ok := resp.Answer[0].(*dns.CNAME)
+ assert.True(t, ok, "Answer should be CNAME record")
+ })
+
+ t.Run("external CNAME target exists but no AAAA records (NODATA)", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.resolver = &mockResolver{
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ if host == "external.example.com." {
+ if network == "ip6" {
+ // No AAAA records
+ return nil, &net.DNSError{IsNotFound: true, Name: host}
+ }
+ if network == "ip4" {
+ // But A records exist - domain exists
+ return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
+ }
+ }
+ return nil, &net.DNSError{IsNotFound: true, Name: host}
+ },
+ }
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success), not NXDOMAIN")
+ require.Len(t, resp.Answer, 1, "Should have only CNAME")
+ _, ok := resp.Answer[0].(*dns.CNAME)
+ assert.True(t, ok, "Answer should be CNAME record")
+ })
+
+ // Table-driven test for all external resolution outcomes
+ externalCases := []struct {
+ name string
+ lookupFunc func(context.Context, string, string) ([]netip.Addr, error)
+ expectedRcode int
+ expectedAnswer int
+ }{
+ {
+ name: "external NXDOMAIN (both A and AAAA not found)",
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ return nil, &net.DNSError{IsNotFound: true, Name: host}
+ },
+ expectedRcode: dns.RcodeNameError,
+ expectedAnswer: 1, // CNAME only
+ },
+ {
+ name: "external SERVFAIL (temporary error)",
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ return nil, &net.DNSError{IsTemporary: true, Name: host}
+ },
+ expectedRcode: dns.RcodeServerFailure,
+ expectedAnswer: 1, // CNAME only
+ },
+ {
+ name: "external SERVFAIL (timeout)",
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ return nil, &net.DNSError{IsTimeout: true, Name: host}
+ },
+ expectedRcode: dns.RcodeServerFailure,
+ expectedAnswer: 1, // CNAME only
+ },
+ {
+ name: "external SERVFAIL (generic error)",
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ return nil, fmt.Errorf("connection refused")
+ },
+ expectedRcode: dns.RcodeServerFailure,
+ expectedAnswer: 1, // CNAME only
+ },
+ {
+ name: "external success with IPs",
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ if network == "ip4" {
+ return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
+ }
+ return nil, &net.DNSError{IsNotFound: true, Name: host}
+ },
+ expectedRcode: dns.RcodeSuccess,
+ expectedAnswer: 2, // CNAME + A
+ },
+ }
+
+ for _, tc := range externalCases {
+ t.Run(tc.name, func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.resolver = &mockResolver{lookupFunc: tc.lookupFunc}
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, tc.expectedRcode, resp.Rcode, "rcode mismatch")
+ assert.Len(t, resp.Answer, tc.expectedAnswer, "answer count mismatch")
+ if tc.expectedAnswer > 0 {
+ _, ok := resp.Answer[0].(*dns.CNAME)
+ assert.True(t, ok, "first answer should be CNAME")
+ }
+ })
+ }
+}
+
+// TestLocalResolver_Fallthrough verifies that non-authoritative zones
+// trigger fallthrough (Zero bit set) when no records match
+func TestLocalResolver_Fallthrough(t *testing.T) {
+ resolver := NewResolver()
+
+ record := nbdns.SimpleRecord{
+ Name: "existing.custom.zone.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "10.0.0.1",
+ }
+
+ testCases := []struct {
+ name string
+ zones []nbdns.CustomZone
+ queryName string
+ expectFallthrough bool
+ expectRecord bool
+ }{
+ {
+ name: "Authoritative zone returns NXDOMAIN without fallthrough",
+ zones: []nbdns.CustomZone{{
+ Domain: "custom.zone.",
+ Records: []nbdns.SimpleRecord{record},
+ }},
+ queryName: "nonexistent.custom.zone.",
+ expectFallthrough: false,
+ expectRecord: false,
+ },
+ {
+ name: "Non-authoritative zone triggers fallthrough",
+ zones: []nbdns.CustomZone{{
+ Domain: "custom.zone.",
+ Records: []nbdns.SimpleRecord{record},
+ NonAuthoritative: true,
+ }},
+ queryName: "nonexistent.custom.zone.",
+ expectFallthrough: true,
+ expectRecord: false,
+ },
+ {
+ name: "Record found in non-authoritative zone returns normally",
+ zones: []nbdns.CustomZone{{
+ Domain: "custom.zone.",
+ Records: []nbdns.SimpleRecord{record},
+ NonAuthoritative: true,
+ }},
+ queryName: "existing.custom.zone.",
+ expectFallthrough: false,
+ expectRecord: true,
+ },
+ {
+ name: "Record found in authoritative zone returns normally",
+ zones: []nbdns.CustomZone{{
+ Domain: "custom.zone.",
+ Records: []nbdns.SimpleRecord{record},
+ }},
+ queryName: "existing.custom.zone.",
+ expectFallthrough: false,
+ expectRecord: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ resolver.Update(tc.zones)
+
+ var responseMSG *dns.Msg
+ responseWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ responseMSG = m
+ return nil
+ },
+ }
+
+ msg := new(dns.Msg).SetQuestion(tc.queryName, dns.TypeA)
+ resolver.ServeDNS(responseWriter, msg)
+
+ require.NotNil(t, responseMSG, "Should have received a response")
+
+ if tc.expectFallthrough {
+ assert.True(t, responseMSG.MsgHdr.Zero, "Zero bit should be set for fallthrough")
+ assert.Equal(t, dns.RcodeNameError, responseMSG.Rcode, "Should return NXDOMAIN")
+ } else {
+ assert.False(t, responseMSG.MsgHdr.Zero, "Zero bit should not be set")
+ }
+
+ if tc.expectRecord {
+ assert.Greater(t, len(responseMSG.Answer), 0, "Should have answer records")
+ assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode)
+ }
+ })
+ }
+}
+
+// TestLocalResolver_AuthoritativeFlag tests the AA flag behavior
+func TestLocalResolver_AuthoritativeFlag(t *testing.T) {
+ t.Run("direct record lookup is authoritative", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.True(t, resp.Authoritative)
+ })
+
+ t.Run("external resolution is not authoritative", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.resolver = &mockResolver{
+ lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
+ if host == "external.example.com." && network == "ip4" {
+ return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
+ }
+ return nil, nil
+ },
+ }
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Len(t, resp.Answer, 2)
+ assert.False(t, resp.Authoritative)
+ })
+}
+
+// TestLocalResolver_Stop tests cleanup on Stop
+func TestLocalResolver_Stop(t *testing.T) {
+ t.Run("Stop clears all state", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ resolver.Stop()
+
+ msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Len(t, resp.Answer, 0)
+ assert.False(t, resolver.isInManagedZone("host.example.com."))
+ })
+
+ t.Run("Stop is safe to call multiple times", func(t *testing.T) {
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ resolver.Stop()
+ resolver.Stop()
+ resolver.Stop()
+ })
+
+ t.Run("Stop cancels in-flight external resolution", func(t *testing.T) {
+ resolver := NewResolver()
+
+ lookupStarted := make(chan struct{})
+ lookupCtxCanceled := make(chan struct{})
+
+ resolver.resolver = &mockResolver{
+ lookupFunc: func(ctx context.Context, network, host string) ([]netip.Addr, error) {
+ close(lookupStarted)
+ <-ctx.Done()
+ close(lookupCtxCanceled)
+ return nil, ctx.Err()
+ },
+ }
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "test.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
+ },
+ }})
+
+ done := make(chan struct{})
+ go func() {
+ msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }}, msg)
+ close(done)
+ }()
+
+ <-lookupStarted
+ resolver.Stop()
+
+ select {
+ case <-lookupCtxCanceled:
+ case <-time.After(time.Second):
+ t.Fatal("external lookup context was not canceled")
+ }
+
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("ServeDNS did not return after Stop")
+ }
+ })
+}
+
+// TestLocalResolver_FallthroughCaseInsensitive verifies case-insensitive domain matching for fallthrough
+func TestLocalResolver_FallthroughCaseInsensitive(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "EXAMPLE.COM.",
+ Records: []nbdns.SimpleRecord{{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "1.2.3.4"}},
+ NonAuthoritative: true,
+ }})
+
+ var responseMSG *dns.Msg
+ responseWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ responseMSG = m
+ return nil
+ },
+ }
+
+ msg := new(dns.Msg).SetQuestion("nonexistent.example.com.", dns.TypeA)
+ resolver.ServeDNS(responseWriter, msg)
+
+ require.NotNil(t, responseMSG)
+ assert.True(t, responseMSG.MsgHdr.Zero, "Should fallthrough for non-authoritative zone with case-insensitive match")
+}
+
+// TestLocalResolver_WildcardCNAME tests wildcard CNAME record handling for non-CNAME queries
+func TestLocalResolver_WildcardCNAME(t *testing.T) {
+ t.Run("wildcard CNAME resolves A query with internal target", func(t *testing.T) {
+ resolver := NewResolver()
+
+ // Configure wildcard CNAME pointing to internal A record
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should resolve via wildcard CNAME")
+ require.Len(t, resp.Answer, 2, "Should have CNAME + A record")
+
+ // Verify CNAME has the original query name, not the wildcard
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok, "First answer should be CNAME")
+ assert.Equal(t, "foo.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten to query name")
+ assert.Equal(t, "target.example.com.", cname.Target)
+
+ // Verify A record
+ a, ok := resp.Answer[1].(*dns.A)
+ require.True(t, ok, "Second answer should be A record")
+ assert.Equal(t, "10.0.0.1", a.A.String())
+ })
+
+ t.Run("wildcard CNAME resolves AAAA query with internal target", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("bar.example.com.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should resolve via wildcard CNAME")
+ require.Len(t, resp.Answer, 2, "Should have CNAME + AAAA record")
+
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "bar.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten")
+
+ aaaa, ok := resp.Answer[1].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String())
+ })
+
+ t.Run("specific record takes precedence over wildcard CNAME", func(t *testing.T) {
+ resolver := NewResolver()
+
+ // Both wildcard CNAME and specific A record exist
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "specific.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1, "Should return specific A record only")
+
+ a, ok := resp.Answer[0].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "192.168.1.1", a.A.String())
+ })
+
+ t.Run("specific CNAME takes precedence over wildcard CNAME", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "wildcard-target.example.com."},
+ {Name: "specific.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "specific-target.example.com."},
+ {Name: "specific-target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.1.1.1"},
+ {Name: "wildcard-target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.2.2.2"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.GreaterOrEqual(t, len(resp.Answer), 1)
+
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "specific-target.example.com.", cname.Target, "Should use specific CNAME, not wildcard")
+ })
+
+ t.Run("wildcard CNAME to non-existent internal target returns NXDOMAIN with CNAME", func(t *testing.T) {
+ resolver := NewResolver()
+
+ // Wildcard CNAME pointing to non-existent internal target
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.example.com."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ // Per RFC 6604, CNAME chains should return the rcode of the final target.
+ // When the wildcard CNAME target doesn't exist in the managed zone, this
+ // returns NXDOMAIN with the CNAME record included.
+ // Note: Current implementation returns NODATA (success) because the wildcard
+ // domain exists. This test documents the actual behavior.
+ if resp.Rcode == dns.RcodeNameError {
+ // RFC-compliant behavior: NXDOMAIN with CNAME
+ require.Len(t, resp.Answer, 1, "Should include the CNAME pointing to non-existent target")
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "foo.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten")
+ assert.Equal(t, "nonexistent.example.com.", cname.Target)
+ } else {
+ // Current behavior: NODATA (success with CNAME but target not found)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Returns NODATA when wildcard exists but target doesn't")
+ }
+ })
+
+ t.Run("wildcard CNAME with multi-level subdomain", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ // Query with multi-level subdomain - wildcard should only match first label
+ // Standard DNS wildcards only match a single label, so sub.domain.example.com
+ // should NOT match *.example.com - this tests current implementation behavior
+ msg := new(dns.Msg).SetQuestion("sub.domain.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ })
+
+ t.Run("wildcard CNAME NODATA when target has no matching type", func(t *testing.T) {
+ resolver := NewResolver()
+
+ // Wildcard CNAME to target that only has A record, query for AAAA
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no answer for AAAA)")
+ require.Len(t, resp.Answer, 1, "Should have only CNAME")
+
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "foo.example.com.", cname.Hdr.Name)
+ })
+
+ t.Run("direct CNAME query for wildcard record", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ },
+ }})
+
+ // Direct CNAME query should also work via wildcard
+ msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeCNAME)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "foo.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten")
+ assert.Equal(t, "target.example.com.", cname.Target)
+ })
+
+ t.Run("wildcard CNAME case insensitive query", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("FOO.EXAMPLE.COM.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode, "Wildcard CNAME should match case-insensitively")
+ require.Len(t, resp.Answer, 2)
+ })
+
+ t.Run("wildcard A and wildcard CNAME coexist - A takes precedence", func(t *testing.T) {
+ resolver := NewResolver()
+
+ // Both wildcard A and wildcard CNAME exist
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+
+ // A record should be returned, not CNAME
+ a, ok := resp.Answer[0].(*dns.A)
+ require.True(t, ok, "Wildcard A should take precedence over wildcard CNAME for A query")
+ assert.Equal(t, "10.0.0.1", a.A.String())
+ })
+
+ t.Run("wildcard CNAME with chained CNAMEs", func(t *testing.T) {
+ resolver := NewResolver()
+
+ // Wildcard CNAME -> another CNAME -> A record
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop1.example.com."},
+ {Name: "hop1.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "final.example.com."},
+ {Name: "final.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 3, "Should have wildcard CNAME + hop1 CNAME + A record")
+
+ // First should be the wildcard CNAME with rewritten name
+ cname1, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "anyhost.example.com.", cname1.Hdr.Name)
+ assert.Equal(t, "hop1.example.com.", cname1.Target)
+ })
+}
+
+// TestLocalResolver_WildcardAandAAAA tests wildcard A and AAAA record handling
+func TestLocalResolver_WildcardAandAAAA(t *testing.T) {
+ t.Run("wildcard A record resolves with owner name rewriting", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+
+ a, ok := resp.Answer[0].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "anyhost.example.com.", a.Hdr.Name, "Owner name should be rewritten to query name")
+ assert.Equal(t, "10.0.0.1", a.A.String())
+ })
+
+ t.Run("wildcard AAAA record resolves with owner name rewriting", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+
+ aaaa, ok := resp.Answer[0].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "anyhost.example.com.", aaaa.Hdr.Name, "Owner name should be rewritten to query name")
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String())
+ })
+
+ t.Run("NODATA when querying AAAA but only wildcard A exists", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no answer)")
+ assert.Len(t, resp.Answer, 0, "Should have no AAAA answer")
+ })
+
+ t.Run("NODATA when querying A but only wildcard AAAA exists", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no answer)")
+ assert.Len(t, resp.Answer, 0, "Should have no A answer")
+ })
+
+ t.Run("dual-stack wildcard returns both A and AAAA separately", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ // Query A
+ msgA := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA)
+ var respA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA)
+
+ require.NotNil(t, respA)
+ require.Equal(t, dns.RcodeSuccess, respA.Rcode)
+ require.Len(t, respA.Answer, 1)
+ a, ok := respA.Answer[0].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "10.0.0.1", a.A.String())
+
+ // Query AAAA
+ msgAAAA := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeAAAA)
+ var respAAAA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA)
+
+ require.NotNil(t, respAAAA)
+ require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode)
+ require.Len(t, respAAAA.Answer, 1)
+ aaaa, ok := respAAAA.Answer[0].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String())
+ })
+
+ t.Run("specific A takes precedence over wildcard A", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "specific.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+
+ a, ok := resp.Answer[0].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "192.168.1.1", a.A.String(), "Specific record should take precedence")
+ })
+
+ t.Run("specific AAAA takes precedence over wildcard AAAA", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ {Name: "specific.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::2"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+
+ aaaa, ok := resp.Answer[0].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2001:db8::2", aaaa.AAAA.String(), "Specific record should take precedence")
+ })
+
+ t.Run("multiple wildcard A records round-robin", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2"},
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.3"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA)
+
+ var firstIPs []string
+ for i := 0; i < 3; i++ {
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Len(t, resp.Answer, 3, "Should return all 3 A records")
+
+ a, ok := resp.Answer[0].(*dns.A)
+ require.True(t, ok)
+ firstIPs = append(firstIPs, a.A.String())
+
+ // Verify owner name is rewritten for all records
+ for _, ans := range resp.Answer {
+ assert.Equal(t, "anyhost.example.com.", ans.Header().Name)
+ }
+ }
+
+ // Verify rotation happened
+ assert.NotEqual(t, firstIPs[0], firstIPs[1], "First record should rotate")
+ assert.NotEqual(t, firstIPs[1], firstIPs[2], "Second rotation should differ")
+ })
+
+ t.Run("wildcard A case insensitive", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("ANYHOST.EXAMPLE.COM.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ })
+
+ t.Run("wildcard does not match multi-level subdomain", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ // *.example.com should NOT match sub.domain.example.com (standard DNS behavior)
+ msg := new(dns.Msg).SetQuestion("sub.domain.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ // This depends on implementation - standard DNS wildcards only match single label
+ // Current implementation replaces first label with *, so it WOULD match
+ // This test documents the current behavior
+ })
+
+ t.Run("wildcard with existing domain but different type returns NODATA", func(t *testing.T) {
+ resolver := NewResolver()
+
+ // Specific A record exists, but query for TXT on wildcard domain
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("test.example.com.", dns.TypeTXT)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA for existing wildcard domain with different type")
+ assert.Len(t, resp.Answer, 0)
+ })
+
+ t.Run("mixed specific and wildcard returns correct records", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "specific.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ // Query A for specific - should use wildcard
+ msgA := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA)
+ var respA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA)
+
+ require.NotNil(t, respA)
+ // This could be NODATA since specific.example.com exists but has no A
+ // or could return wildcard A - depends on implementation
+ // The current behavior returns NODATA because specific domain exists
+
+ // Query AAAA for specific - should use specific record
+ msgAAAA := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeAAAA)
+ var respAAAA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA)
+
+ require.NotNil(t, respAAAA)
+ require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode)
+ require.Len(t, respAAAA.Answer, 1)
+ aaaa, ok := respAAAA.Answer[0].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String())
+ })
+}
+
+// TestLocalResolver_WildcardEdgeCases tests edge cases for wildcard record handling
+func TestLocalResolver_WildcardEdgeCases(t *testing.T) {
+ t.Run("wildcard does not match NS queries", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeNS)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeNameError, resp.Rcode, "NS queries should not match wildcards")
+ assert.Len(t, resp.Answer, 0)
+ })
+
+ t.Run("wildcard does not match SOA queries", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeSOA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeNameError, resp.Rcode, "SOA queries should not match wildcards")
+ assert.Len(t, resp.Answer, 0)
+ })
+
+ t.Run("apex wildcard query", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ // Query for *.example.com directly (the wildcard itself)
+ msg := new(dns.Msg).SetQuestion("*.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+
+ a, ok := resp.Answer[0].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "10.0.0.1", a.A.String())
+ })
+
+ t.Run("wildcard TXT record", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeTXT), Class: nbdns.DefaultClass, TTL: 300, RData: "v=spf1 -all"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("mail.example.com.", dns.TypeTXT)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+
+ txt, ok := resp.Answer[0].(*dns.TXT)
+ require.True(t, ok)
+ assert.Equal(t, "mail.example.com.", txt.Hdr.Name, "TXT owner should be rewritten")
+ })
+
+ t.Run("wildcard MX record", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeMX), Class: nbdns.DefaultClass, TTL: 300, RData: "10 mail.example.com."},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("sub.example.com.", dns.TypeMX)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+
+ mx, ok := resp.Answer[0].(*dns.MX)
+ require.True(t, ok)
+ assert.Equal(t, "sub.example.com.", mx.Hdr.Name, "MX owner should be rewritten")
+ })
+
+ t.Run("non-authoritative zone with wildcard CNAME triggers fallthrough for unmatched names", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ NonAuthoritative: true,
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.sub.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ },
+ }})
+
+ // Query for name not matching the wildcard pattern
+ msg := new(dns.Msg).SetQuestion("other.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.True(t, resp.MsgHdr.Zero, "Should trigger fallthrough for non-authoritative zone")
+ })
+}
+
+// TestLocalResolver_MixedRecordTypes tests scenarios with A, AAAA, and CNAME records combined
+func TestLocalResolver_MixedRecordTypes(t *testing.T) {
+ t.Run("specific A with wildcard CNAME - A query uses specific A", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "specific.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1, "Should return only the specific A record")
+
+ a, ok := resp.Answer[0].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "10.0.0.1", a.A.String(), "Should use specific A, not follow wildcard CNAME")
+ })
+
+ t.Run("specific AAAA with wildcard CNAME - AAAA query uses specific AAAA", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "specific.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::2"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1, "Should return only the specific AAAA record")
+
+ aaaa, ok := resp.Answer[0].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String(), "Should use specific AAAA, not follow wildcard CNAME")
+ })
+
+ t.Run("specific A only - AAAA query returns NODATA", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no AAAA)")
+ assert.Len(t, resp.Answer, 0)
+ })
+
+ t.Run("specific AAAA only - A query returns NODATA", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "host.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no A)")
+ assert.Len(t, resp.Answer, 0)
+ })
+
+ t.Run("CNAME with both A and AAAA target - A query returns CNAME + A", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 2, "Should have CNAME + A")
+
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "target.example.com.", cname.Target)
+
+ a, ok := resp.Answer[1].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "10.0.0.1", a.A.String())
+ })
+
+ t.Run("CNAME with both A and AAAA target - AAAA query returns CNAME + AAAA", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 2, "Should have CNAME + AAAA")
+
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "target.example.com.", cname.Target)
+
+ aaaa, ok := resp.Answer[1].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String())
+ })
+
+ t.Run("CNAME to target with only A - AAAA query returns CNAME only (NODATA)", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeAAAA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA with CNAME")
+ require.Len(t, resp.Answer, 1, "Should have only CNAME")
+
+ _, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ })
+
+ t.Run("CNAME to target with only AAAA - A query returns CNAME only (NODATA)", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeA)
+ var resp *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
+
+ require.NotNil(t, resp)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA with CNAME")
+ require.Len(t, resp.Answer, 1, "Should have only CNAME")
+
+ _, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ })
+
+ t.Run("wildcard A + wildcard AAAA + wildcard CNAME - each query type returns correct record", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ },
+ }})
+
+ // A query should return wildcard A (not CNAME)
+ msgA := new(dns.Msg).SetQuestion("any.example.com.", dns.TypeA)
+ var respA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA)
+
+ require.NotNil(t, respA)
+ require.Equal(t, dns.RcodeSuccess, respA.Rcode)
+ require.Len(t, respA.Answer, 1)
+ a, ok := respA.Answer[0].(*dns.A)
+ require.True(t, ok, "A query should return A record, not CNAME")
+ assert.Equal(t, "10.0.0.1", a.A.String())
+
+ // AAAA query should return wildcard AAAA (not CNAME)
+ msgAAAA := new(dns.Msg).SetQuestion("any.example.com.", dns.TypeAAAA)
+ var respAAAA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA)
+
+ require.NotNil(t, respAAAA)
+ require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode)
+ require.Len(t, respAAAA.Answer, 1)
+ aaaa, ok := respAAAA.Answer[0].(*dns.AAAA)
+ require.True(t, ok, "AAAA query should return AAAA record, not CNAME")
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String())
+
+ // CNAME query should return wildcard CNAME
+ msgCNAME := new(dns.Msg).SetQuestion("any.example.com.", dns.TypeCNAME)
+ var respCNAME *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respCNAME = m; return nil }}, msgCNAME)
+
+ require.NotNil(t, respCNAME)
+ require.Equal(t, dns.RcodeSuccess, respCNAME.Rcode)
+ require.Len(t, respCNAME.Answer, 1)
+ cname, ok := respCNAME.Answer[0].(*dns.CNAME)
+ require.True(t, ok, "CNAME query should return CNAME record")
+ assert.Equal(t, "target.example.com.", cname.Target)
+ })
+
+ t.Run("dual-stack host with both A and AAAA", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2"},
+ {Name: "host.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ {Name: "host.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::2"},
+ },
+ }})
+
+ // A query
+ msgA := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA)
+ var respA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA)
+
+ require.NotNil(t, respA)
+ require.Equal(t, dns.RcodeSuccess, respA.Rcode)
+ require.Len(t, respA.Answer, 2, "Should return both A records")
+ for _, ans := range respA.Answer {
+ _, ok := ans.(*dns.A)
+ require.True(t, ok)
+ }
+
+ // AAAA query
+ msgAAAA := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeAAAA)
+ var respAAAA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA)
+
+ require.NotNil(t, respAAAA)
+ require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode)
+ require.Len(t, respAAAA.Answer, 2, "Should return both AAAA records")
+ for _, ans := range respAAAA.Answer {
+ _, ok := ans.(*dns.AAAA)
+ require.True(t, ok)
+ }
+ })
+
+ t.Run("CNAME chain with mixed record types at target", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias1.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "alias2.example.com."},
+ {Name: "alias2.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ // A query through chain
+ msgA := new(dns.Msg).SetQuestion("alias1.example.com.", dns.TypeA)
+ var respA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA)
+
+ require.NotNil(t, respA)
+ require.Equal(t, dns.RcodeSuccess, respA.Rcode)
+ require.Len(t, respA.Answer, 3, "Should have 2 CNAMEs + 1 A")
+
+ // Verify chain order
+ cname1, ok := respA.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "alias2.example.com.", cname1.Target)
+
+ cname2, ok := respA.Answer[1].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "target.example.com.", cname2.Target)
+
+ a, ok := respA.Answer[2].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "10.0.0.1", a.A.String())
+
+ // AAAA query through chain
+ msgAAAA := new(dns.Msg).SetQuestion("alias1.example.com.", dns.TypeAAAA)
+ var respAAAA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA)
+
+ require.NotNil(t, respAAAA)
+ require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode)
+ require.Len(t, respAAAA.Answer, 3, "Should have 2 CNAMEs + 1 AAAA")
+
+ aaaa, ok := respAAAA.Answer[2].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String())
+ })
+
+ t.Run("wildcard CNAME with dual-stack target", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
+ {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ // A query via wildcard CNAME
+ msgA := new(dns.Msg).SetQuestion("any.example.com.", dns.TypeA)
+ var respA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA)
+
+ require.NotNil(t, respA)
+ require.Equal(t, dns.RcodeSuccess, respA.Rcode)
+ require.Len(t, respA.Answer, 2, "Should have CNAME + A")
+
+ cname, ok := respA.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "any.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten")
+
+ a, ok := respA.Answer[1].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "10.0.0.1", a.A.String())
+
+ // AAAA query via wildcard CNAME
+ msgAAAA := new(dns.Msg).SetQuestion("other.example.com.", dns.TypeAAAA)
+ var respAAAA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA)
+
+ require.NotNil(t, respAAAA)
+ require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode)
+ require.Len(t, respAAAA.Answer, 2, "Should have CNAME + AAAA")
+
+ cname2, ok := respAAAA.Answer[0].(*dns.CNAME)
+ require.True(t, ok)
+ assert.Equal(t, "other.example.com.", cname2.Hdr.Name, "CNAME owner should be rewritten")
+
+ aaaa, ok := respAAAA.Answer[1].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String())
+ })
+
+ t.Run("specific A + wildcard AAAA - each query type returns correct record", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
+ {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"},
+ },
+ }})
+
+ // A query for host should return specific A
+ msgA := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA)
+ var respA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA)
+
+ require.NotNil(t, respA)
+ require.Equal(t, dns.RcodeSuccess, respA.Rcode)
+ require.Len(t, respA.Answer, 1)
+ a, ok := respA.Answer[0].(*dns.A)
+ require.True(t, ok)
+ assert.Equal(t, "10.0.0.1", a.A.String())
+
+ // AAAA query for host should return NODATA (specific A exists, no AAAA for host.example.com)
+ msgAAAA := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeAAAA)
+ var respAAAA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA)
+
+ require.NotNil(t, respAAAA)
+ // RFC 4592 section 2.2.1: wildcard should NOT match when the name EXISTS in zone.
+ // host.example.com exists (has A record), so AAAA query returns NODATA, not wildcard.
+ assert.Equal(t, dns.RcodeSuccess, respAAAA.Rcode, "Should return NODATA for existing host without AAAA")
+ assert.Len(t, respAAAA.Answer, 0, "RFC 4592: wildcard should not match when name exists")
+
+ // AAAA query for other host should return wildcard AAAA
+ msgAAAAOther := new(dns.Msg).SetQuestion("other.example.com.", dns.TypeAAAA)
+ var respAAAAOther *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAAOther = m; return nil }}, msgAAAAOther)
+
+ require.NotNil(t, respAAAAOther)
+ require.Equal(t, dns.RcodeSuccess, respAAAAOther.Rcode)
+ require.Len(t, respAAAAOther.Answer, 1)
+ aaaa, ok := respAAAAOther.Answer[0].(*dns.AAAA)
+ require.True(t, ok)
+ assert.Equal(t, "2001:db8::1", aaaa.AAAA.String())
+ assert.Equal(t, "other.example.com.", aaaa.Hdr.Name, "Owner should be rewritten")
+ })
+
+ t.Run("multiple zones with mixed records", func(t *testing.T) {
+ resolver := NewResolver()
+
+ resolver.Update([]nbdns.CustomZone{
+ {
+ Domain: "zone1.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "host.zone1.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.1.0.1"},
+ {Name: "host.zone1.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8:1::1"},
+ },
+ },
+ {
+ Domain: "zone2.com.",
+ Records: []nbdns.SimpleRecord{
+ {Name: "alias.zone2.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.zone2.com."},
+ {Name: "target.zone2.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.2.0.1"},
+ },
+ },
+ })
+
+ // Query zone1 A
+ msg1A := new(dns.Msg).SetQuestion("host.zone1.com.", dns.TypeA)
+ var resp1A *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp1A = m; return nil }}, msg1A)
+
+ require.NotNil(t, resp1A)
+ require.Equal(t, dns.RcodeSuccess, resp1A.Rcode)
+ require.Len(t, resp1A.Answer, 1)
+
+ // Query zone1 AAAA
+ msg1AAAA := new(dns.Msg).SetQuestion("host.zone1.com.", dns.TypeAAAA)
+ var resp1AAAA *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp1AAAA = m; return nil }}, msg1AAAA)
+
+ require.NotNil(t, resp1AAAA)
+ require.Equal(t, dns.RcodeSuccess, resp1AAAA.Rcode)
+ require.Len(t, resp1AAAA.Answer, 1)
+
+ // Query zone2 via CNAME
+ msg2A := new(dns.Msg).SetQuestion("alias.zone2.com.", dns.TypeA)
+ var resp2A *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp2A = m; return nil }}, msg2A)
+
+ require.NotNil(t, resp2A)
+ require.Equal(t, dns.RcodeSuccess, resp2A.Rcode)
+ require.Len(t, resp2A.Answer, 2, "Should have CNAME + A")
+ })
+}
+
+// BenchmarkFindZone_BestCase benchmarks zone lookup with immediate match (first label)
+func BenchmarkFindZone_BestCase(b *testing.B) {
+ resolver := NewResolver()
+
+ // Single zone that matches immediately
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "example.com.",
+ NonAuthoritative: true,
+ }})
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ resolver.shouldFallthrough("example.com.")
+ }
+}
+
+// BenchmarkFindZone_WorstCase benchmarks zone lookup with many zones, no match, many labels
+func BenchmarkFindZone_WorstCase(b *testing.B) {
+ resolver := NewResolver()
+
+ // 100 zones that won't match
+ var zones []nbdns.CustomZone
+ for i := 0; i < 100; i++ {
+ zones = append(zones, nbdns.CustomZone{
+ Domain: fmt.Sprintf("zone%d.internal.", i),
+ NonAuthoritative: true,
+ })
+ }
+ resolver.Update(zones)
+
+ // Query with many labels that won't match any zone
+ qname := "a.b.c.d.e.f.g.h.external.com."
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ resolver.shouldFallthrough(qname)
+ }
+}
+
+// BenchmarkFindZone_TypicalCase benchmarks typical usage: few zones, subdomain match
+func BenchmarkFindZone_TypicalCase(b *testing.B) {
+ resolver := NewResolver()
+
+ // Typical setup: peer zone (authoritative) + one user zone (non-authoritative)
+ resolver.Update([]nbdns.CustomZone{
+ {Domain: "netbird.cloud.", NonAuthoritative: false},
+ {Domain: "custom.local.", NonAuthoritative: true},
+ })
+
+ // Query for subdomain of user zone
+ qname := "myhost.custom.local."
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ resolver.shouldFallthrough(qname)
+ }
+}
+
+// BenchmarkIsInManagedZone_ManyZones benchmarks isInManagedZone with 100 zones
+func BenchmarkIsInManagedZone_ManyZones(b *testing.B) {
+ resolver := NewResolver()
+
+ var zones []nbdns.CustomZone
+ for i := 0; i < 100; i++ {
+ zones = append(zones, nbdns.CustomZone{
+ Domain: fmt.Sprintf("zone%d.internal.", i),
+ })
+ }
+ resolver.Update(zones)
+
+ // Query that matches zone50
+ qname := "host.zone50.internal."
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ resolver.isInManagedZone(qname)
+ }
+}
diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go
new file mode 100644
index 000000000..5a3744719
--- /dev/null
+++ b/client/internal/dns/resutil/resolve.go
@@ -0,0 +1,197 @@
+// Package resutil provides shared DNS resolution utilities
+package resutil
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "net"
+ "net/netip"
+ "strings"
+
+ "github.com/miekg/dns"
+ log "github.com/sirupsen/logrus"
+)
+
+// GenerateRequestID creates a random 8-character hex string for request tracing.
+func GenerateRequestID() string {
+ bytes := make([]byte, 4)
+ if _, err := rand.Read(bytes); err != nil {
+ log.Errorf("generate request ID: %v", err)
+ return ""
+ }
+ return hex.EncodeToString(bytes)
+}
+
+// IPsToRRs converts a slice of IP addresses to DNS resource records.
+// IPv4 addresses become A records, IPv6 addresses become AAAA records.
+func IPsToRRs(name string, ips []netip.Addr, ttl uint32) []dns.RR {
+ var result []dns.RR
+
+ for _, ip := range ips {
+ if ip.Is6() {
+ result = append(result, &dns.AAAA{
+ Hdr: dns.RR_Header{
+ Name: name,
+ Rrtype: dns.TypeAAAA,
+ Class: dns.ClassINET,
+ Ttl: ttl,
+ },
+ AAAA: ip.AsSlice(),
+ })
+ } else {
+ result = append(result, &dns.A{
+ Hdr: dns.RR_Header{
+ Name: name,
+ Rrtype: dns.TypeA,
+ Class: dns.ClassINET,
+ Ttl: ttl,
+ },
+ A: ip.AsSlice(),
+ })
+ }
+ }
+
+ return result
+}
+
+// NetworkForQtype returns the network string ("ip4" or "ip6") for a DNS query type.
+// Returns empty string for unsupported types.
+func NetworkForQtype(qtype uint16) string {
+ switch qtype {
+ case dns.TypeA:
+ return "ip4"
+ case dns.TypeAAAA:
+ return "ip6"
+ default:
+ return ""
+ }
+}
+
+type resolver interface {
+ LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
+}
+
+// chainedWriter is implemented by ResponseWriters that carry request metadata
+type chainedWriter interface {
+ RequestID() string
+ SetMeta(key, value string)
+}
+
+// GetRequestID extracts a request ID from the ResponseWriter if available,
+// otherwise generates a new one.
+func GetRequestID(w dns.ResponseWriter) string {
+ if cw, ok := w.(chainedWriter); ok {
+ if id := cw.RequestID(); id != "" {
+ return id
+ }
+ }
+ return GenerateRequestID()
+}
+
+// SetMeta sets metadata on the ResponseWriter if it supports it.
+func SetMeta(w dns.ResponseWriter, key, value string) {
+ if cw, ok := w.(chainedWriter); ok {
+ cw.SetMeta(key, value)
+ }
+}
+
+// LookupResult contains the result of an external DNS lookup
+type LookupResult struct {
+ IPs []netip.Addr
+ Rcode int
+ Err error // Original error for caller's logging needs
+}
+
+// LookupIP performs a DNS lookup and determines the appropriate rcode.
+func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint16) LookupResult {
+ ips, err := r.LookupNetIP(ctx, network, host)
+ if err != nil {
+ return LookupResult{
+ Rcode: getRcodeForError(ctx, r, host, qtype, err),
+ Err: err,
+ }
+ }
+
+ // Unmap IPv4-mapped IPv6 addresses that some resolvers may return
+ for i, ip := range ips {
+ ips[i] = ip.Unmap()
+ }
+
+ return LookupResult{
+ IPs: ips,
+ Rcode: dns.RcodeSuccess,
+ }
+}
+
+func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int {
+ var dnsErr *net.DNSError
+ if !errors.As(err, &dnsErr) {
+ return dns.RcodeServerFailure
+ }
+
+ if dnsErr.IsNotFound {
+ return getRcodeForNotFound(ctx, r, host, qtype)
+ }
+
+ return dns.RcodeServerFailure
+}
+
+// getRcodeForNotFound distinguishes between NXDOMAIN (domain doesn't exist) and NODATA
+// (domain exists but no records of requested type) by checking the opposite record type.
+//
+// musl libc (the reason we need this distinction) only queries A/AAAA pairs in getaddrinfo,
+// so checking the opposite A/AAAA type is sufficient. Other record types (MX, TXT, etc.)
+// are not queried by musl and don't need this handling.
+func getRcodeForNotFound(ctx context.Context, r resolver, domain string, originalQtype uint16) int {
+ // Try querying for a different record type to see if the domain exists
+ // If the original query was for AAAA, try A. If it was for A, try AAAA.
+ // This helps distinguish between NXDOMAIN and NODATA.
+ var alternativeNetwork string
+ switch originalQtype {
+ case dns.TypeAAAA:
+ alternativeNetwork = "ip4"
+ case dns.TypeA:
+ alternativeNetwork = "ip6"
+ default:
+ return dns.RcodeNameError
+ }
+
+ if _, err := r.LookupNetIP(ctx, alternativeNetwork, domain); err != nil {
+ var dnsErr *net.DNSError
+ if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
+ // Alternative query also returned not found - domain truly doesn't exist
+ return dns.RcodeNameError
+ }
+ // Some other error (timeout, server failure, etc.) - can't determine, assume domain exists
+ return dns.RcodeSuccess
+ }
+
+ // Alternative query succeeded - domain exists but has no records of this type
+ return dns.RcodeSuccess
+}
+
+// FormatAnswers formats DNS resource records for logging.
+func FormatAnswers(answers []dns.RR) string {
+ if len(answers) == 0 {
+ return "[]"
+ }
+
+ parts := make([]string, 0, len(answers))
+ for _, rr := range answers {
+ switch r := rr.(type) {
+ case *dns.A:
+ parts = append(parts, r.A.String())
+ case *dns.AAAA:
+ parts = append(parts, r.AAAA.String())
+ case *dns.CNAME:
+ parts = append(parts, "CNAME:"+r.Target)
+ case *dns.PTR:
+ parts = append(parts, "PTR:"+r.Ptr)
+ default:
+ parts = append(parts, dns.TypeToString[rr.Header().Rrtype])
+ }
+ }
+ return "[" + strings.Join(parts, ", ") + "]"
+}
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index 94945b55a..1ce7bf1c6 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -485,7 +485,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
}
}
- localMuxUpdates, localRecords, err := s.buildLocalHandlerUpdate(update.CustomZones)
+ localMuxUpdates, localZones, err := s.buildLocalHandlerUpdate(update.CustomZones)
if err != nil {
return fmt.Errorf("local handler updater: %w", err)
}
@@ -498,8 +498,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
s.updateMux(muxUpdates)
- // register local records
- s.localResolver.Update(localRecords)
+ s.localResolver.Update(localZones)
s.currentConfig = dnsConfigToHostDNSConfig(update, s.service.RuntimeIP(), s.service.RuntimePort())
@@ -632,9 +631,7 @@ func (s *DefaultServer) registerFallback(config HostDNSConfig) {
handler, err := newUpstreamResolver(
s.ctx,
- s.wgInterface.Name(),
- s.wgInterface.Address().IP,
- s.wgInterface.Address().Network,
+ s.wgInterface,
s.statusRecorder,
s.hostsDNSHolder,
nbdns.RootZone,
@@ -659,9 +656,9 @@ func (s *DefaultServer) registerFallback(config HostDNSConfig) {
s.registerHandler([]string{nbdns.RootZone}, handler, PriorityFallback)
}
-func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) ([]handlerWrapper, []nbdns.SimpleRecord, error) {
+func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) ([]handlerWrapper, []nbdns.CustomZone, error) {
var muxUpdates []handlerWrapper
- var localRecords []nbdns.SimpleRecord
+ var zones []nbdns.CustomZone
for _, customZone := range customZones {
if len(customZone.Records) == 0 {
@@ -675,17 +672,20 @@ func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone)
priority: PriorityLocal,
})
+ // zone records contain the fqdn, so we can just flatten them
+ var localRecords []nbdns.SimpleRecord
for _, record := range customZone.Records {
if record.Class != nbdns.DefaultClass {
log.Warnf("received an invalid class type: %s", record.Class)
continue
}
- // zone records contain the fqdn, so we can just flatten them
localRecords = append(localRecords, record)
}
+ customZone.Records = localRecords
+ zones = append(zones, customZone)
}
- return muxUpdates, localRecords, nil
+ return muxUpdates, zones, nil
}
func (s *DefaultServer) buildUpstreamHandlerUpdate(nameServerGroups []*nbdns.NameServerGroup) ([]handlerWrapper, error) {
@@ -741,9 +741,7 @@ func (s *DefaultServer) createHandlersForDomainGroup(domainGroup nsGroupsByDomai
log.Debugf("creating handler for domain=%s with priority=%d", domainGroup.domain, priority)
handler, err := newUpstreamResolver(
s.ctx,
- s.wgInterface.Name(),
- s.wgInterface.Address().IP,
- s.wgInterface.Address().Network,
+ s.wgInterface,
s.statusRecorder,
s.hostsDNSHolder,
domainGroup.domain,
@@ -924,9 +922,7 @@ func (s *DefaultServer) addHostRootZone() {
handler, err := newUpstreamResolver(
s.ctx,
- s.wgInterface.Name(),
- s.wgInterface.Address().IP,
- s.wgInterface.Address().Network,
+ s.wgInterface,
s.statusRecorder,
s.hostsDNSHolder,
nbdns.RootZone,
diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go
index fe1f67f66..3606d48b9 100644
--- a/client/internal/dns/server_test.go
+++ b/client/internal/dns/server_test.go
@@ -15,6 +15,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
+ "golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/firewall/uspfilter"
@@ -81,6 +82,10 @@ func (w *mocWGIface) GetStats(_ string) (configurer.WGStats, error) {
return configurer.WGStats{}, nil
}
+func (w *mocWGIface) GetNet() *netstack.Net {
+ return nil
+}
+
var zoneRecords = []nbdns.SimpleRecord{
{
Name: "peera.netbird.cloud",
@@ -128,7 +133,7 @@ func TestUpdateDNSServer(t *testing.T) {
testCases := []struct {
name string
initUpstreamMap registeredHandlerMap
- initLocalRecords []nbdns.SimpleRecord
+ initLocalZones []nbdns.CustomZone
initSerial uint64
inputSerial uint64
inputUpdate nbdns.Config
@@ -180,8 +185,8 @@ func TestUpdateDNSServer(t *testing.T) {
expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
},
{
- name: "New Config Should Succeed",
- initLocalRecords: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}},
+ name: "New Config Should Succeed",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
initUpstreamMap: registeredHandlerMap{
generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
domain: "netbird.cloud",
@@ -221,19 +226,19 @@ func TestUpdateDNSServer(t *testing.T) {
expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
},
{
- name: "Smaller Config Serial Should Be Skipped",
- initLocalRecords: []nbdns.SimpleRecord{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 2,
- inputSerial: 1,
- shouldFail: true,
+ name: "Smaller Config Serial Should Be Skipped",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: make(registeredHandlerMap),
+ initSerial: 2,
+ inputSerial: 1,
+ shouldFail: true,
},
{
- name: "Empty NS Group Domain Or Not Primary Element Should Fail",
- initLocalRecords: []nbdns.SimpleRecord{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
+ name: "Empty NS Group Domain Or Not Primary Element Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: make(registeredHandlerMap),
+ initSerial: 0,
+ inputSerial: 1,
inputUpdate: nbdns.Config{
ServiceEnable: true,
CustomZones: []nbdns.CustomZone{
@@ -251,11 +256,11 @@ func TestUpdateDNSServer(t *testing.T) {
shouldFail: true,
},
{
- name: "Invalid NS Group Nameservers list Should Fail",
- initLocalRecords: []nbdns.SimpleRecord{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
+ name: "Invalid NS Group Nameservers list Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: make(registeredHandlerMap),
+ initSerial: 0,
+ inputSerial: 1,
inputUpdate: nbdns.Config{
ServiceEnable: true,
CustomZones: []nbdns.CustomZone{
@@ -273,11 +278,11 @@ func TestUpdateDNSServer(t *testing.T) {
shouldFail: true,
},
{
- name: "Invalid Custom Zone Records list Should Skip",
- initLocalRecords: []nbdns.SimpleRecord{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
+ name: "Invalid Custom Zone Records list Should Skip",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: make(registeredHandlerMap),
+ initSerial: 0,
+ inputSerial: 1,
inputUpdate: nbdns.Config{
ServiceEnable: true,
CustomZones: []nbdns.CustomZone{
@@ -299,8 +304,8 @@ func TestUpdateDNSServer(t *testing.T) {
}},
},
{
- name: "Empty Config Should Succeed and Clean Maps",
- initLocalRecords: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}},
+ name: "Empty Config Should Succeed and Clean Maps",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
initUpstreamMap: registeredHandlerMap{
generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
domain: zoneRecords[0].Name,
@@ -315,8 +320,8 @@ func TestUpdateDNSServer(t *testing.T) {
expectedLocalQs: []dns.Question{},
},
{
- name: "Disabled Service Should clean map",
- initLocalRecords: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}},
+ name: "Disabled Service Should clean map",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
initUpstreamMap: registeredHandlerMap{
generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
domain: zoneRecords[0].Name,
@@ -385,7 +390,7 @@ func TestUpdateDNSServer(t *testing.T) {
}()
dnsServer.dnsMuxMap = testCase.initUpstreamMap
- dnsServer.localResolver.Update(testCase.initLocalRecords)
+ dnsServer.localResolver.Update(testCase.initLocalZones)
dnsServer.updateSerial = testCase.initSerial
err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
@@ -510,8 +515,7 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) {
priority: PriorityUpstream,
},
}
- //dnsServer.localResolver.RegisteredMap = local.RegistrationMap{local.BuildRecordKey("netbird.cloud", dns.ClassINET, dns.TypeA): struct{}{}}
- dnsServer.localResolver.Update([]nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}})
+ dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
dnsServer.updateSerial = 0
nameServers := []nbdns.NameServer{
@@ -2048,7 +2052,7 @@ func TestLocalResolverPriorityInServer(t *testing.T) {
func TestLocalResolverPriorityConstants(t *testing.T) {
// Test that priority constants are ordered correctly
- assert.Greater(t, PriorityLocal, PriorityDNSRoute, "Local priority should be higher than DNS route")
+ assert.Greater(t, PriorityDNSRoute, PriorityLocal, "DNS Route should be higher than Local priority")
assert.Greater(t, PriorityLocal, PriorityUpstream, "Local priority should be higher than upstream")
assert.Greater(t, PriorityUpstream, PriorityDefault, "Upstream priority should be higher than default")
diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go
index 2a92fd6d8..654d280ef 100644
--- a/client/internal/dns/upstream.go
+++ b/client/internal/dns/upstream.go
@@ -2,7 +2,6 @@ package dns
import (
"context"
- "crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -19,8 +18,10 @@ import (
"github.com/hashicorp/go-multierror"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
+ "golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/internal/dns/resutil"
"github.com/netbirdio/netbird/client/internal/dns/types"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/proto"
@@ -113,10 +114,7 @@ func (u *upstreamResolverBase) Stop() {
// ServeDNS handles a DNS request
func (u *upstreamResolverBase) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
- requestID := GenerateRequestID()
- logger := log.WithField("request_id", requestID)
-
- logger.Tracef("received upstream question: domain=%s type=%v class=%v", r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass)
+ logger := log.WithField("request_id", resutil.GetRequestID(w))
u.prepareRequest(r)
@@ -202,11 +200,18 @@ func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.Add
func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dns.Msg, upstream netip.AddrPort, domain string, t time.Duration, logger *log.Entry) bool {
u.successCount.Add(1)
- logger.Tracef("took %s to query the upstream %s for question domain=%s", t, upstream, domain)
+
+ resutil.SetMeta(w, "upstream", upstream.String())
+
+ // Clear Zero bit from external responses to prevent upstream servers from
+ // manipulating our internal fallthrough signaling mechanism
+ rm.MsgHdr.Zero = false
if err := w.WriteMsg(rm); err != nil {
logger.Errorf("failed to write DNS response for question domain=%s: %s", domain, err)
+ return true
}
+
return true
}
@@ -414,16 +419,56 @@ func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, u
return rm, t, nil
}
-func GenerateRequestID() string {
- bytes := make([]byte, 4)
- _, err := rand.Read(bytes)
+// ExchangeWithNetstack performs a DNS exchange using netstack for dialing.
+// This is needed when netstack is enabled to reach peer IPs through the tunnel.
+func ExchangeWithNetstack(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, upstream string) (*dns.Msg, error) {
+ reply, err := netstackExchange(ctx, nsNet, r, upstream, "udp")
if err != nil {
- log.Errorf("failed to generate request ID: %v", err)
- return ""
+ return nil, err
}
- return hex.EncodeToString(bytes)
+
+ // If response is truncated, retry with TCP
+ if reply != nil && reply.MsgHdr.Truncated {
+ log.Tracef("udp response for domain=%s type=%v class=%v is truncated, trying TCP",
+ r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass)
+ return netstackExchange(ctx, nsNet, r, upstream, "tcp")
+ }
+
+ return reply, nil
}
+func netstackExchange(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, upstream, network string) (*dns.Msg, error) {
+ conn, err := nsNet.DialContext(ctx, network, upstream)
+ if err != nil {
+ return nil, fmt.Errorf("with %s: %w", network, err)
+ }
+ defer func() {
+ if err := conn.Close(); err != nil {
+ log.Debugf("failed to close DNS connection: %v", err)
+ }
+ }()
+
+ if deadline, ok := ctx.Deadline(); ok {
+ if err := conn.SetDeadline(deadline); err != nil {
+ return nil, fmt.Errorf("set deadline: %w", err)
+ }
+ }
+
+ dnsConn := &dns.Conn{Conn: conn}
+
+ if err := dnsConn.WriteMsg(r); err != nil {
+ return nil, fmt.Errorf("write %s message: %w", network, err)
+ }
+
+ reply, err := dnsConn.ReadMsg()
+ if err != nil {
+ return nil, fmt.Errorf("read %s message: %w", network, err)
+ }
+
+ return reply, nil
+}
+
+
// FormatPeerStatus formats peer connection status information for debugging DNS timeouts
func FormatPeerStatus(peerState *peer.State) string {
isConnected := peerState.ConnStatus == peer.StatusConnected
diff --git a/client/internal/dns/upstream_android.go b/client/internal/dns/upstream_android.go
index def281f28..d7cff377b 100644
--- a/client/internal/dns/upstream_android.go
+++ b/client/internal/dns/upstream_android.go
@@ -23,9 +23,7 @@ type upstreamResolver struct {
// first time, and we need to wait for a while to start to use again the proper DNS resolver.
func newUpstreamResolver(
ctx context.Context,
- _ string,
- _ netip.Addr,
- _ netip.Prefix,
+ _ WGIface,
statusRecorder *peer.Status,
hostsDNSHolder *hostsDNSHolder,
domain string,
diff --git a/client/internal/dns/upstream_general.go b/client/internal/dns/upstream_general.go
index 434e5880b..1143b6c51 100644
--- a/client/internal/dns/upstream_general.go
+++ b/client/internal/dns/upstream_general.go
@@ -5,22 +5,23 @@ package dns
import (
"context"
"net/netip"
+ "runtime"
"time"
"github.com/miekg/dns"
+ "golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/internal/peer"
)
type upstreamResolver struct {
*upstreamResolverBase
+ nsNet *netstack.Net
}
func newUpstreamResolver(
ctx context.Context,
- _ string,
- _ netip.Addr,
- _ netip.Prefix,
+ wgIface WGIface,
statusRecorder *peer.Status,
_ *hostsDNSHolder,
domain string,
@@ -28,12 +29,23 @@ func newUpstreamResolver(
upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain)
nonIOS := &upstreamResolver{
upstreamResolverBase: upstreamResolverBase,
+ nsNet: wgIface.GetNet(),
}
upstreamResolverBase.upstreamClient = nonIOS
return nonIOS, nil
}
func (u *upstreamResolver) exchange(ctx context.Context, upstream string, r *dns.Msg) (rm *dns.Msg, t time.Duration, err error) {
+ // TODO: Check if upstream DNS server is routed through a peer before using netstack.
+ // Similar to iOS logic, we should determine if the DNS server is reachable directly
+ // or needs to go through the tunnel, and only use netstack when necessary.
+ // For now, only use netstack on JS platform where direct access is not possible.
+ if u.nsNet != nil && runtime.GOOS == "js" {
+ start := time.Now()
+ reply, err := ExchangeWithNetstack(ctx, u.nsNet, r, upstream)
+ return reply, time.Since(start), err
+ }
+
client := &dns.Client{
Timeout: ClientTimeout,
}
diff --git a/client/internal/dns/upstream_ios.go b/client/internal/dns/upstream_ios.go
index eadcdd117..4d053a5a1 100644
--- a/client/internal/dns/upstream_ios.go
+++ b/client/internal/dns/upstream_ios.go
@@ -26,9 +26,7 @@ type upstreamResolverIOS struct {
func newUpstreamResolver(
ctx context.Context,
- interfaceName string,
- ip netip.Addr,
- net netip.Prefix,
+ wgIface WGIface,
statusRecorder *peer.Status,
_ *hostsDNSHolder,
domain string,
@@ -37,9 +35,9 @@ func newUpstreamResolver(
ios := &upstreamResolverIOS{
upstreamResolverBase: upstreamResolverBase,
- lIP: ip,
- lNet: net,
- interfaceName: interfaceName,
+ lIP: wgIface.Address().IP,
+ lNet: wgIface.Address().Network,
+ interfaceName: wgIface.Name(),
}
ios.upstreamClient = ios
diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go
index e1573e75e..2852f4775 100644
--- a/client/internal/dns/upstream_test.go
+++ b/client/internal/dns/upstream_test.go
@@ -2,13 +2,17 @@ package dns
import (
"context"
+ "net"
"net/netip"
"strings"
"testing"
"time"
"github.com/miekg/dns"
+ "golang.zx2c4.com/wireguard/tun/netstack"
+ "github.com/netbirdio/netbird/client/iface/device"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/dns/test"
)
@@ -58,7 +62,7 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.TODO())
- resolver, _ := newUpstreamResolver(ctx, "", netip.Addr{}, netip.Prefix{}, nil, nil, ".")
+ resolver, _ := newUpstreamResolver(ctx, &mockNetstackProvider{}, nil, nil, ".")
// Convert test servers to netip.AddrPort
var servers []netip.AddrPort
for _, server := range testCase.InputServers {
@@ -112,6 +116,19 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) {
}
}
+type mockNetstackProvider struct{}
+
+func (m *mockNetstackProvider) Name() string { return "mock" }
+func (m *mockNetstackProvider) Address() wgaddr.Address { return wgaddr.Address{} }
+func (m *mockNetstackProvider) ToInterface() *net.Interface { return nil }
+func (m *mockNetstackProvider) IsUserspaceBind() bool { return false }
+func (m *mockNetstackProvider) GetFilter() device.PacketFilter { return nil }
+func (m *mockNetstackProvider) GetDevice() *device.FilteredDevice { return nil }
+func (m *mockNetstackProvider) GetNet() *netstack.Net { return nil }
+func (m *mockNetstackProvider) GetInterfaceGUIDString() (string, error) {
+ return "", nil
+}
+
type mockUpstreamResolver struct {
r *dns.Msg
rtt time.Duration
diff --git a/client/internal/dns/wgiface.go b/client/internal/dns/wgiface.go
index 28e9cebf1..717e16325 100644
--- a/client/internal/dns/wgiface.go
+++ b/client/internal/dns/wgiface.go
@@ -5,6 +5,8 @@ package dns
import (
"net"
+ "golang.zx2c4.com/wireguard/tun/netstack"
+
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
@@ -17,4 +19,5 @@ type WGIface interface {
IsUserspaceBind() bool
GetFilter() device.PacketFilter
GetDevice() *device.FilteredDevice
+ GetNet() *netstack.Net
}
diff --git a/client/internal/dns/wgiface_windows.go b/client/internal/dns/wgiface_windows.go
index d1374fd54..347e0233a 100644
--- a/client/internal/dns/wgiface_windows.go
+++ b/client/internal/dns/wgiface_windows.go
@@ -1,6 +1,8 @@
package dns
import (
+ "golang.zx2c4.com/wireguard/tun/netstack"
+
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
@@ -12,5 +14,6 @@ type WGIface interface {
IsUserspaceBind() bool
GetFilter() device.PacketFilter
GetDevice() *device.FilteredDevice
+ GetNet() *netstack.Net
GetInterfaceGUIDString() (string, error)
}
diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go
index 6b8042ccb..1230a4e46 100644
--- a/client/internal/dnsfwd/forwarder.go
+++ b/client/internal/dnsfwd/forwarder.go
@@ -18,6 +18,7 @@ import (
nberrors "github.com/netbirdio/netbird/client/errors"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
+ "github.com/netbirdio/netbird/client/internal/dns/resutil"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/route"
)
@@ -189,29 +190,22 @@ func (f *DNSForwarder) Close(ctx context.Context) error {
return nberrors.FormatErrorOrNil(result)
}
-func (f *DNSForwarder) handleDNSQuery(w dns.ResponseWriter, query *dns.Msg) *dns.Msg {
+func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, query *dns.Msg) *dns.Msg {
if len(query.Question) == 0 {
return nil
}
question := query.Question[0]
- log.Tracef("received DNS request for DNS forwarder: domain=%v type=%v class=%v",
- question.Name, question.Qtype, question.Qclass)
+ logger.Tracef("received DNS request for DNS forwarder: domain=%s type=%s class=%s",
+ question.Name, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
domain := strings.ToLower(question.Name)
resp := query.SetReply(query)
- var network string
- switch question.Qtype {
- case dns.TypeA:
- network = "ip4"
- case dns.TypeAAAA:
- network = "ip6"
- default:
- // TODO: Handle other types
-
+ network := resutil.NetworkForQtype(question.Qtype)
+ if network == "" {
resp.Rcode = dns.RcodeNotImplemented
if err := w.WriteMsg(resp); err != nil {
- log.Errorf("failed to write DNS response: %v", err)
+ logger.Errorf("failed to write DNS response: %v", err)
}
return nil
}
@@ -221,33 +215,35 @@ func (f *DNSForwarder) handleDNSQuery(w dns.ResponseWriter, query *dns.Msg) *dns
if mostSpecificResId == "" {
resp.Rcode = dns.RcodeRefused
if err := w.WriteMsg(resp); err != nil {
- log.Errorf("failed to write DNS response: %v", err)
+ logger.Errorf("failed to write DNS response: %v", err)
}
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout)
defer cancel()
- ips, err := f.resolver.LookupNetIP(ctx, network, domain)
- if err != nil {
- f.handleDNSError(ctx, w, question, resp, domain, err)
+
+ result := resutil.LookupIP(ctx, f.resolver, network, domain, question.Qtype)
+ if result.Err != nil {
+ f.handleDNSError(ctx, logger, w, question, resp, domain, result)
return nil
}
- // Unmap IPv4-mapped IPv6 addresses that some resolvers may return
- for i, ip := range ips {
- ips[i] = ip.Unmap()
- }
-
- f.updateInternalState(ips, mostSpecificResId, matchingEntries)
- f.addIPsToResponse(resp, domain, ips)
- f.cache.set(domain, question.Qtype, ips)
+ f.updateInternalState(result.IPs, mostSpecificResId, matchingEntries)
+ resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, result.IPs, f.ttl)...)
+ f.cache.set(domain, question.Qtype, result.IPs)
return resp
}
func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) {
- resp := f.handleDNSQuery(w, query)
+ startTime := time.Now()
+ logger := log.WithFields(log.Fields{
+ "request_id": resutil.GenerateRequestID(),
+ "dns_id": fmt.Sprintf("%04x", query.Id),
+ })
+
+ resp := f.handleDNSQuery(logger, w, query)
if resp == nil {
return
}
@@ -265,19 +261,33 @@ func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) {
}
if err := w.WriteMsg(resp); err != nil {
- log.Errorf("failed to write DNS response: %v", err)
+ logger.Errorf("failed to write DNS response: %v", err)
+ return
}
+
+ logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s",
+ query.Question[0].Name, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime))
}
func (f *DNSForwarder) handleDNSQueryTCP(w dns.ResponseWriter, query *dns.Msg) {
- resp := f.handleDNSQuery(w, query)
+ startTime := time.Now()
+ logger := log.WithFields(log.Fields{
+ "request_id": resutil.GenerateRequestID(),
+ "dns_id": fmt.Sprintf("%04x", query.Id),
+ })
+
+ resp := f.handleDNSQuery(logger, w, query)
if resp == nil {
return
}
if err := w.WriteMsg(resp); err != nil {
- log.Errorf("failed to write DNS response: %v", err)
+ logger.Errorf("failed to write DNS response: %v", err)
+ return
}
+
+ logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s",
+ query.Question[0].Name, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime))
}
func (f *DNSForwarder) updateInternalState(ips []netip.Addr, mostSpecificResId route.ResID, matchingEntries []*ForwarderEntry) {
@@ -315,140 +325,64 @@ func (f *DNSForwarder) updateFirewall(matchingEntries []*ForwarderEntry, prefixe
}
}
-// setResponseCodeForNotFound determines and sets the appropriate response code when IsNotFound is true
-// It distinguishes between NXDOMAIN (domain doesn't exist) and NODATA (domain exists but no records of requested type)
-//
-// LIMITATION: This function only checks A and AAAA record types to determine domain existence.
-// If a domain has only other record types (MX, TXT, CNAME, etc.) but no A/AAAA records,
-// it may incorrectly return NXDOMAIN instead of NODATA. This is acceptable since the forwarder
-// only handles A/AAAA queries and returns NOTIMP for other types.
-func (f *DNSForwarder) setResponseCodeForNotFound(ctx context.Context, resp *dns.Msg, domain string, originalQtype uint16) {
- // Try querying for a different record type to see if the domain exists
- // If the original query was for AAAA, try A. If it was for A, try AAAA.
- // This helps distinguish between NXDOMAIN and NODATA.
- var alternativeNetwork string
- switch originalQtype {
- case dns.TypeAAAA:
- alternativeNetwork = "ip4"
- case dns.TypeA:
- alternativeNetwork = "ip6"
- default:
- resp.Rcode = dns.RcodeNameError
- return
- }
-
- if _, err := f.resolver.LookupNetIP(ctx, alternativeNetwork, domain); err != nil {
- var dnsErr *net.DNSError
- if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
- // Alternative query also returned not found - domain truly doesn't exist
- resp.Rcode = dns.RcodeNameError
- return
- }
- // Some other error (timeout, server failure, etc.) - can't determine, assume domain exists
- resp.Rcode = dns.RcodeSuccess
- return
- }
-
- // Alternative query succeeded - domain exists but has no records of this type
- resp.Rcode = dns.RcodeSuccess
-}
-
// handleDNSError processes DNS lookup errors and sends an appropriate error response.
func (f *DNSForwarder) handleDNSError(
ctx context.Context,
+ logger *log.Entry,
w dns.ResponseWriter,
question dns.Question,
resp *dns.Msg,
domain string,
- err error,
+ result resutil.LookupResult,
) {
- // Default to SERVFAIL; override below when appropriate.
- resp.Rcode = dns.RcodeServerFailure
-
qType := question.Qtype
qTypeName := dns.TypeToString[qType]
- // Prefer typed DNS errors; fall back to generic logging otherwise.
- var dnsErr *net.DNSError
- if !errors.As(err, &dnsErr) {
- log.Warnf(errResolveFailed, domain, err)
- if writeErr := w.WriteMsg(resp); writeErr != nil {
- log.Errorf("failed to write failure DNS response: %v", writeErr)
- }
- return
- }
+ resp.Rcode = result.Rcode
- // NotFound: set NXDOMAIN / appropriate code via helper.
- if dnsErr.IsNotFound {
- f.setResponseCodeForNotFound(ctx, resp, domain, qType)
- if writeErr := w.WriteMsg(resp); writeErr != nil {
- log.Errorf("failed to write failure DNS response: %v", writeErr)
- }
+ // NotFound: cache negative result and respond
+ if result.Rcode == dns.RcodeNameError || result.Rcode == dns.RcodeSuccess {
f.cache.set(domain, question.Qtype, nil)
+ if writeErr := w.WriteMsg(resp); writeErr != nil {
+ logger.Errorf("failed to write failure DNS response: %v", writeErr)
+ }
return
}
// Upstream failed but we might have a cached answer—serve it if present.
if ips, ok := f.cache.get(domain, qType); ok {
if len(ips) > 0 {
- log.Debugf("serving cached DNS response after upstream failure: domain=%s type=%s", domain, qTypeName)
- f.addIPsToResponse(resp, domain, ips)
+ logger.Debugf("serving cached DNS response after upstream failure: domain=%s type=%s", domain, qTypeName)
+ resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, ips, f.ttl)...)
resp.Rcode = dns.RcodeSuccess
if writeErr := w.WriteMsg(resp); writeErr != nil {
- log.Errorf("failed to write cached DNS response: %v", writeErr)
- }
- } else { // send NXDOMAIN / appropriate code if cache is empty
- f.setResponseCodeForNotFound(ctx, resp, domain, qType)
- if writeErr := w.WriteMsg(resp); writeErr != nil {
- log.Errorf("failed to write failure DNS response: %v", writeErr)
+ logger.Errorf("failed to write cached DNS response: %v", writeErr)
}
+ return
+ }
+
+ // Cached negative result - re-verify NXDOMAIN vs NODATA
+ verifyResult := resutil.LookupIP(ctx, f.resolver, resutil.NetworkForQtype(qType), domain, qType)
+ if verifyResult.Rcode == dns.RcodeNameError || verifyResult.Rcode == dns.RcodeSuccess {
+ resp.Rcode = verifyResult.Rcode
+ if writeErr := w.WriteMsg(resp); writeErr != nil {
+ logger.Errorf("failed to write failure DNS response: %v", writeErr)
+ }
+ return
}
- return
}
- // No cache. Log with or without the server field for more context.
- if dnsErr.Server != "" {
- log.Warnf("failed to resolve: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, err)
+ // No cache or verification failed. Log with or without the server field for more context.
+ var dnsErr *net.DNSError
+ if errors.As(result.Err, &dnsErr) && dnsErr.Server != "" {
+ logger.Warnf("failed to resolve: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, result.Err)
} else {
- log.Warnf(errResolveFailed, domain, err)
+ logger.Warnf(errResolveFailed, domain, result.Err)
}
// Write final failure response.
if writeErr := w.WriteMsg(resp); writeErr != nil {
- log.Errorf("failed to write failure DNS response: %v", writeErr)
- }
-}
-
-// addIPsToResponse adds IP addresses to the DNS response as appropriate A or AAAA records
-func (f *DNSForwarder) addIPsToResponse(resp *dns.Msg, domain string, ips []netip.Addr) {
- for _, ip := range ips {
- var respRecord dns.RR
- if ip.Is6() {
- log.Tracef("resolved domain=%s to IPv6=%s", domain, ip)
- rr := dns.AAAA{
- AAAA: ip.AsSlice(),
- Hdr: dns.RR_Header{
- Name: domain,
- Rrtype: dns.TypeAAAA,
- Class: dns.ClassINET,
- Ttl: f.ttl,
- },
- }
- respRecord = &rr
- } else {
- log.Tracef("resolved domain=%s to IPv4=%s", domain, ip)
- rr := dns.A{
- A: ip.AsSlice(),
- Hdr: dns.RR_Header{
- Name: domain,
- Rrtype: dns.TypeA,
- Class: dns.ClassINET,
- Ttl: f.ttl,
- },
- }
- respRecord = &rr
- }
- resp.Answer = append(resp.Answer, respRecord)
+ logger.Errorf("failed to write failure DNS response: %v", writeErr)
}
}
diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go
index 4d0b96a75..6416c2f21 100644
--- a/client/internal/dnsfwd/forwarder_test.go
+++ b/client/internal/dnsfwd/forwarder_test.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/miekg/dns"
+ log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -317,7 +318,7 @@ func TestDNSForwarder_UnauthorizedDomainAccess(t *testing.T) {
query.SetQuestion(dns.Fqdn(tt.queryDomain), dns.TypeA)
mockWriter := &test.MockResponseWriter{}
- resp := forwarder.handleDNSQuery(mockWriter, query)
+ resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
if tt.shouldResolve {
require.NotNil(t, resp, "Expected response for authorized domain")
@@ -465,7 +466,7 @@ func TestDNSForwarder_FirewallSetUpdates(t *testing.T) {
dnsQuery.SetQuestion(dns.Fqdn(tt.query), dns.TypeA)
mockWriter := &test.MockResponseWriter{}
- resp := forwarder.handleDNSQuery(mockWriter, dnsQuery)
+ resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, dnsQuery)
// Verify response
if tt.shouldResolve {
@@ -527,7 +528,7 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) {
query.SetQuestion("example.com.", dns.TypeA)
mockWriter := &test.MockResponseWriter{}
- resp := forwarder.handleDNSQuery(mockWriter, query)
+ resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
// Verify response contains all IPs
require.NotNil(t, resp)
@@ -604,7 +605,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
},
}
- _ = forwarder.handleDNSQuery(mockWriter, query)
+ _ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
// Check the response written to the writer
require.NotNil(t, writtenResp, "Expected response to be written")
@@ -674,7 +675,7 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) {
q1 := &dns.Msg{}
q1.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
w1 := &test.MockResponseWriter{}
- resp1 := forwarder.handleDNSQuery(w1, q1)
+ resp1 := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1)
require.NotNil(t, resp1)
require.Equal(t, dns.RcodeSuccess, resp1.Rcode)
require.Len(t, resp1.Answer, 1)
@@ -684,7 +685,7 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) {
q2.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
var writtenResp *dns.Msg
w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }}
- _ = forwarder.handleDNSQuery(w2, q2)
+ _ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2)
require.NotNil(t, writtenResp, "expected response to be written")
require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode)
@@ -714,7 +715,7 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) {
q1 := &dns.Msg{}
q1.SetQuestion(mixedQuery+".", dns.TypeA)
w1 := &test.MockResponseWriter{}
- resp1 := forwarder.handleDNSQuery(w1, q1)
+ resp1 := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1)
require.NotNil(t, resp1)
require.Equal(t, dns.RcodeSuccess, resp1.Rcode)
require.Len(t, resp1.Answer, 1)
@@ -728,7 +729,7 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) {
q2.SetQuestion("EXAMPLE.COM", dns.TypeA)
var writtenResp *dns.Msg
w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }}
- _ = forwarder.handleDNSQuery(w2, q2)
+ _ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2)
require.NotNil(t, writtenResp)
require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode)
@@ -783,7 +784,7 @@ func TestDNSForwarder_MultipleOverlappingPatterns(t *testing.T) {
query.SetQuestion("smtp.mail.example.com.", dns.TypeA)
mockWriter := &test.MockResponseWriter{}
- resp := forwarder.handleDNSQuery(mockWriter, query)
+ resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
require.NotNil(t, resp)
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
@@ -904,7 +905,7 @@ func TestDNSForwarder_NodataVsNxdomain(t *testing.T) {
},
}
- resp := forwarder.handleDNSQuery(mockWriter, query)
+ resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
// If a response was returned, it means it should be written (happens in wrapper functions)
if resp != nil && writtenResp == nil {
@@ -937,7 +938,7 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) {
return nil
},
}
- resp := forwarder.handleDNSQuery(mockWriter, query)
+ resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
assert.Nil(t, resp, "Should return nil for empty query")
assert.False(t, writeCalled, "Should not write response for empty query")
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 7e6a933f4..d3aeb0fa6 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -31,6 +31,7 @@ import (
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/udpmux"
"github.com/netbirdio/netbird/client/internal/acl"
+ "github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
"github.com/netbirdio/netbird/client/internal/dnsfwd"
@@ -43,12 +44,14 @@ import (
"github.com/netbirdio/netbird/client/internal/peer/guard"
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/peerstore"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/relay"
"github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/updatemanager"
+ "github.com/netbirdio/netbird/client/jobexec"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/shared/management/domain"
semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group"
@@ -133,6 +136,11 @@ type EngineConfig struct {
LazyConnectionEnabled bool
MTU uint16
+
+ // for debug bundle generation
+ ProfileConfig *profilemanager.Config
+
+ LogPath string
}
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
@@ -196,7 +204,8 @@ type Engine struct {
stateManager *statemanager.Manager
srWatcher *guard.SRWatcher
- // Sync response persistence
+ // Sync response persistence (protected by syncRespMux)
+ syncRespMux sync.RWMutex
persistSyncResponse bool
latestSyncResponse *mgmProto.SyncResponse
connSemaphore *semaphoregroup.SemaphoreGroup
@@ -215,6 +224,9 @@ type Engine struct {
// clientMetrics collects and pushes metrics
clientMetrics *metrics.ClientMetrics
+
+ jobExecutor *jobexec.Executor
+ jobExecutorWG sync.WaitGroup
}
// Peer is an instance of the Connection Peer
@@ -228,7 +240,18 @@ type localIpUpdater interface {
}
// NewEngine creates a new Connection Engine with probes attached
-func NewEngine(clientCtx context.Context, clientCancel context.CancelFunc, signalClient signal.Client, mgmClient mgm.Client, relayManager *relayClient.Manager, config *EngineConfig, mobileDep MobileDependency, statusRecorder *peer.Status, checks []*mgmProto.Checks, stateManager *statemanager.Manager) *Engine {
+func NewEngine(
+ clientCtx context.Context,
+ clientCancel context.CancelFunc,
+ signalClient signal.Client,
+ mgmClient mgm.Client,
+ relayManager *relayClient.Manager,
+ config *EngineConfig,
+ mobileDep MobileDependency,
+ statusRecorder *peer.Status,
+ checks []*mgmProto.Checks,
+ stateManager *statemanager.Manager,
+) *Engine {
// Initialize metrics based on deployment type
var deploymentType metrics.DeploymentType
if mgmClient != nil {
@@ -254,6 +277,7 @@ func NewEngine(clientCtx context.Context, clientCancel context.CancelFunc, signa
checks: checks,
connSemaphore: semaphoregroup.NewSemaphoreGroup(connInitLimit),
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
+ jobExecutor: jobexec.NewExecutor(),
clientMetrics: metrics.NewClientMetrics(deploymentType, true),
}
@@ -323,6 +347,8 @@ func (e *Engine) Stop() error {
e.cancel()
}
+ e.jobExecutorWG.Wait() // block until job goroutines finish
+
e.close()
// stop flow manager after wg interface is gone
@@ -490,6 +516,11 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
return fmt.Errorf("up wg interface: %w", err)
}
+ // Set the WireGuard interface for rosenpass after interface is up
+ if e.rpManager != nil {
+ e.rpManager.SetInterface(e.wgInterface)
+ }
+
// if inbound conns are blocked there is no need to create the ACL manager
if e.firewall != nil && !e.config.BlockInbound {
e.acl = acl.NewDefaultManager(e.firewall)
@@ -511,6 +542,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.receiveSignalEvents()
e.receiveManagementEvents()
+ e.receiveJobEvents()
// starting network monitor at the very last to avoid disruptions
e.startNetworkMonitor()
@@ -839,9 +871,18 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
+ // Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
+ // Read the storage-enabled flag under the syncRespMux too.
+ e.syncRespMux.RLock()
+ enabled := e.persistSyncResponse
+ e.syncRespMux.RUnlock()
+
// Store sync response if persistence is enabled
- if e.persistSyncResponse {
+ if enabled {
+ e.syncRespMux.Lock()
e.latestSyncResponse = update
+ e.syncRespMux.Unlock()
+
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
}
@@ -971,6 +1012,78 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
return nil
}
+func (e *Engine) receiveJobEvents() {
+ e.jobExecutorWG.Add(1)
+ go func() {
+ defer e.jobExecutorWG.Done()
+ err := e.mgmClient.Job(e.ctx, func(msg *mgmProto.JobRequest) *mgmProto.JobResponse {
+ resp := mgmProto.JobResponse{
+ ID: msg.ID,
+ Status: mgmProto.JobStatus_failed,
+ }
+ switch params := msg.WorkloadParameters.(type) {
+ case *mgmProto.JobRequest_Bundle:
+ bundleResult, err := e.handleBundle(params.Bundle)
+ if err != nil {
+ log.Errorf("handling bundle: %v", err)
+ resp.Reason = []byte(err.Error())
+ return &resp
+ }
+ resp.Status = mgmProto.JobStatus_succeeded
+ resp.WorkloadResults = bundleResult
+ return &resp
+ default:
+ resp.Reason = []byte(jobexec.ErrJobNotImplemented.Error())
+ return &resp
+ }
+ })
+ if err != nil {
+ // happens if management is unavailable for a long time.
+ // We want to cancel the operation of the whole client
+ _ = CtxGetState(e.ctx).Wrap(ErrResetConnection)
+ e.clientCancel()
+ return
+ }
+ log.Info("stopped receiving jobs from Management Service")
+ }()
+ log.Info("connecting to Management Service jobs stream")
+}
+
+func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) {
+ log.Infof("handle remote debug bundle request: %s", params.String())
+ syncResponse, err := e.GetLatestSyncResponse()
+ if err != nil {
+ log.Warnf("get latest sync response: %v", err)
+ }
+
+ bundleDeps := debug.GeneratorDependencies{
+ InternalConfig: e.config.ProfileConfig,
+ StatusRecorder: e.statusRecorder,
+ SyncResponse: syncResponse,
+ LogPath: e.config.LogPath,
+ ClientMetrics: e.clientMetrics,
+ }
+
+ bundleJobParams := debug.BundleConfig{
+ Anonymize: params.Anonymize,
+ IncludeSystemInfo: true,
+ LogFileCount: uint32(params.LogFileCount),
+ }
+
+ waitFor := time.Duration(params.BundleForTime) * time.Minute
+
+ uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String())
+ if err != nil {
+ return nil, err
+ }
+
+ response := &mgmProto.JobResponse_Bundle{
+ Bundle: &mgmProto.BundleResult{
+ UploadKey: uploadKey,
+ },
+ }
+ return response, nil
+}
// receiveManagementEvents connects to the Management Service event stream to receive updates from the management service
// E.g. when a new peer has been registered and we are allowed to connect to it.
@@ -1262,11 +1375,16 @@ func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig, network netip.Prefix) nbdns
ForwarderPort: forwarderPort,
}
- for _, zone := range protoDNSConfig.GetCustomZones() {
+ protoZones := protoDNSConfig.GetCustomZones()
+ // Treat single zone as authoritative for backward compatibility with old servers
+ // that only send the peer FQDN zone without setting field 4.
+ singleZoneCompat := len(protoZones) == 1
+
+ for _, zone := range protoZones {
dnsZone := nbdns.CustomZone{
Domain: zone.GetDomain(),
SearchDomainDisabled: zone.GetSearchDomainDisabled(),
- SkipPTRProcess: zone.GetSkipPTRProcess(),
+ NonAuthoritative: zone.GetNonAuthoritative() && !singleZoneCompat,
}
for _, record := range zone.Records {
dnsRecord := nbdns.SimpleRecord{
@@ -1412,6 +1530,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
if e.rpManager != nil {
peerConn.SetOnConnected(e.rpManager.OnConnected)
peerConn.SetOnDisconnected(e.rpManager.OnDisconnected)
+ peerConn.SetRosenpassInitializedPresharedKeyValidator(e.rpManager.IsPresharedKeyInitialized)
}
return peerConn, nil
@@ -1760,22 +1879,26 @@ func (e *Engine) RunHealthProbes(waitForResult bool) bool {
}
e.syncMsgMux.Unlock()
- var results []relay.ProbeResult
- if waitForResult {
- results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns)
- } else {
- results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns)
- }
- e.statusRecorder.UpdateRelayStates(results)
+ // Skip STUN/TURN probing for JS/WASM as it's not available
relayHealthy := true
- for _, res := range results {
- if res.Err != nil {
- relayHealthy = false
- break
+ if runtime.GOOS != "js" {
+ var results []relay.ProbeResult
+ if waitForResult {
+ results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns)
+ } else {
+ results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns)
}
+ e.statusRecorder.UpdateRelayStates(results)
+
+ for _, res := range results {
+ if res.Err != nil {
+ relayHealthy = false
+ break
+ }
+ }
+ log.Debugf("relay health check: healthy=%t", relayHealthy)
}
- log.Debugf("relay health check: healthy=%t", relayHealthy)
allHealthy := signalHealthy && managementHealthy && relayHealthy
log.Debugf("all health checks completed: healthy=%t", allHealthy)
@@ -1856,8 +1979,8 @@ func (e *Engine) stopDNSServer() {
// SetSyncResponsePersistence enables or disables sync response persistence
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
- e.syncMsgMux.Lock()
- defer e.syncMsgMux.Unlock()
+ e.syncRespMux.Lock()
+ defer e.syncRespMux.Unlock()
if enabled == e.persistSyncResponse {
return
@@ -1872,20 +1995,22 @@ func (e *Engine) SetSyncResponsePersistence(enabled bool) {
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
- e.syncMsgMux.Lock()
- defer e.syncMsgMux.Unlock()
+ e.syncRespMux.RLock()
+ enabled := e.persistSyncResponse
+ latest := e.latestSyncResponse
+ e.syncRespMux.RUnlock()
- if !e.persistSyncResponse {
+ if !enabled {
return nil, errors.New("sync response persistence is disabled")
}
- if e.latestSyncResponse == nil {
+ if latest == nil {
//nolint:nilnil
return nil, nil
}
- log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(e.latestSyncResponse))
- sr, ok := proto.Clone(e.latestSyncResponse).(*mgmProto.SyncResponse)
+ log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest))
+ sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
if !ok {
return nil, fmt.Errorf("failed to clone sync response")
}
diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go
index e683d8cee..a8c05fe0a 100644
--- a/client/internal/engine_ssh.go
+++ b/client/internal/engine_ssh.go
@@ -72,9 +72,16 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
}
if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil {
+ audiences := protoJWT.GetAudiences()
+ if len(audiences) == 0 && protoJWT.GetAudience() != "" {
+ audiences = []string{protoJWT.GetAudience()}
+ }
+
+ log.Debugf("starting SSH server with JWT authentication: audiences=%v", audiences)
+
jwtConfig := &sshserver.JWTConfig{
Issuer: protoJWT.GetIssuer(),
- Audience: protoJWT.GetAudience(),
+ Audiences: audiences,
KeysLocation: protoJWT.GetKeysLocation(),
MaxTokenAge: protoJWT.GetMaxTokenAge(),
}
diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go
index a15ee0581..af9f27a71 100644
--- a/client/internal/engine_test.go
+++ b/client/internal/engine_test.go
@@ -25,6 +25,7 @@ import (
"google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal/stdnet"
+ "github.com/netbirdio/netbird/management/server/job"
"github.com/netbirdio/management-integrations/integrations"
@@ -213,6 +214,10 @@ func (m *MockWGIface) LastActivities() map[string]monotime.Time {
return nil
}
+func (m *MockWGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
+ return nil
+}
+
func TestMain(m *testing.M) {
_ = util.InitLog("debug", util.LogConsole)
code := m.Run()
@@ -1599,6 +1604,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
permissionsManager := permissions.NewManager(store)
peersManager := peers.NewManager(store, permissionsManager)
+ jobManager := job.NewJobManager(nil, store, peersManager)
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore)
@@ -1622,7 +1628,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
- accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
if err != nil {
return nil, "", err
}
@@ -1631,7 +1637,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
if err != nil {
return nil, "", err
}
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil)
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil)
if err != nil {
return nil, "", err
}
diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go
index 90b06cbd1..f8a433a6e 100644
--- a/client/internal/iface_common.go
+++ b/client/internal/iface_common.go
@@ -42,4 +42,5 @@ type wgIfaceBase interface {
GetNet() *netstack.Net
FullStats() (*configurer.Stats, error)
LastActivities() map[string]monotime.Time
+ SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
}
diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go
index 787ffc1ac..98df1a6a3 100644
--- a/client/internal/metrics/metrics.go
+++ b/client/internal/metrics/metrics.go
@@ -41,7 +41,7 @@ func NewClientMetrics(deploymentType DeploymentType, enabled bool) *ClientMetric
if !enabled {
impl = &noopMetrics{}
} else {
- impl = newOtelMetrics(deploymentType)
+ impl = newVictoriaMetrics(deploymentType)
}
return &ClientMetrics{impl: impl}
}
diff --git a/client/internal/metrics/victoria.go b/client/internal/metrics/victoria.go
index 0ea953003..23a967d45 100644
--- a/client/internal/metrics/victoria.go
+++ b/client/internal/metrics/victoria.go
@@ -6,6 +6,7 @@ import (
"io"
"github.com/VictoriaMetrics/metrics"
+ log "github.com/sirupsen/logrus"
)
// victoriaMetrics is the VictoriaMetrics implementation of ClientMetrics
@@ -82,6 +83,11 @@ func (m *victoriaMetrics) RecordConnectionStages(
m.set.GetOrCreateHistogram(
m.getMetricName("netbird_peer_connection_total_creation_to_handshake", connTypeStr, attemptType),
).Update(totalDuration)
+
+ log.Infof("--- Peer connection metrics [%s, %s, %s]: creation→semaphore: %.3fs, semaphore→signaling: %.3fs, signaling→connection: %.3fs, connection→handshake: %.3fs, total: %.3fs",
+ m.deploymentType.String(), connTypeStr, attemptType,
+ creationToSemaphore, semaphoreToSignaling, signalingToConnection, connectionToHandshake,
+ totalDuration)
}
// getMetricName constructs a metric name with labels
diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go
index 0efd95918..c23d356e0 100644
--- a/client/internal/peer/conn.go
+++ b/client/internal/peer/conn.go
@@ -100,8 +100,9 @@ type Conn struct {
relayManager *relayClient.Manager
srWatcher *guard.SRWatcher
- onConnected func(remoteWireGuardKey string, remoteRosenpassPubKey []byte, wireGuardIP string, remoteRosenpassAddr string)
- onDisconnected func(remotePeer string)
+ onConnected func(remoteWireGuardKey string, remoteRosenpassPubKey []byte, wireGuardIP string, remoteRosenpassAddr string)
+ onDisconnected func(remotePeer string)
+ rosenpassInitializedPresharedKeyValidator func(peerKey string) bool
statusRelay *worker.AtomicWorkerStatus
statusICE *worker.AtomicWorkerStatus
@@ -110,8 +111,10 @@ type Conn struct {
workerICE *WorkerICE
workerRelay *WorkerRelay
- wgWatcher *WGWatcher
- wgWatcherWg sync.WaitGroup
+
+ wgWatcher *WGWatcher
+ wgWatcherWg sync.WaitGroup
+ wgWatcherCancel context.CancelFunc
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
rosenpassRemoteKey []byte
@@ -130,11 +133,8 @@ type Conn struct {
endpointUpdater *EndpointUpdater
// Connection stage timestamps for metrics
- metricsRecorder MetricsRecorder
- hasBeenConnected bool // Track if we've ever established a successful connection
- isReconnectionAttempt bool // Track if current attempt is a reconnection
- stageTimestamps metrics.ConnectionStageTimestamps
- stagesMutex sync.Mutex
+ metricsRecorder MetricsRecorder
+ metricsStages MetricsStages
}
// NewConn creates a new not opened Conn to the remote peer.
@@ -163,7 +163,7 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
metricsRecorder: services.MetricsRecorder,
}
- conn.wgWatcher = NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState, conn.onWGHandshakeSuccess)
+ conn.wgWatcher = NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState)
return conn, nil
}
@@ -172,21 +172,24 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
// It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will
// be used.
func (conn *Conn) Open(engineCtx context.Context) error {
+ conn.mu.Lock()
+ if conn.opened {
+ conn.mu.Unlock()
+ return nil
+ }
+
// Record the start time - beginning of connection attempt
- conn.stagesMutex.Lock()
- conn.stageTimestamps.Created = time.Now()
- conn.stagesMutex.Unlock()
+ conn.metricsStages = MetricsStages{}
+ conn.metricsStages.RecordCreated()
+
+ conn.mu.Unlock()
// Semaphore.Add() blocks here until there's a free slot
+ // todo create common semaphor logic for reconnection and connections too that can remote seats from semaphor on the fly
if err := conn.semaphore.Add(engineCtx); err != nil {
return err
}
- // Record when semaphore was acquired (after the wait)
- conn.stagesMutex.Lock()
- conn.stageTimestamps.SemaphoreAcquired = time.Now()
- conn.stagesMutex.Unlock()
-
conn.mu.Lock()
defer conn.mu.Unlock()
@@ -195,9 +198,12 @@ func (conn *Conn) Open(engineCtx context.Context) error {
return nil
}
+ // Record when semaphore was acquired (after the wait)
+ conn.metricsStages.RecordSemaphoreAcquired()
+
conn.ctx, conn.ctxCancel = context.WithCancel(engineCtx)
- conn.workerRelay = NewWorkerRelay(conn.ctx, conn.Log, isController(conn.config), conn.config, conn, conn.relayManager, conn.dumpState)
+ conn.workerRelay = NewWorkerRelay(conn.ctx, conn.Log, isController(conn.config), conn.config, conn, conn.relayManager)
relayIsSupportedLocally := conn.workerRelay.RelayIsSupportedLocally()
workerICE, err := NewWorkerICE(conn.ctx, conn.Log, conn.config, conn, conn.signaler, conn.iFaceDiscover, conn.statusRecorder, relayIsSupportedLocally)
@@ -266,7 +272,9 @@ func (conn *Conn) Close(signalToRemote bool) {
conn.Log.Infof("close peer connection")
conn.ctxCancel()
- conn.wgWatcher.DisableWgWatcher()
+ if conn.wgWatcherCancel != nil {
+ conn.wgWatcherCancel()
+ }
conn.workerRelay.CloseConn()
conn.workerICE.Close()
@@ -296,13 +304,6 @@ func (conn *Conn) Close(signalToRemote bool) {
conn.setStatusToDisconnected()
- // Reset connection metrics state
- conn.stagesMutex.Lock()
- conn.hasBeenConnected = false
- conn.isReconnectionAttempt = false
- conn.stageTimestamps = metrics.ConnectionStageTimestamps{}
- conn.stagesMutex.Unlock()
-
conn.opened = false
conn.wg.Wait()
conn.Log.Infof("peer connection closed")
@@ -332,25 +333,17 @@ func (conn *Conn) SetOnDisconnected(handler func(remotePeer string)) {
conn.onDisconnected = handler
}
+// SetRosenpassInitializedPresharedKeyValidator sets a function to check if Rosenpass has taken over
+// PSK management for a peer. When this returns true, presharedKey() returns nil
+// to prevent UpdatePeer from overwriting the Rosenpass-managed PSK.
+func (conn *Conn) SetRosenpassInitializedPresharedKeyValidator(handler func(peerKey string) bool) {
+ conn.rosenpassInitializedPresharedKeyValidator = handler
+}
+
func (conn *Conn) OnRemoteOffer(offer OfferAnswer) {
conn.dumpState.RemoteOffer()
conn.Log.Infof("OnRemoteOffer, on status ICE: %s, status Relay: %s", conn.statusICE, conn.statusRelay)
- conn.stagesMutex.Lock()
- // Detect reconnection: we had been connected before, but now both ICE and Relay are disconnected
- if conn.hasBeenConnected && conn.evalStatus() != StatusConnected {
- conn.isReconnectionAttempt = true
- conn.stageTimestamps = metrics.ConnectionStageTimestamps{} // Reset for new reconnection attempt
- now := time.Now()
- conn.stageTimestamps.Created = now
- conn.stageTimestamps.Signaling = now
- conn.Log.Infof("Reconnection triggered by remote offer")
- } else if conn.stageTimestamps.Signaling.IsZero() {
- // First time receiving offer for this connection attempt (signaling start)
- conn.stageTimestamps.Signaling = time.Now()
- }
- conn.stagesMutex.Unlock()
-
conn.handshaker.OnRemoteOffer(offer)
}
@@ -425,9 +418,6 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
ep = directEp
}
- conn.wgWatcher.DisableWgWatcher()
- // todo consider to run conn.wgWatcherWg.Wait() here
-
if conn.wgProxyRelay != nil {
conn.wgProxyRelay.Pause()
}
@@ -438,6 +428,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
conn.Log.Infof("configure WireGuard endpoint to: %s", ep.String())
presharedKey := conn.presharedKey(iceConnInfo.RosenpassPubKey)
+ conn.enableWgWatcherIfNeeded()
if err = conn.endpointUpdater.ConfigureWGEndpoint(ep, presharedKey); err != nil {
conn.handleConfigurationFailure(err, wgProxy)
return
@@ -449,12 +440,6 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
conn.wgProxyRelay.RedirectAs(ep)
}
- conn.wgWatcherWg.Add(1)
- go func() {
- defer conn.wgWatcherWg.Done()
- conn.wgWatcher.EnableWgWatcher(conn.ctx, nil)
- }()
-
conn.currentConnPriority = priority
conn.statusICE.SetConnected()
conn.updateIceState(iceConnInfo)
@@ -488,17 +473,10 @@ func (conn *Conn) onICEStateDisconnected() {
conn.Log.Errorf("failed to switch to relay conn: %v", err)
}
- conn.wgWatcher.DisableWgWatcher()
- conn.wgWatcherWg.Add(1)
- go func() {
- defer conn.wgWatcherWg.Done()
- conn.wgWatcher.EnableWgWatcher(conn.ctx, conn.onWGDisconnected)
- }()
conn.wgProxyRelay.Work()
conn.currentConnPriority = conntype.Relay
} else {
conn.Log.Infof("ICE disconnected, do not switch to Relay. Reset priority to: %s", conntype.None.String())
- conn.wgWatcher.DisableWgWatcher()
conn.currentConnPriority = conntype.None
if err := conn.config.WgConfig.WgInterface.RemoveEndpointAddress(conn.config.WgConfig.RemoteKey); err != nil {
conn.Log.Errorf("failed to remove wg endpoint: %v", err)
@@ -511,15 +489,19 @@ func (conn *Conn) onICEStateDisconnected() {
}
conn.statusICE.SetDisconnected()
+ conn.disableWgWatcherIfNeeded()
+
+ if conn.currentConnPriority == conntype.None {
+ conn.metricsStages.Disconnected()
+ }
+
peerState := State{
PubKey: conn.config.Key,
ConnStatus: conn.evalStatus(),
Relayed: conn.isRelayed(),
ConnStatusUpdate: time.Now(),
}
-
- err := conn.statusRecorder.UpdatePeerICEStateToDisconnected(peerState)
- if err != nil {
+ if err := conn.statusRecorder.UpdatePeerICEStateToDisconnected(peerState); err != nil {
conn.Log.Warnf("unable to set peer's state to disconnected ice, got error: %v", err)
}
}
@@ -559,6 +541,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
wgProxy.Work()
presharedKey := conn.presharedKey(rci.rosenpassPubKey)
+ conn.enableWgWatcherIfNeeded()
if err := conn.endpointUpdater.ConfigureWGEndpoint(wgProxy.EndpointAddr(), presharedKey); err != nil {
if err := wgProxy.CloseConn(); err != nil {
conn.Log.Warnf("Failed to close relay connection: %v", err)
@@ -567,13 +550,6 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
return
}
- conn.wgWatcher.DisableWgWatcher()
- conn.wgWatcherWg.Add(1)
- go func() {
- defer conn.wgWatcherWg.Done()
- conn.wgWatcher.EnableWgWatcher(conn.ctx, conn.onWGDisconnected)
- }()
-
wgConfigWorkaround()
conn.rosenpassRemoteKey = rci.rosenpassPubKey
conn.currentConnPriority = conntype.Relay
@@ -587,7 +563,11 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
func (conn *Conn) onRelayDisconnected() {
conn.mu.Lock()
defer conn.mu.Unlock()
+ conn.handleRelayDisconnectedLocked()
+}
+// handleRelayDisconnectedLocked handles relay disconnection. Caller must hold conn.mu.
+func (conn *Conn) handleRelayDisconnectedLocked() {
if conn.ctx.Err() != nil {
return
}
@@ -613,6 +593,12 @@ func (conn *Conn) onRelayDisconnected() {
}
conn.statusRelay.SetDisconnected()
+ conn.disableWgWatcherIfNeeded()
+
+ if conn.currentConnPriority == conntype.None {
+ conn.metricsStages.Disconnected()
+ }
+
peerState := State{
PubKey: conn.config.Key,
ConnStatus: conn.evalStatus(),
@@ -628,15 +614,30 @@ func (conn *Conn) onGuardEvent() {
conn.dumpState.SendOffer()
if err := conn.handshaker.SendOffer(); err != nil {
conn.Log.Errorf("failed to send offer: %v", err)
+ }
+ conn.metricsStages.RecordSignaling()
+}
+
+func (conn *Conn) onWGDisconnected() {
+ conn.mu.Lock()
+ defer conn.mu.Unlock()
+
+ if conn.ctx.Err() != nil {
return
}
- // Record signaling start timestamp (first signal sent)
- conn.stagesMutex.Lock()
- if conn.stageTimestamps.Signaling.IsZero() {
- conn.stageTimestamps.Signaling = time.Now()
+ conn.Log.Warnf("WireGuard handshake timeout detected, closing current connection")
+
+ // Close the active connection based on current priority
+ switch conn.currentConnPriority {
+ case conntype.Relay:
+ conn.workerRelay.CloseConn()
+ conn.handleRelayDisconnectedLocked()
+ case conntype.ICEP2P, conntype.ICETurn:
+ conn.workerICE.Close()
+ default:
+ conn.Log.Debugf("No active connection to close on WG timeout")
}
- conn.stagesMutex.Unlock()
}
func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte) {
@@ -701,19 +702,7 @@ func (conn *Conn) doOnConnected(remoteRosenpassPubKey []byte, remoteRosenpassAdd
runtime.GC()
}
- // Record connection ready timestamp and mark as connected
- conn.stagesMutex.Lock()
- if conn.stageTimestamps.ConnectionReady.IsZero() {
- conn.stageTimestamps.ConnectionReady = time.Now()
- }
- // Mark that we've established a connection
- conn.hasBeenConnected = true
-
- // todo: remove this when fixed the wireguard watcher
- conn.stageTimestamps.WgHandshakeSuccess = time.Now()
- conn.recordConnectionMetrics()
-
- conn.stagesMutex.Unlock()
+ conn.metricsStages.RecordConnectionReady()
if conn.onConnected != nil {
conn.onConnected(conn.config.Key, remoteRosenpassPubKey, conn.config.WgConfig.AllowedIps[0].Addr().String(), remoteRosenpassAddr)
@@ -759,10 +748,17 @@ func (conn *Conn) isConnectedOnAllWay() (connected bool) {
}
}()
- if runtime.GOOS != "js" && conn.statusICE.Get() == worker.StatusDisconnected && !conn.workerICE.InProgress() {
+ // For JS platform: only relay connection is supported
+ if runtime.GOOS == "js" {
+ return conn.statusRelay.Get() == worker.StatusConnected
+ }
+
+ // For non-JS platforms: check ICE connection status
+ if conn.statusICE.Get() == worker.StatusDisconnected && !conn.workerICE.InProgress() {
return false
}
+ // If relay is supported with peer, it must also be connected
if conn.workerRelay.IsRelayConnectionSupportedWithPeer() {
if conn.statusRelay.Get() == worker.StatusDisconnected {
return false
@@ -772,6 +768,26 @@ func (conn *Conn) isConnectedOnAllWay() (connected bool) {
return true
}
+func (conn *Conn) enableWgWatcherIfNeeded() {
+ if !conn.wgWatcher.IsEnabled() {
+ wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
+ conn.wgWatcherCancel = wgWatcherCancel
+ conn.wgWatcherWg.Add(1)
+ now := time.Now()
+ go func() {
+ defer conn.wgWatcherWg.Done()
+ conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, now, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
+ }()
+ }
+}
+
+func (conn *Conn) disableWgWatcherIfNeeded() {
+ if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil {
+ conn.wgWatcherCancel()
+ conn.wgWatcherCancel = nil
+ }
+}
+
func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
conn.Log.Debugf("setup proxied WireGuard connection")
udpAddr := &net.UDPAddr{
@@ -824,29 +840,17 @@ func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy) {
conn.wgProxyRelay = proxy
}
-// onWGDisconnected is called when the WireGuard handshake times out
-func (conn *Conn) onWGDisconnected() {
- conn.workerRelay.CloseConn()
- conn.onRelayDisconnected()
-}
-
// onWGHandshakeSuccess is called when the first WireGuard handshake is detected
-func (conn *Conn) onWGHandshakeSuccess() {
- conn.stagesMutex.Lock()
- defer conn.stagesMutex.Unlock()
-
- /*
- if conn.stageTimestamps.WgHandshakeSuccess.IsZero() {
- conn.stageTimestamps.WgHandshakeSuccess = time.Now()
- conn.recordConnectionMetrics()
- }
-
- */
+func (conn *Conn) onWGHandshakeSuccess(when time.Time) {
+ conn.metricsStages.RecordWGHandshakeSuccess(when)
+ conn.recordConnectionMetrics()
}
// recordConnectionMetrics records connection stage timestamps as metrics
func (conn *Conn) recordConnectionMetrics() {
+ log.Infof("--- record Metrics")
if conn.metricsRecorder == nil {
+ log.Infof("--- is nil")
return
}
@@ -859,12 +863,13 @@ func (conn *Conn) recordConnectionMetrics() {
connType = metrics.ConnectionTypeICE
}
+ log.Infof("-- record: connType: %v, %v, %v", connType, conn.metricsStages.IsReconnection(), conn.metricsStages.GetTimestamps())
// Record metrics with timestamps - duration calculation happens in metrics package
conn.metricsRecorder.RecordConnectionStages(
context.Background(),
connType,
- conn.isReconnectionAttempt,
- conn.stageTimestamps,
+ conn.metricsStages.IsReconnection(),
+ conn.metricsStages.GetTimestamps(),
)
}
@@ -886,10 +891,24 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
return conn.config.WgConfig.PreSharedKey
}
+ // If Rosenpass has already set a PSK for this peer, return nil to prevent
+ // UpdatePeer from overwriting the Rosenpass-managed key.
+ if conn.rosenpassInitializedPresharedKeyValidator != nil && conn.rosenpassInitializedPresharedKeyValidator(conn.config.Key) {
+ return nil
+ }
+
+ // Use NetBird PSK as the seed for Rosenpass. This same PSK is passed to
+ // Rosenpass as PeerConfig.PresharedKey, ensuring the derived post-quantum
+ // key is cryptographically bound to the original secret.
+ if conn.config.WgConfig.PreSharedKey != nil {
+ return conn.config.WgConfig.PreSharedKey
+ }
+
+ // Fallback to deterministic key if no NetBird PSK is configured
determKey, err := conn.rosenpassDetermKey()
if err != nil {
conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err)
- return conn.config.WgConfig.PreSharedKey
+ return nil
}
return determKey
diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go
index 6b47f95eb..32383b530 100644
--- a/client/internal/peer/conn_test.go
+++ b/client/internal/peer/conn_test.go
@@ -284,3 +284,27 @@ func TestConn_presharedKey(t *testing.T) {
})
}
}
+
+func TestConn_presharedKey_RosenpassManaged(t *testing.T) {
+ conn := Conn{
+ config: ConnConfig{
+ Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ RosenpassConfig: RosenpassConfig{PubKey: []byte("dummykey")},
+ },
+ }
+
+ // When Rosenpass has already initialized the PSK for this peer,
+ // presharedKey must return nil to avoid UpdatePeer overwriting it.
+ conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return true }
+ if k := conn.presharedKey([]byte("remote")); k != nil {
+ t.Fatalf("expected nil presharedKey when Rosenpass manages PSK, got %v", k)
+ }
+
+ // When Rosenpass hasn't taken over yet, presharedKey should provide
+ // a non-nil initial key (deterministic or from NetBird PSK).
+ conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return false }
+ if k := conn.presharedKey([]byte("remote")); k == nil {
+ t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK")
+ }
+}
diff --git a/client/internal/peer/metrics_saver.go b/client/internal/peer/metrics_saver.go
new file mode 100644
index 000000000..6cf00d4ff
--- /dev/null
+++ b/client/internal/peer/metrics_saver.go
@@ -0,0 +1,88 @@
+package peer
+
+import (
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "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
+}
+
+func (s *MetricsStages) RecordCreated() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ log.Infof("--- RecordCreated")
+ s.stageTimestamps.Created = time.Now()
+}
+
+func (s *MetricsStages) RecordSemaphoreAcquired() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ log.Infof("--- RecordSemaphoreAcquired")
+ s.stageTimestamps.SemaphoreAcquired = time.Now()
+}
+
+func (s *MetricsStages) RecordSignaling() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ log.Infof("--- RecordSignaling")
+
+ if s.stageTimestamps.Signaling.IsZero() {
+ log.Infof("--- Recorded Signaling")
+ s.stageTimestamps.Signaling = time.Now()
+ }
+}
+
+func (s *MetricsStages) RecordConnectionReady() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ log.Infof("--- RecordConnectionReady")
+ if s.stageTimestamps.ConnectionReady.IsZero() {
+ log.Infof("--- Recorded ConnectionReady")
+ s.stageTimestamps.ConnectionReady = time.Now()
+ }
+
+}
+
+func (s *MetricsStages) RecordWGHandshakeSuccess(elapsed time.Time) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ log.Infof("--- record: %v, %v", s.stageTimestamps.ConnectionReady, elapsed)
+ if !s.stageTimestamps.ConnectionReady.IsZero() {
+ // todo, check if it is earlier then ConnectionReady
+ s.stageTimestamps.WgHandshakeSuccess = elapsed
+ }
+}
+
+func (s *MetricsStages) Disconnected() {
+ log.Infof("--- Disconnected")
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ now := time.Now()
+ s.stageTimestamps = metrics.ConnectionStageTimestamps{
+ Created: now,
+ SemaphoreAcquired: now,
+ }
+ 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/status.go b/client/internal/peer/status.go
index 76f4f523c..697bda2ff 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -14,6 +14,7 @@ import (
"golang.org/x/exp/maps"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
@@ -158,6 +159,7 @@ type FullStatus struct {
NSGroupStates []NSGroupState
NumOfForwardingRules int
LazyConnectionEnabled bool
+ Events []*proto.SystemEvent
}
type StatusChangeSubscription struct {
@@ -981,6 +983,7 @@ func (d *Status) GetFullStatus() FullStatus {
}
fullStatus.Peers = append(fullStatus.Peers, d.offlinePeers...)
+ fullStatus.Events = d.GetEventHistory()
return fullStatus
}
@@ -1181,3 +1184,97 @@ type EventSubscription struct {
func (s *EventSubscription) Events() <-chan *proto.SystemEvent {
return s.events
}
+
+// ToProto converts FullStatus to proto.FullStatus.
+func (fs FullStatus) ToProto() *proto.FullStatus {
+ pbFullStatus := proto.FullStatus{
+ ManagementState: &proto.ManagementState{},
+ SignalState: &proto.SignalState{},
+ LocalPeerState: &proto.LocalPeerState{},
+ Peers: []*proto.PeerState{},
+ }
+
+ pbFullStatus.ManagementState.URL = fs.ManagementState.URL
+ pbFullStatus.ManagementState.Connected = fs.ManagementState.Connected
+ if err := fs.ManagementState.Error; err != nil {
+ pbFullStatus.ManagementState.Error = err.Error()
+ }
+
+ pbFullStatus.SignalState.URL = fs.SignalState.URL
+ pbFullStatus.SignalState.Connected = fs.SignalState.Connected
+ if err := fs.SignalState.Error; err != nil {
+ pbFullStatus.SignalState.Error = err.Error()
+ }
+
+ pbFullStatus.LocalPeerState.IP = fs.LocalPeerState.IP
+ pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
+ pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
+ pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
+ pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
+ pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
+ pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
+ pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled
+
+ pbFullStatus.LocalPeerState.Networks = maps.Keys(fs.LocalPeerState.Routes)
+
+ for _, peerState := range fs.Peers {
+ networks := maps.Keys(peerState.GetRoutes())
+
+ pbPeerState := &proto.PeerState{
+ IP: peerState.IP,
+ PubKey: peerState.PubKey,
+ ConnStatus: peerState.ConnStatus.String(),
+ ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate),
+ Relayed: peerState.Relayed,
+ LocalIceCandidateType: peerState.LocalIceCandidateType,
+ RemoteIceCandidateType: peerState.RemoteIceCandidateType,
+ LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint,
+ RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint,
+ RelayAddress: peerState.RelayServerAddress,
+ Fqdn: peerState.FQDN,
+ LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake),
+ BytesRx: peerState.BytesRx,
+ BytesTx: peerState.BytesTx,
+ RosenpassEnabled: peerState.RosenpassEnabled,
+ Networks: networks,
+ Latency: durationpb.New(peerState.Latency),
+ SshHostKey: peerState.SSHHostKey,
+ }
+ pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState)
+ }
+
+ for _, relayState := range fs.Relays {
+ pbRelayState := &proto.RelayState{
+ URI: relayState.URI,
+ Available: relayState.Err == nil,
+ }
+ if err := relayState.Err; err != nil {
+ pbRelayState.Error = err.Error()
+ }
+ pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState)
+ }
+
+ for _, dnsState := range fs.NSGroupStates {
+ var err string
+ if dnsState.Error != nil {
+ err = dnsState.Error.Error()
+ }
+
+ var servers []string
+ for _, server := range dnsState.Servers {
+ servers = append(servers, server.String())
+ }
+
+ pbDnsState := &proto.NSGroupState{
+ Servers: servers,
+ Domains: dnsState.Domains,
+ Enabled: dnsState.Enabled,
+ Error: err,
+ }
+ pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
+ }
+
+ pbFullStatus.Events = fs.Events
+
+ return &pbFullStatus
+}
diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go
index 39241d596..94df5c8d2 100644
--- a/client/internal/peer/wg_watcher.go
+++ b/client/internal/peer/wg_watcher.go
@@ -30,71 +30,57 @@ type WGWatcher struct {
peerKey string
stateDump *stateDump
- ctx context.Context
- ctxCancel context.CancelFunc
- ctxLock sync.Mutex
- enabledTime time.Time
-
- onFirstHandshakeFn func()
+ enabled bool
+ muEnabled sync.RWMutex
}
-func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey string, stateDump *stateDump, onFirstHandshakeFn func()) *WGWatcher {
+func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey string, stateDump *stateDump) *WGWatcher {
return &WGWatcher{
- log: log,
- wgIfaceStater: wgIfaceStater,
- peerKey: peerKey,
- stateDump: stateDump,
- onFirstHandshakeFn: onFirstHandshakeFn,
+ log: log,
+ wgIfaceStater: wgIfaceStater,
+ peerKey: peerKey,
+ stateDump: stateDump,
}
}
// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing.
-func (w *WGWatcher) EnableWgWatcher(parentCtx context.Context, onDisconnectedFn func()) {
- w.log.Debugf("enable WireGuard watcher")
- w.ctxLock.Lock()
- w.enabledTime = time.Now()
-
- if w.ctx != nil && w.ctx.Err() == nil {
- w.log.Errorf("WireGuard watcher already enabled")
- w.ctxLock.Unlock()
+// The watcher runs until ctx is cancelled. Caller is responsible for context lifecycle management.
+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()
return
}
- ctx, ctxCancel := context.WithCancel(parentCtx)
- w.ctx = ctx
- w.ctxCancel = ctxCancel
- w.ctxLock.Unlock()
+ w.log.Debugf("enable WireGuard watcher")
+ w.enabled = true
+ w.muEnabled.Unlock()
initialHandshake, err := w.wgState()
if err != nil {
w.log.Warnf("failed to read initial wg stats: %v", err)
}
- w.periodicHandshakeCheck(ctx, ctxCancel, onDisconnectedFn, initialHandshake)
+ w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake)
+
+ w.muEnabled.Lock()
+ w.enabled = false
+ w.muEnabled.Unlock()
}
-// DisableWgWatcher stops the WireGuard watcher and wait for the watcher to exit
-func (w *WGWatcher) DisableWgWatcher() {
- w.ctxLock.Lock()
- defer w.ctxLock.Unlock()
-
- if w.ctxCancel == nil {
- return
- }
-
- w.log.Debugf("disable WireGuard watcher")
-
- w.ctxCancel()
- w.ctxCancel = nil
+// IsEnabled returns true if the WireGuard watcher is currently enabled
+func (w *WGWatcher) IsEnabled() bool {
+ w.muEnabled.RLock()
+ defer w.muEnabled.RUnlock()
+ return w.enabled
}
// wgStateCheck help to check the state of the WireGuard handshake and relay connection
-func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, ctxCancel context.CancelFunc, onDisconnectedFn func(), 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)
defer timer.Stop()
- defer ctxCancel()
lastHandshake := initialHandshake
@@ -103,17 +89,13 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, ctxCancel contex
case <-timer.C:
handshake, ok := w.handshakeCheck(lastHandshake)
if !ok {
- if onDisconnectedFn != nil {
- onDisconnectedFn()
- }
+ onDisconnectedFn()
return
}
if lastHandshake.IsZero() {
- elapsed := w.calcElapsed(handshake)
- w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake)
- if w.onFirstHandshakeFn != nil {
- w.onFirstHandshakeFn()
- }
+ elapsed := calcElapsed(enabledTime, *handshake)
+ w.log.Infof("--- first wg handshake detected within: %.2fsec, (%s - %s)", elapsed, enabledTime, handshake)
+ onHandshakeSuccessFn(*handshake)
}
lastHandshake = *handshake
@@ -175,8 +157,8 @@ func (w *WGWatcher) wgState() (time.Time, error) {
// calcElapsed calculates elapsed time since watcher was enabled.
// The watcher started after the wg configuration happens, because of this need to normalise the negative value
-func (w *WGWatcher) calcElapsed(handshake *time.Time) float64 {
- elapsed := handshake.Sub(w.enabledTime).Seconds()
+func calcElapsed(enabledTime, handshake time.Time) float64 {
+ elapsed := handshake.Sub(enabledTime).Seconds()
if elapsed < 0 {
elapsed = 0
}
diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go
index 3442fa786..e73d35668 100644
--- a/client/internal/peer/wg_watcher_test.go
+++ b/client/internal/peer/wg_watcher_test.go
@@ -2,6 +2,7 @@ package peer
import (
"context"
+ "sync"
"testing"
"time"
@@ -28,15 +29,17 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
mlog := log.WithField("peer", "tet")
mocWgIface := &MocWgIface{}
- watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}), nil)
+ watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
onDisconnected := make(chan struct{}, 1)
- go watcher.EnableWgWatcher(ctx, func() {
+ go watcher.EnableWgWatcher(ctx,, func() {
mlog.Infof("onDisconnectedFn")
onDisconnected <- struct{}{}
+ }, func(elapsed float64) {
+ mlog.Infof("onHandshakeSuccess: %.3fs", elapsed)
})
// wait for initial reading
@@ -48,7 +51,6 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
case <-time.After(10 * time.Second):
t.Errorf("timeout")
}
- watcher.DisableWgWatcher()
}
func TestWGWatcher_ReEnable(t *testing.T) {
@@ -57,20 +59,27 @@ func TestWGWatcher_ReEnable(t *testing.T) {
mlog := log.WithField("peer", "tet")
mocWgIface := &MocWgIface{}
- watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}), nil)
+ watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
ctx, cancel := context.WithCancel(context.Background())
+ wg := &sync.WaitGroup{}
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ watcher.EnableWgWatcher(ctx, func() {}, func(elapsed float64) {})
+ }()
+ cancel()
+
+ wg.Wait()
+
+ // Re-enable with a new context
+ ctx, cancel = context.WithCancel(context.Background())
defer cancel()
onDisconnected := make(chan struct{}, 1)
-
- go watcher.EnableWgWatcher(ctx, func() {})
- time.Sleep(1 * time.Second)
- watcher.DisableWgWatcher()
-
- go watcher.EnableWgWatcher(ctx, func() {
+ go watcher.EnableWgWatcher(ctx,, func() {
onDisconnected <- struct{}{}
- })
+ }, func(elapsed float64) {})
time.Sleep(2 * time.Second)
mocWgIface.disconnect()
@@ -80,5 +89,4 @@ func TestWGWatcher_ReEnable(t *testing.T) {
case <-time.After(10 * time.Second):
t.Errorf("timeout")
}
- watcher.DisableWgWatcher()
}
diff --git a/client/internal/peer/worker_relay.go b/client/internal/peer/worker_relay.go
index a9fb72d2b..06309fbaf 100644
--- a/client/internal/peer/worker_relay.go
+++ b/client/internal/peer/worker_relay.go
@@ -32,7 +32,7 @@ type WorkerRelay struct {
relaySupportedOnRemotePeer atomic.Bool
}
-func NewWorkerRelay(ctx context.Context, log *log.Entry, ctrl bool, config ConnConfig, conn *Conn, relayManager *relayClient.Manager, stateDump *stateDump) *WorkerRelay {
+func NewWorkerRelay(ctx context.Context, log *log.Entry, ctrl bool, config ConnConfig, conn *Conn, relayManager *relayClient.Manager) *WorkerRelay {
r := &WorkerRelay{
peerCtx: ctx,
log: log,
diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go
index d2d7408fd..26a1eef58 100644
--- a/client/internal/rosenpass/manager.go
+++ b/client/internal/rosenpass/manager.go
@@ -34,6 +34,7 @@ type Manager struct {
server *rp.Server
lock sync.Mutex
port int
+ wgIface PresharedKeySetter
}
// NewManager creates a new Rosenpass manager
@@ -109,7 +110,13 @@ func (m *Manager) generateConfig() (rp.Config, error) {
cfg.SecretKey = m.ssk
cfg.Peers = []rp.PeerConfig{}
- m.rpWgHandler, _ = NewNetbirdHandler(m.preSharedKey, m.ifaceName)
+
+ m.lock.Lock()
+ m.rpWgHandler = NewNetbirdHandler()
+ if m.wgIface != nil {
+ m.rpWgHandler.SetInterface(m.wgIface)
+ }
+ m.lock.Unlock()
cfg.Handlers = []rp.Handler{m.rpWgHandler}
@@ -172,6 +179,20 @@ func (m *Manager) Close() error {
return nil
}
+// SetInterface sets the WireGuard interface for the rosenpass handler.
+// This can be called before or after Run() - the interface will be stored
+// and passed to the handler when it's created or updated immediately if
+// already running.
+func (m *Manager) SetInterface(iface PresharedKeySetter) {
+ m.lock.Lock()
+ defer m.lock.Unlock()
+
+ m.wgIface = iface
+ if m.rpWgHandler != nil {
+ m.rpWgHandler.SetInterface(iface)
+ }
+}
+
// OnConnected is a handler function that is triggered when a connection to a remote peer establishes
func (m *Manager) OnConnected(remoteWireGuardKey string, remoteRosenpassPubKey []byte, wireGuardIP string, remoteRosenpassAddr string) {
m.lock.Lock()
@@ -192,6 +213,20 @@ func (m *Manager) OnConnected(remoteWireGuardKey string, remoteRosenpassPubKey [
}
}
+// IsPresharedKeyInitialized returns true if Rosenpass has completed a handshake
+// and set a PSK for the given WireGuard peer.
+func (m *Manager) IsPresharedKeyInitialized(wireGuardPubKey string) bool {
+ m.lock.Lock()
+ defer m.lock.Unlock()
+
+ peerID, ok := m.rpPeerIDs[wireGuardPubKey]
+ if !ok || peerID == nil {
+ return false
+ }
+
+ return m.rpWgHandler.IsPeerInitialized(*peerID)
+}
+
func findRandomAvailableUDPPort() (int, error) {
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
diff --git a/client/internal/rosenpass/netbird_handler.go b/client/internal/rosenpass/netbird_handler.go
index 345f95c01..9de2409ef 100644
--- a/client/internal/rosenpass/netbird_handler.go
+++ b/client/internal/rosenpass/netbird_handler.go
@@ -1,46 +1,50 @@
package rosenpass
import (
- "fmt"
- "log/slog"
+ "sync"
rp "cunicu.li/go-rosenpass"
log "github.com/sirupsen/logrus"
- "golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
+// PresharedKeySetter is the interface for setting preshared keys on WireGuard peers.
+// This minimal interface allows rosenpass to update PSKs without depending on the full WGIface.
+type PresharedKeySetter interface {
+ SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
+}
+
type wireGuardPeer struct {
Interface string
PublicKey rp.Key
}
type NetbirdHandler struct {
- ifaceName string
- client *wgctrl.Client
- peers map[rp.PeerID]wireGuardPeer
- presharedKey [32]byte
+ mu sync.Mutex
+ iface PresharedKeySetter
+ peers map[rp.PeerID]wireGuardPeer
+ initializedPeers map[rp.PeerID]bool
}
-func NewNetbirdHandler(preSharedKey *[32]byte, wgIfaceName string) (hdlr *NetbirdHandler, err error) {
- hdlr = &NetbirdHandler{
- ifaceName: wgIfaceName,
- peers: map[rp.PeerID]wireGuardPeer{},
+func NewNetbirdHandler() *NetbirdHandler {
+ return &NetbirdHandler{
+ peers: map[rp.PeerID]wireGuardPeer{},
+ initializedPeers: map[rp.PeerID]bool{},
}
+}
- if preSharedKey != nil {
- hdlr.presharedKey = *preSharedKey
- }
-
- if hdlr.client, err = wgctrl.New(); err != nil {
- return nil, fmt.Errorf("failed to creat WireGuard client: %w", err)
- }
-
- return hdlr, nil
+// SetInterface sets the WireGuard interface for the handler.
+// This must be called after the WireGuard interface is created.
+func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.iface = iface
}
func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.peers[pid] = wireGuardPeer{
Interface: intf,
PublicKey: pk,
@@ -48,79 +52,61 @@ func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) {
}
func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
delete(h.peers, pid)
+ delete(h.initializedPeers, pid)
+}
+
+// IsPeerInitialized returns true if Rosenpass has completed a handshake
+// and set a PSK for this peer.
+func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.initializedPeers[pid]
}
func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) {
- log.Debug("Handshake complete")
h.outputKey(rp.KeyOutputReasonStale, pid, key)
}
func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
key, _ := rp.GeneratePresharedKey()
- log.Debug("Handshake expired")
h.outputKey(rp.KeyOutputReasonStale, pid, key)
}
func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) {
+ h.mu.Lock()
+ iface := h.iface
wg, ok := h.peers[pid]
+ isInitialized := h.initializedPeers[pid]
+ h.mu.Unlock()
+
+ if iface == nil {
+ log.Warn("rosenpass: interface not set, cannot update preshared key")
+ return
+ }
+
if !ok {
return
}
- device, err := h.client.Device(h.ifaceName)
- if err != nil {
- log.Errorf("Failed to get WireGuard device: %v", err)
+ peerKey := wgtypes.Key(wg.PublicKey).String()
+ pskKey := wgtypes.Key(psk)
+
+ // Use updateOnly=true for later rotations (peer already has Rosenpass PSK)
+ // Use updateOnly=false for first rotation (peer has original/empty PSK)
+ if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil {
+ log.Errorf("Failed to apply rosenpass key: %v", err)
return
}
- config := []wgtypes.PeerConfig{
- {
- UpdateOnly: true,
- PublicKey: wgtypes.Key(wg.PublicKey),
- PresharedKey: (*wgtypes.Key)(&psk),
- },
- }
- for _, peer := range device.Peers {
- if peer.PublicKey == wgtypes.Key(wg.PublicKey) {
- if publicKeyEmpty(peer.PresharedKey) || peer.PresharedKey == h.presharedKey {
- log.Debugf("Restart wireguard connection to peer %s", peer.PublicKey)
- config = []wgtypes.PeerConfig{
- {
- PublicKey: wgtypes.Key(wg.PublicKey),
- PresharedKey: (*wgtypes.Key)(&psk),
- Endpoint: peer.Endpoint,
- AllowedIPs: peer.AllowedIPs,
- },
- }
- err = h.client.ConfigureDevice(wg.Interface, wgtypes.Config{
- Peers: []wgtypes.PeerConfig{
- {
- Remove: true,
- PublicKey: wgtypes.Key(wg.PublicKey),
- },
- },
- })
- if err != nil {
- slog.Debug("Failed to remove peer")
- return
- }
- }
+ // Mark peer as isInitialized after the successful first rotation
+ if !isInitialized {
+ h.mu.Lock()
+ if _, exists := h.peers[pid]; exists {
+ h.initializedPeers[pid] = true
}
- }
-
- if err = h.client.ConfigureDevice(wg.Interface, wgtypes.Config{
- Peers: config,
- }); err != nil {
- log.Errorf("Failed to apply rosenpass key: %v", err)
+ h.mu.Unlock()
}
}
-
-func publicKeyEmpty(key wgtypes.Key) bool {
- for _, b := range key {
- if b != 0 {
- return false
- }
- }
- return true
-}
diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go
index 348338dac..12c9ff4af 100644
--- a/client/internal/routemanager/dnsinterceptor/handler.go
+++ b/client/internal/routemanager/dnsinterceptor/handler.go
@@ -17,12 +17,13 @@ import (
nberrors "github.com/netbirdio/netbird/client/errors"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
- "github.com/netbirdio/netbird/client/iface/wgaddr"
nbdns "github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/dns/resutil"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
"github.com/netbirdio/netbird/client/internal/routemanager/common"
"github.com/netbirdio/netbird/client/internal/routemanager/fakeip"
+ iface "github.com/netbirdio/netbird/client/internal/routemanager/iface"
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -37,11 +38,6 @@ type internalDNATer interface {
AddInternalDNATMapping(netip.Addr, netip.Addr) error
}
-type wgInterface interface {
- Name() string
- Address() wgaddr.Address
-}
-
type DnsInterceptor struct {
mu sync.RWMutex
route *route.Route
@@ -51,7 +47,7 @@ type DnsInterceptor struct {
dnsServer nbdns.Server
currentPeerKey string
interceptedDomains domainMap
- wgInterface wgInterface
+ wgInterface iface.WGIface
peerStore *peerstore.Store
firewall firewall.Manager
fakeIPManager *fakeip.Manager
@@ -219,14 +215,14 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error {
// ServeDNS implements the dns.Handler interface
func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
- requestID := nbdns.GenerateRequestID()
- logger := log.WithField("request_id", requestID)
+ logger := log.WithFields(log.Fields{
+ "request_id": resutil.GetRequestID(w),
+ "dns_id": fmt.Sprintf("%04x", r.Id),
+ })
if len(r.Question) == 0 {
return
}
- logger.Tracef("received DNS request for domain=%s type=%v class=%v",
- r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass)
// pass if non A/AAAA query
if r.Question[0].Qtype != dns.TypeA && r.Question[0].Qtype != dns.TypeAAAA {
@@ -249,12 +245,6 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
return
}
- client, err := nbdns.GetClientPrivate(d.wgInterface.Address().IP, d.wgInterface.Name(), dnsTimeout)
- if err != nil {
- d.writeDNSError(w, r, logger, fmt.Sprintf("create DNS client: %v", err))
- return
- }
-
if r.Extra == nil {
r.MsgHdr.AuthenticatedData = true
}
@@ -263,32 +253,15 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout)
defer cancel()
- startTime := time.Now()
- reply, _, err := nbdns.ExchangeWithFallback(ctx, client, r, upstream)
- if err != nil {
- if errors.Is(err, context.DeadlineExceeded) {
- elapsed := time.Since(startTime)
- peerInfo := d.debugPeerTimeout(upstreamIP, peerKey)
- logger.Errorf("peer DNS timeout after %v (timeout=%v) for domain=%s to peer %s (%s)%s - error: %v",
- elapsed.Truncate(time.Millisecond), dnsTimeout, r.Question[0].Name, upstreamIP.String(), peerKey, peerInfo, err)
- } else {
- logger.Errorf("failed to exchange DNS request with %s (%s) for domain=%s: %v", upstreamIP.String(), peerKey, r.Question[0].Name, err)
- }
- if err := w.WriteMsg(&dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeServerFailure, Id: r.Id}}); err != nil {
- logger.Errorf("failed writing DNS response: %v", err)
- }
+ reply := d.queryUpstreamDNS(ctx, w, r, upstream, upstreamIP, peerKey, logger)
+ if reply == nil {
return
}
- var answer []dns.RR
- if reply != nil {
- answer = reply.Answer
- }
-
- logger.Tracef("upstream %s (%s) DNS response for domain=%s answers=%v", upstreamIP.String(), peerKey, r.Question[0].Name, answer)
+ resutil.SetMeta(w, "peer", peerKey)
reply.Id = r.Id
- if err := d.writeMsg(w, reply); err != nil {
+ if err := d.writeMsg(w, reply, logger); err != nil {
logger.Errorf("failed writing DNS response: %v", err)
}
}
@@ -324,11 +297,15 @@ func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) {
return peerAllowedIP, nil
}
-func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error {
+func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry) error {
if r == nil {
return fmt.Errorf("received nil DNS message")
}
+ // Clear Zero bit from peer responses to prevent external sources from
+ // manipulating our internal fallthrough signaling mechanism
+ r.MsgHdr.Zero = false
+
if len(r.Answer) > 0 && len(r.Question) > 0 {
origPattern := ""
if writer, ok := w.(*nbdns.ResponseWriterChain); ok {
@@ -350,14 +327,14 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error {
case *dns.A:
addr, ok := netip.AddrFromSlice(rr.A)
if !ok {
- log.Tracef("failed to convert A record for domain=%s ip=%v", resolvedDomain, rr.A)
+ logger.Tracef("failed to convert A record for domain=%s ip=%v", resolvedDomain, rr.A)
continue
}
ip = addr
case *dns.AAAA:
addr, ok := netip.AddrFromSlice(rr.AAAA)
if !ok {
- log.Tracef("failed to convert AAAA record for domain=%s ip=%v", resolvedDomain, rr.AAAA)
+ logger.Tracef("failed to convert AAAA record for domain=%s ip=%v", resolvedDomain, rr.AAAA)
continue
}
ip = addr
@@ -370,11 +347,11 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error {
}
if len(newPrefixes) > 0 {
- if err := d.updateDomainPrefixes(resolvedDomain, originalDomain, newPrefixes); err != nil {
- log.Errorf("failed to update domain prefixes: %v", err)
+ if err := d.updateDomainPrefixes(resolvedDomain, originalDomain, newPrefixes, logger); err != nil {
+ logger.Errorf("failed to update domain prefixes: %v", err)
}
- d.replaceIPsInDNSResponse(r, newPrefixes)
+ d.replaceIPsInDNSResponse(r, newPrefixes, logger)
}
}
@@ -386,22 +363,22 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error {
}
// logPrefixChanges handles the logging for prefix changes
-func (d *DnsInterceptor) logPrefixChanges(resolvedDomain, originalDomain domain.Domain, toAdd, toRemove []netip.Prefix) {
+func (d *DnsInterceptor) logPrefixChanges(resolvedDomain, originalDomain domain.Domain, toAdd, toRemove []netip.Prefix, logger *log.Entry) {
if len(toAdd) > 0 {
- log.Debugf("added dynamic route(s) for domain=%s (pattern: domain=%s): %s",
+ logger.Debugf("added dynamic route(s) for domain=%s (pattern: domain=%s): %s",
resolvedDomain.SafeString(),
originalDomain.SafeString(),
toAdd)
}
if len(toRemove) > 0 && !d.route.KeepRoute {
- log.Debugf("removed dynamic route(s) for domain=%s (pattern: domain=%s): %s",
+ logger.Debugf("removed dynamic route(s) for domain=%s (pattern: domain=%s): %s",
resolvedDomain.SafeString(),
originalDomain.SafeString(),
toRemove)
}
}
-func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain domain.Domain, newPrefixes []netip.Prefix) error {
+func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain domain.Domain, newPrefixes []netip.Prefix, logger *log.Entry) error {
d.mu.Lock()
defer d.mu.Unlock()
@@ -418,9 +395,9 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom
realIP := prefix.Addr()
if fakeIP, err := d.fakeIPManager.AllocateFakeIP(realIP); err == nil {
dnatMappings[fakeIP] = realIP
- log.Tracef("allocated fake IP %s for real IP %s", fakeIP, realIP)
+ logger.Tracef("allocated fake IP %s for real IP %s", fakeIP, realIP)
} else {
- log.Errorf("Failed to allocate fake IP for %s: %v", realIP, err)
+ logger.Errorf("failed to allocate fake IP for %s: %v", realIP, err)
}
}
}
@@ -432,7 +409,7 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom
}
}
- d.addDNATMappings(dnatMappings)
+ d.addDNATMappings(dnatMappings, logger)
if !d.route.KeepRoute {
// Remove old prefixes
@@ -448,7 +425,7 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom
}
}
- d.removeDNATMappings(toRemove)
+ d.removeDNATMappings(toRemove, logger)
}
// Update domain prefixes using resolved domain as key - store real IPs
@@ -463,14 +440,14 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom
// Store real IPs for status (user-facing), not fake IPs
d.statusRecorder.UpdateResolvedDomainsStates(originalDomain, resolvedDomain, newPrefixes, d.route.GetResourceID())
- d.logPrefixChanges(resolvedDomain, originalDomain, toAdd, toRemove)
+ d.logPrefixChanges(resolvedDomain, originalDomain, toAdd, toRemove, logger)
}
return nberrors.FormatErrorOrNil(merr)
}
// removeDNATMappings removes DNAT mappings from the firewall for real IP prefixes
-func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix) {
+func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger *log.Entry) {
if len(realPrefixes) == 0 {
return
}
@@ -484,9 +461,9 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix) {
realIP := prefix.Addr()
if fakeIP, exists := d.fakeIPManager.GetFakeIP(realIP); exists {
if err := dnatFirewall.RemoveInternalDNATMapping(fakeIP); err != nil {
- log.Errorf("Failed to remove DNAT mapping for %s: %v", fakeIP, err)
+ logger.Errorf("failed to remove DNAT mapping for %s: %v", fakeIP, err)
} else {
- log.Debugf("Removed DNAT mapping for: %s -> %s", fakeIP, realIP)
+ logger.Debugf("removed DNAT mapping: %s -> %s", fakeIP, realIP)
}
}
}
@@ -502,7 +479,7 @@ func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
}
// addDNATMappings adds DNAT mappings to the firewall
-func (d *DnsInterceptor) addDNATMappings(mappings map[netip.Addr]netip.Addr) {
+func (d *DnsInterceptor) addDNATMappings(mappings map[netip.Addr]netip.Addr, logger *log.Entry) {
if len(mappings) == 0 {
return
}
@@ -514,9 +491,9 @@ func (d *DnsInterceptor) addDNATMappings(mappings map[netip.Addr]netip.Addr) {
for fakeIP, realIP := range mappings {
if err := dnatFirewall.AddInternalDNATMapping(fakeIP, realIP); err != nil {
- log.Errorf("Failed to add DNAT mapping %s -> %s: %v", fakeIP, realIP, err)
+ logger.Errorf("failed to add DNAT mapping %s -> %s: %v", fakeIP, realIP, err)
} else {
- log.Debugf("Added DNAT mapping: %s -> %s", fakeIP, realIP)
+ logger.Debugf("added DNAT mapping: %s -> %s", fakeIP, realIP)
}
}
}
@@ -528,12 +505,12 @@ func (d *DnsInterceptor) cleanupDNATMappings() {
}
for _, prefixes := range d.interceptedDomains {
- d.removeDNATMappings(prefixes)
+ d.removeDNATMappings(prefixes, log.NewEntry(log.StandardLogger()))
}
}
// replaceIPsInDNSResponse replaces real IPs with fake IPs in the DNS response
-func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []netip.Prefix) {
+func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []netip.Prefix, logger *log.Entry) {
if _, ok := d.internalDnatFw(); !ok {
return
}
@@ -549,7 +526,7 @@ func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []
if fakeIP, exists := d.fakeIPManager.GetFakeIP(realIP); exists {
rr.A = fakeIP.AsSlice()
- log.Tracef("Replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP)
+ logger.Tracef("replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP)
}
case *dns.AAAA:
@@ -560,7 +537,7 @@ func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []
if fakeIP, exists := d.fakeIPManager.GetFakeIP(realIP); exists {
rr.AAAA = fakeIP.AsSlice()
- log.Tracef("Replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP)
+ logger.Tracef("replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP)
}
}
}
@@ -586,6 +563,44 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
return
}
+// queryUpstreamDNS queries the upstream DNS server using netstack if available, otherwise uses regular client.
+// Returns the DNS reply on success, or nil on error (error responses are written internally).
+func (d *DnsInterceptor) queryUpstreamDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, upstream string, upstreamIP netip.Addr, peerKey string, logger *log.Entry) *dns.Msg {
+ startTime := time.Now()
+
+ nsNet := d.wgInterface.GetNet()
+ var reply *dns.Msg
+ var err error
+
+ if nsNet != nil {
+ reply, err = nbdns.ExchangeWithNetstack(ctx, nsNet, r, upstream)
+ } else {
+ client, clientErr := nbdns.GetClientPrivate(d.wgInterface.Address().IP, d.wgInterface.Name(), dnsTimeout)
+ if clientErr != nil {
+ d.writeDNSError(w, r, logger, fmt.Sprintf("create DNS client: %v", clientErr))
+ return nil
+ }
+ reply, _, err = nbdns.ExchangeWithFallback(ctx, client, r, upstream)
+ }
+
+ if err == nil {
+ return reply
+ }
+
+ if errors.Is(err, context.DeadlineExceeded) {
+ elapsed := time.Since(startTime)
+ peerInfo := d.debugPeerTimeout(upstreamIP, peerKey)
+ logger.Errorf("peer DNS timeout after %v (timeout=%v) for domain=%s to peer %s (%s)%s - error: %v",
+ elapsed.Truncate(time.Millisecond), dnsTimeout, r.Question[0].Name, upstreamIP.String(), peerKey, peerInfo, err)
+ } else {
+ logger.Errorf("failed to exchange DNS request with %s (%s) for domain=%s: %v", upstreamIP.String(), peerKey, r.Question[0].Name, err)
+ }
+ if err := w.WriteMsg(&dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeServerFailure, Id: r.Id}}); err != nil {
+ logger.Errorf("failed writing DNS response: %v", err)
+ }
+ return nil
+}
+
func (d *DnsInterceptor) debugPeerTimeout(peerIP netip.Addr, peerKey string) string {
if d.statusRecorder == nil {
return ""
diff --git a/client/internal/routemanager/iface/iface_common.go b/client/internal/routemanager/iface/iface_common.go
index f844f4bed..9b7bce751 100644
--- a/client/internal/routemanager/iface/iface_common.go
+++ b/client/internal/routemanager/iface/iface_common.go
@@ -4,6 +4,8 @@ import (
"net"
"net/netip"
+ "golang.zx2c4.com/wireguard/tun/netstack"
+
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
@@ -18,4 +20,5 @@ type wgIfaceBase interface {
IsUserspaceBind() bool
GetFilter() device.PacketFilter
GetDevice() *device.FilteredDevice
+ GetNet() *netstack.Net
}
diff --git a/client/jobexec/executor.go b/client/jobexec/executor.go
new file mode 100644
index 000000000..e29cc8840
--- /dev/null
+++ b/client/jobexec/executor.go
@@ -0,0 +1,76 @@
+package jobexec
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/debug"
+ "github.com/netbirdio/netbird/upload-server/types"
+)
+
+const (
+ MaxBundleWaitTime = 60 * time.Minute // maximum wait time for bundle generation (1 hour)
+)
+
+var (
+ ErrJobNotImplemented = errors.New("job not implemented")
+)
+
+type Executor struct {
+}
+
+func NewExecutor() *Executor {
+ return &Executor{}
+}
+
+func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL string) (string, error) {
+ if waitForDuration > MaxBundleWaitTime {
+ log.Warnf("bundle wait time %v exceeds maximum %v, capping to maximum", waitForDuration, MaxBundleWaitTime)
+ waitForDuration = MaxBundleWaitTime
+ }
+
+ if waitForDuration > 0 {
+ if err := waitFor(ctx, waitForDuration); err != nil {
+ return "", err
+ }
+ }
+
+ log.Infof("execute debug bundle generation")
+
+ bundleGenerator := debug.NewBundleGenerator(debugBundleDependencies, params)
+
+ path, err := bundleGenerator.Generate()
+ if err != nil {
+ return "", fmt.Errorf("generate debug bundle: %w", err)
+ }
+ defer func() {
+ if err := os.Remove(path); err != nil {
+ log.Errorf("failed to remove debug bundle file: %v", err)
+ }
+ }()
+
+ key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path)
+ if err != nil {
+ log.Errorf("failed to upload debug bundle: %v", err)
+ return "", fmt.Errorf("upload debug bundle: %w", err)
+ }
+
+ log.Infof("debug bundle has been generated successfully")
+ return key, nil
+}
+
+func waitFor(ctx context.Context, duration time.Duration) error {
+ log.Infof("wait for %v minutes before executing debug bundle", duration.Minutes())
+ select {
+ case <-time.After(duration):
+ return nil
+ case <-ctx.Done():
+ log.Infof("wait cancelled: %v", ctx.Err())
+ return ctx.Err()
+ }
+}
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 5d56befc7..9cbe34e1d 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
-// protoc v3.21.12
+// protoc v6.33.1
// source: daemon.proto
package proto
@@ -2757,7 +2757,6 @@ func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule {
type DebugBundleRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"`
- Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
@@ -2802,13 +2801,6 @@ func (x *DebugBundleRequest) GetAnonymize() bool {
return false
}
-func (x *DebugBundleRequest) GetStatus() string {
- if x != nil {
- return x.Status
- }
- return ""
-}
-
func (x *DebugBundleRequest) GetSystemInfo() bool {
if x != nil {
return x.SystemInfo
@@ -5773,10 +5765,9 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" +
- "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xac\x01\n" +
+ "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
- "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x16\n" +
- "\x06status\x18\x02 \x01(\tR\x06status\x12\x1e\n" +
+ "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
"systemInfo\x18\x03 \x01(\bR\n" +
"systemInfo\x12\x1c\n" +
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index b75ca821a..7a802d830 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -455,7 +455,6 @@ message ForwardingRulesResponse {
// DebugBundler
message DebugBundleRequest {
bool anonymize = 1;
- string status = 2;
bool systemInfo = 3;
string uploadURL = 4;
uint32 logFileCount = 5;
diff --git a/client/server/debug.go b/client/server/debug.go
index a6e5926e9..d3f27af55 100644
--- a/client/server/debug.go
+++ b/client/server/debug.go
@@ -4,24 +4,16 @@ package server
import (
"context"
- "crypto/sha256"
- "encoding/json"
"errors"
"fmt"
- "io"
- "net/http"
- "os"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
- "github.com/netbirdio/netbird/upload-server/types"
)
-const maxBundleUploadSize = 50 * 1024 * 1024
-
// DebugBundle creates a debug bundle and returns the location.
func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
s.mutex.Lock()
@@ -44,12 +36,11 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
InternalConfig: s.config,
StatusRecorder: s.statusRecorder,
SyncResponse: syncResponse,
- LogFile: s.logFile,
+ LogPath: s.logFile,
ClientMetrics: clientMetrics,
},
debug.BundleConfig{
Anonymize: req.GetAnonymize(),
- ClientStatus: req.GetStatus(),
IncludeSystemInfo: req.GetSystemInfo(),
LogFileCount: req.GetLogFileCount(),
},
@@ -63,7 +54,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
if req.GetUploadURL() == "" {
return &proto.DebugBundleResponse{Path: path}, nil
}
- key, err := uploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path)
+ key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path)
if err != nil {
log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
@@ -74,92 +65,6 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
}
-func uploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) {
- response, err := getUploadURL(ctx, url, managementURL)
- if err != nil {
- return "", err
- }
-
- err = upload(ctx, filePath, response)
- if err != nil {
- return "", err
- }
- return response.Key, nil
-}
-
-func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error {
- fileData, err := os.Open(filePath)
- if err != nil {
- return fmt.Errorf("open file: %w", err)
- }
-
- defer fileData.Close()
-
- stat, err := fileData.Stat()
- if err != nil {
- return fmt.Errorf("stat file: %w", err)
- }
-
- if stat.Size() > maxBundleUploadSize {
- return fmt.Errorf("file size exceeds maximum limit of %d bytes", maxBundleUploadSize)
- }
-
- req, err := http.NewRequestWithContext(ctx, "PUT", response.URL, fileData)
- if err != nil {
- return fmt.Errorf("create PUT request: %w", err)
- }
-
- req.ContentLength = stat.Size()
- req.Header.Set("Content-Type", "application/octet-stream")
-
- putResp, err := http.DefaultClient.Do(req)
- if err != nil {
- return fmt.Errorf("upload failed: %v", err)
- }
- defer putResp.Body.Close()
-
- if putResp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(putResp.Body)
- return fmt.Errorf("upload status %d: %s", putResp.StatusCode, string(body))
- }
- return nil
-}
-
-func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) {
- id := getURLHash(managementURL)
- getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil)
- if err != nil {
- return nil, fmt.Errorf("create GET request: %w", err)
- }
-
- getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue)
-
- resp, err := http.DefaultClient.Do(getReq)
- if err != nil {
- return nil, fmt.Errorf("get presigned URL: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("get presigned URL status %d: %s", resp.StatusCode, string(body))
- }
-
- urlBytes, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("read response body: %w", err)
- }
- var response types.GetURLResponse
- if err := json.Unmarshal(urlBytes, &response); err != nil {
- return nil, fmt.Errorf("unmarshal response: %w", err)
- }
- return &response, nil
-}
-
-func getURLHash(url string) string {
- return fmt.Sprintf("%x", sha256.Sum256([]byte(url)))
-}
-
// GetLogLevel gets the current logging level for the server.
func (s *Server) GetLogLevel(_ context.Context, _ *proto.GetLogLevelRequest) (*proto.GetLogLevelResponse, error) {
s.mutex.Lock()
@@ -181,20 +86,9 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
log.SetLevel(level)
- if s.connectClient == nil {
- return nil, fmt.Errorf("connect client not initialized")
+ if s.connectClient != nil {
+ s.connectClient.SetLogLevel(level)
}
- engine := s.connectClient.Engine()
- if engine == nil {
- return nil, fmt.Errorf("engine not initialized")
- }
-
- fwManager := engine.GetFirewallManager()
- if fwManager == nil {
- return nil, fmt.Errorf("firewall manager not initialized")
- }
-
- fwManager.SetLogLevel(level)
log.Infof("Log level set to %s", level.String())
diff --git a/client/server/event.go b/client/server/event.go
index 9a4e0fbf5..b5c12a3a6 100644
--- a/client/server/event.go
+++ b/client/server/event.go
@@ -1,8 +1,6 @@
package server
import (
- "context"
-
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
@@ -29,8 +27,3 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
}
}
}
-
-func (s *Server) GetEvents(context.Context, *proto.GetEventsRequest) (*proto.GetEventsResponse, error) {
- events := s.statusRecorder.GetEventHistory()
- return &proto.GetEventsResponse{Events: events}, nil
-}
diff --git a/client/server/server.go b/client/server/server.go
index 7b6c4e98c..408bd56db 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -13,15 +13,11 @@ import (
"time"
"github.com/cenkalti/backoff/v4"
- "golang.org/x/exp/maps"
- "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "google.golang.org/protobuf/types/known/durationpb"
-
log "github.com/sirupsen/logrus"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
gstatus "google.golang.org/grpc/status"
- "google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -70,7 +66,7 @@ type Server struct {
proto.UnimplementedDaemonServiceServer
clientRunning bool // protected by mutex
clientRunningChan chan struct{}
- clientGiveUpChan chan struct{}
+ clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
connectClient *internal.ConnectClient
@@ -796,9 +792,11 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
// Down engine work in the daemon.
func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownResponse, error) {
s.mutex.Lock()
- defer s.mutex.Unlock()
+
+ giveUpChan := s.clientGiveUpChan
if err := s.cleanupConnection(); err != nil {
+ s.mutex.Unlock()
// todo review to update the status in case any type of error
log.Errorf("failed to shut down properly: %v", err)
return nil, err
@@ -807,6 +805,20 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusIdle)
+ s.mutex.Unlock()
+
+ // Wait for the connectWithRetryRuns goroutine to finish with a short timeout.
+ // This prevents the goroutine from setting ErrResetConnection after Down() returns.
+ // The giveUpChan is closed at the end of connectWithRetryRuns.
+ if giveUpChan != nil {
+ select {
+ case <-giveUpChan:
+ log.Debugf("client goroutine finished successfully")
+ case <-time.After(5 * time.Second):
+ log.Warnf("timeout waiting for client goroutine to finish, proceeding anyway")
+ }
+ }
+
return &proto.DownResponse{}, nil
}
@@ -1067,11 +1079,9 @@ func (s *Server) Status(
if msg.GetFullPeerStatus {
s.runProbes(msg.ShouldRunProbes)
fullStatus := s.statusRecorder.GetFullStatus()
- pbFullStatus := toProtoFullStatus(fullStatus)
+ pbFullStatus := fullStatus.ToProto()
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
-
pbFullStatus.SshServerState = s.getSSHServerState()
-
statusResponse.FullStatus = pbFullStatus
}
@@ -1526,7 +1536,7 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta
log.Tracef("running client connection")
s.connectClient = internal.NewConnectClient(ctx, config, statusRecorder, doInitialAutoUpdate)
s.connectClient.SetSyncResponsePersistence(s.persistSyncResponse)
- if err := s.connectClient.Run(runningChan); err != nil {
+ if err := s.connectClient.Run(runningChan, s.logFile); err != nil {
return err
}
return nil
@@ -1600,94 +1610,6 @@ func parseEnvDuration(envVar string, defaultDuration time.Duration) time.Duratio
return defaultDuration
}
-func toProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
- pbFullStatus := proto.FullStatus{
- ManagementState: &proto.ManagementState{},
- SignalState: &proto.SignalState{},
- LocalPeerState: &proto.LocalPeerState{},
- Peers: []*proto.PeerState{},
- }
-
- pbFullStatus.ManagementState.URL = fullStatus.ManagementState.URL
- pbFullStatus.ManagementState.Connected = fullStatus.ManagementState.Connected
- if err := fullStatus.ManagementState.Error; err != nil {
- pbFullStatus.ManagementState.Error = err.Error()
- }
-
- pbFullStatus.SignalState.URL = fullStatus.SignalState.URL
- pbFullStatus.SignalState.Connected = fullStatus.SignalState.Connected
- if err := fullStatus.SignalState.Error; err != nil {
- pbFullStatus.SignalState.Error = err.Error()
- }
-
- pbFullStatus.LocalPeerState.IP = fullStatus.LocalPeerState.IP
- pbFullStatus.LocalPeerState.PubKey = fullStatus.LocalPeerState.PubKey
- pbFullStatus.LocalPeerState.KernelInterface = fullStatus.LocalPeerState.KernelInterface
- pbFullStatus.LocalPeerState.Fqdn = fullStatus.LocalPeerState.FQDN
- pbFullStatus.LocalPeerState.RosenpassPermissive = fullStatus.RosenpassState.Permissive
- pbFullStatus.LocalPeerState.RosenpassEnabled = fullStatus.RosenpassState.Enabled
- pbFullStatus.LocalPeerState.Networks = maps.Keys(fullStatus.LocalPeerState.Routes)
- pbFullStatus.NumberOfForwardingRules = int32(fullStatus.NumOfForwardingRules)
- pbFullStatus.LazyConnectionEnabled = fullStatus.LazyConnectionEnabled
-
- for _, peerState := range fullStatus.Peers {
- pbPeerState := &proto.PeerState{
- IP: peerState.IP,
- PubKey: peerState.PubKey,
- ConnStatus: peerState.ConnStatus.String(),
- ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate),
- Relayed: peerState.Relayed,
- LocalIceCandidateType: peerState.LocalIceCandidateType,
- RemoteIceCandidateType: peerState.RemoteIceCandidateType,
- LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint,
- RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint,
- RelayAddress: peerState.RelayServerAddress,
- Fqdn: peerState.FQDN,
- LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake),
- BytesRx: peerState.BytesRx,
- BytesTx: peerState.BytesTx,
- RosenpassEnabled: peerState.RosenpassEnabled,
- Networks: maps.Keys(peerState.GetRoutes()),
- Latency: durationpb.New(peerState.Latency),
- SshHostKey: peerState.SSHHostKey,
- }
- pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState)
- }
-
- for _, relayState := range fullStatus.Relays {
- pbRelayState := &proto.RelayState{
- URI: relayState.URI,
- Available: relayState.Err == nil,
- }
- if err := relayState.Err; err != nil {
- pbRelayState.Error = err.Error()
- }
- pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState)
- }
-
- for _, dnsState := range fullStatus.NSGroupStates {
- var err string
- if dnsState.Error != nil {
- err = dnsState.Error.Error()
- }
-
- var servers []string
- for _, server := range dnsState.Servers {
- servers = append(servers, server.String())
- }
-
- pbDnsState := &proto.NSGroupState{
- Servers: servers,
- Domains: dnsState.Domains,
- Enabled: dnsState.Enabled,
- Error: err,
- }
- pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
- }
-
- return &pbFullStatus
-}
-
// sendTerminalNotification sends a terminal notification message
// to inform the user that the NetBird connection session has expired.
func sendTerminalNotification() error {
diff --git a/client/server/server_test.go b/client/server/server_test.go
index 1ed115769..82079c531 100644
--- a/client/server/server_test.go
+++ b/client/server/server_test.go
@@ -20,6 +20,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server/job"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/groups"
@@ -306,6 +307,8 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
peersManager := peers.NewManager(store, permissionsManagerMock)
settingsManagerMock := settings.NewMockManager(ctrl)
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore)
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
@@ -317,7 +320,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
- accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
if err != nil {
return nil, "", err
}
@@ -326,7 +329,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
if err != nil {
return nil, "", err
}
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil)
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil)
if err != nil {
return nil, "", err
}
diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go
index 81d588801..dba2e88da 100644
--- a/client/ssh/proxy/proxy_test.go
+++ b/client/ssh/proxy/proxy_test.go
@@ -132,7 +132,7 @@ func TestSSHProxy_Connect(t *testing.T) {
HostKeyPEM: hostKey,
JWT: &server.JWTConfig{
Issuer: issuer,
- Audience: audience,
+ Audiences: []string{audience},
KeysLocation: jwksURL,
},
}
diff --git a/client/ssh/server/jwt_test.go b/client/ssh/server/jwt_test.go
index 6eb88accc..dbef011ac 100644
--- a/client/ssh/server/jwt_test.go
+++ b/client/ssh/server/jwt_test.go
@@ -43,7 +43,7 @@ func TestJWTEnforcement(t *testing.T) {
t.Run("blocks_without_jwt", func(t *testing.T) {
jwtConfig := &JWTConfig{
Issuer: "test-issuer",
- Audience: "test-audience",
+ Audiences: []string{"test-audience"},
KeysLocation: "test-keys",
}
serverConfig := &Config{
@@ -202,7 +202,7 @@ func TestJWTDetection(t *testing.T) {
jwtConfig := &JWTConfig{
Issuer: issuer,
- Audience: audience,
+ Audiences: []string{audience},
KeysLocation: jwksURL,
}
serverConfig := &Config{
@@ -329,7 +329,7 @@ func TestJWTFailClose(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
jwtConfig := &JWTConfig{
Issuer: issuer,
- Audience: audience,
+ Audiences: []string{audience},
KeysLocation: jwksURL,
MaxTokenAge: 3600,
}
@@ -567,7 +567,7 @@ func TestJWTAuthentication(t *testing.T) {
jwtConfig := &JWTConfig{
Issuer: issuer,
- Audience: audience,
+ Audiences: []string{audience},
KeysLocation: jwksURL,
}
serverConfig := &Config{
@@ -646,3 +646,108 @@ func TestJWTAuthentication(t *testing.T) {
})
}
}
+
+// TestJWTMultipleAudiences tests JWT validation with multiple audiences (dashboard and CLI).
+func TestJWTMultipleAudiences(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping JWT multiple audiences tests in short mode")
+ }
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+ defer jwksServer.Close()
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ dashboardAudience = "dashboard-audience"
+ cliAudience = "cli-audience"
+ )
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+
+ testCases := []struct {
+ name string
+ audience string
+ wantAuthOK bool
+ }{
+ {
+ name: "accepts_dashboard_audience",
+ audience: dashboardAudience,
+ wantAuthOK: true,
+ },
+ {
+ name: "accepts_cli_audience",
+ audience: cliAudience,
+ wantAuthOK: true,
+ },
+ {
+ name: "rejects_unknown_audience",
+ audience: "unknown-audience",
+ wantAuthOK: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ jwtConfig := &JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{dashboardAudience, cliAudience},
+ KeysLocation: jwksURL,
+ }
+ serverConfig := &Config{
+ HostKeyPEM: hostKey,
+ JWT: jwtConfig,
+ }
+ server := New(serverConfig)
+ server.SetAllowRootLogin(true)
+
+ testUserHash, err := sshuserhash.HashUserID("test-user")
+ require.NoError(t, err)
+
+ currentUser := testutil.GetTestUsername(t)
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ currentUser: {0},
+ },
+ }
+ server.UpdateSSHAuth(authConfig)
+
+ serverAddr := StartTestServer(t, server)
+ defer require.NoError(t, server.Stop())
+
+ host, portStr, err := net.SplitHostPort(serverAddr)
+ require.NoError(t, err)
+
+ token := generateValidJWT(t, privateKey, issuer, tc.audience)
+ config := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{
+ cryptossh.Password(token),
+ },
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 2 * time.Second,
+ }
+
+ conn, err := cryptossh.Dial("tcp", net.JoinHostPort(host, portStr), config)
+ if tc.wantAuthOK {
+ require.NoError(t, err, "JWT authentication should succeed for audience %s", tc.audience)
+ defer func() {
+ if err := conn.Close(); err != nil {
+ t.Logf("close connection: %v", err)
+ }
+ }()
+
+ session, err := conn.NewSession()
+ require.NoError(t, err)
+ defer session.Close()
+
+ err = session.Shell()
+ require.NoError(t, err, "Shell should work with valid audience")
+ } else {
+ assert.Error(t, err, "JWT authentication should fail for unknown audience")
+ }
+ })
+ }
+}
diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go
index 3a8568979..e897bbade 100644
--- a/client/ssh/server/server.go
+++ b/client/ssh/server/server.go
@@ -176,9 +176,9 @@ type Server struct {
type JWTConfig struct {
Issuer string
- Audience string
KeysLocation string
MaxTokenAge int64
+ Audiences []string
}
// Config contains all SSH server configuration options
@@ -427,18 +427,21 @@ func (s *Server) ensureJWTValidator() error {
return fmt.Errorf("JWT config not set")
}
- log.Debugf("Initializing JWT validator (issuer: %s, audience: %s)", config.Issuer, config.Audience)
+ if len(config.Audiences) == 0 {
+ return fmt.Errorf("JWT config has no audiences configured")
+ }
+ log.Debugf("Initializing JWT validator (issuer: %s, audiences: %v)", config.Issuer, config.Audiences)
validator := jwt.NewValidator(
config.Issuer,
- []string{config.Audience},
+ config.Audiences,
config.KeysLocation,
true,
)
// Use custom userIDClaim from authorizer if available
extractorOptions := []jwt.ClaimsExtractorOption{
- jwt.WithAudience(config.Audience),
+ jwt.WithAudience(config.Audiences[0]),
}
if authorizer.GetUserIDClaim() != "" {
extractorOptions = append(extractorOptions, jwt.WithUserIDClaim(authorizer.GetUserIDClaim()))
@@ -475,8 +478,8 @@ func (s *Server) validateJWTToken(tokenString string) (*gojwt.Token, error) {
if err != nil {
if jwtConfig != nil {
if claims, parseErr := s.parseTokenWithoutValidation(tokenString); parseErr == nil {
- return nil, fmt.Errorf("validate token (expected issuer=%s, audience=%s, actual issuer=%v, audience=%v): %w",
- jwtConfig.Issuer, jwtConfig.Audience, claims["iss"], claims["aud"], err)
+ return nil, fmt.Errorf("validate token (expected issuer=%s, audiences=%v, actual issuer=%v, audience=%v): %w",
+ jwtConfig.Issuer, jwtConfig.Audiences, claims["iss"], claims["aud"], err)
}
}
return nil, fmt.Errorf("validate token: %w", err)
diff --git a/client/status/status.go b/client/status/status.go
index 4f31f3637..be28ff67d 100644
--- a/client/status/status.go
+++ b/client/status/status.go
@@ -11,8 +11,12 @@ import (
"strings"
"time"
+ "google.golang.org/protobuf/types/known/durationpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
"gopkg.in/yaml.v3"
+ "golang.org/x/exp/maps"
+
"github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/internal/peer"
probeRelay "github.com/netbirdio/netbird/client/internal/relay"
@@ -116,9 +120,7 @@ type OutputOverview struct {
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
}
-func ConvertToStatusOutputOverview(resp *proto.StatusResponse, anon bool, statusFilter string, prefixNamesFilter []string, prefixNamesFilterMap map[string]struct{}, ipsFilter map[string]struct{}, connectionTypeFilter string, profName string) OutputOverview {
- pbFullStatus := resp.GetFullStatus()
-
+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 {
managementState := pbFullStatus.GetManagementState()
managementOverview := ManagementStateOutput{
URL: managementState.GetURL(),
@@ -134,13 +136,13 @@ func ConvertToStatusOutputOverview(resp *proto.StatusResponse, anon bool, status
}
relayOverview := mapRelays(pbFullStatus.GetRelays())
- peersOverview := mapPeers(resp.GetFullStatus().GetPeers(), statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilter, connectionTypeFilter)
sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState())
+ peersOverview := mapPeers(pbFullStatus.GetPeers(), statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilter, connectionTypeFilter)
overview := OutputOverview{
Peers: peersOverview,
CliVersion: version.NetbirdVersion(),
- DaemonVersion: resp.GetDaemonVersion(),
+ DaemonVersion: daemonVersion,
ManagementState: managementOverview,
SignalState: signalOverview,
Relays: relayOverview,
@@ -325,61 +327,64 @@ func sortPeersByIP(peersStateDetail []PeerStateDetailOutput) {
}
}
-func ParseToJSON(overview OutputOverview) (string, error) {
- jsonBytes, err := json.Marshal(overview)
+// JSON returns the status overview as a JSON string.
+func (o *OutputOverview) JSON() (string, error) {
+ jsonBytes, err := json.Marshal(o)
if err != nil {
return "", fmt.Errorf("json marshal failed")
}
return string(jsonBytes), err
}
-func ParseToYAML(overview OutputOverview) (string, error) {
- yamlBytes, err := yaml.Marshal(overview)
+// YAML returns the status overview as a YAML string.
+func (o *OutputOverview) YAML() (string, error) {
+ yamlBytes, err := yaml.Marshal(o)
if err != nil {
return "", fmt.Errorf("yaml marshal failed")
}
return string(yamlBytes), nil
}
-func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool, showNameServers bool, showSSHSessions bool) string {
+// GeneralSummary returns a general summary of the status overview.
+func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameServers bool, showSSHSessions bool) string {
var managementConnString string
- if overview.ManagementState.Connected {
+ if o.ManagementState.Connected {
managementConnString = "Connected"
if showURL {
- managementConnString = fmt.Sprintf("%s to %s", managementConnString, overview.ManagementState.URL)
+ managementConnString = fmt.Sprintf("%s to %s", managementConnString, o.ManagementState.URL)
}
} else {
managementConnString = "Disconnected"
- if overview.ManagementState.Error != "" {
- managementConnString = fmt.Sprintf("%s, reason: %s", managementConnString, overview.ManagementState.Error)
+ if o.ManagementState.Error != "" {
+ managementConnString = fmt.Sprintf("%s, reason: %s", managementConnString, o.ManagementState.Error)
}
}
var signalConnString string
- if overview.SignalState.Connected {
+ if o.SignalState.Connected {
signalConnString = "Connected"
if showURL {
- signalConnString = fmt.Sprintf("%s to %s", signalConnString, overview.SignalState.URL)
+ signalConnString = fmt.Sprintf("%s to %s", signalConnString, o.SignalState.URL)
}
} else {
signalConnString = "Disconnected"
- if overview.SignalState.Error != "" {
- signalConnString = fmt.Sprintf("%s, reason: %s", signalConnString, overview.SignalState.Error)
+ if o.SignalState.Error != "" {
+ signalConnString = fmt.Sprintf("%s, reason: %s", signalConnString, o.SignalState.Error)
}
}
interfaceTypeString := "Userspace"
- interfaceIP := overview.IP
- if overview.KernelInterface {
+ interfaceIP := o.IP
+ if o.KernelInterface {
interfaceTypeString = "Kernel"
- } else if overview.IP == "" {
+ } else if o.IP == "" {
interfaceTypeString = "N/A"
interfaceIP = "N/A"
}
var relaysString string
if showRelays {
- for _, relay := range overview.Relays.Details {
+ for _, relay := range o.Relays.Details {
available := "Available"
reason := ""
@@ -395,18 +400,18 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
relaysString += fmt.Sprintf("\n [%s] is %s%s", relay.URI, available, reason)
}
} else {
- relaysString = fmt.Sprintf("%d/%d Available", overview.Relays.Available, overview.Relays.Total)
+ relaysString = fmt.Sprintf("%d/%d Available", o.Relays.Available, o.Relays.Total)
}
networks := "-"
- if len(overview.Networks) > 0 {
- sort.Strings(overview.Networks)
- networks = strings.Join(overview.Networks, ", ")
+ if len(o.Networks) > 0 {
+ sort.Strings(o.Networks)
+ networks = strings.Join(o.Networks, ", ")
}
var dnsServersString string
if showNameServers {
- for _, nsServerGroup := range overview.NSServerGroups {
+ for _, nsServerGroup := range o.NSServerGroups {
enabled := "Available"
if !nsServerGroup.Enabled {
enabled = "Unavailable"
@@ -430,25 +435,25 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
)
}
} else {
- dnsServersString = fmt.Sprintf("%d/%d Available", countEnabled(overview.NSServerGroups), len(overview.NSServerGroups))
+ dnsServersString = fmt.Sprintf("%d/%d Available", countEnabled(o.NSServerGroups), len(o.NSServerGroups))
}
rosenpassEnabledStatus := "false"
- if overview.RosenpassEnabled {
+ if o.RosenpassEnabled {
rosenpassEnabledStatus = "true"
- if overview.RosenpassPermissive {
+ if o.RosenpassPermissive {
rosenpassEnabledStatus = "true (permissive)" //nolint:gosec
}
}
lazyConnectionEnabledStatus := "false"
- if overview.LazyConnectionEnabled {
+ if o.LazyConnectionEnabled {
lazyConnectionEnabledStatus = "true"
}
sshServerStatus := "Disabled"
- if overview.SSHServerState.Enabled {
- sessionCount := len(overview.SSHServerState.Sessions)
+ if o.SSHServerState.Enabled {
+ sessionCount := len(o.SSHServerState.Sessions)
if sessionCount > 0 {
sessionWord := "session"
if sessionCount > 1 {
@@ -460,7 +465,7 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
}
if showSSHSessions && sessionCount > 0 {
- for _, session := range overview.SSHServerState.Sessions {
+ for _, session := range o.SSHServerState.Sessions {
var sessionDisplay string
if session.JWTUsername != "" {
sessionDisplay = fmt.Sprintf("[%s@%s -> %s] %s",
@@ -484,7 +489,7 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
}
}
- peersCountString := fmt.Sprintf("%d/%d Connected", overview.Peers.Connected, overview.Peers.Total)
+ peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
goos := runtime.GOOS
goarch := runtime.GOARCH
@@ -512,30 +517,31 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
"Forwarding rules: %d\n"+
"Peers count: %s\n",
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
- overview.DaemonVersion,
+ o.DaemonVersion,
version.NetbirdVersion(),
- overview.ProfileName,
+ o.ProfileName,
managementConnString,
signalConnString,
relaysString,
dnsServersString,
- domain.Domain(overview.FQDN).SafeString(),
+ domain.Domain(o.FQDN).SafeString(),
interfaceIP,
interfaceTypeString,
rosenpassEnabledStatus,
lazyConnectionEnabledStatus,
sshServerStatus,
networks,
- overview.NumberOfForwardingRules,
+ o.NumberOfForwardingRules,
peersCountString,
)
return summary
}
-func ParseToFullDetailSummary(overview OutputOverview) string {
- parsedPeersString := parsePeers(overview.Peers, overview.RosenpassEnabled, overview.RosenpassPermissive)
- parsedEventsString := parseEvents(overview.Events)
- summary := ParseGeneralSummary(overview, true, true, true, true)
+// FullDetailSummary returns a full detailed summary with peer details and events.
+func (o *OutputOverview) FullDetailSummary() string {
+ parsedPeersString := parsePeers(o.Peers, o.RosenpassEnabled, o.RosenpassPermissive)
+ parsedEventsString := parseEvents(o.Events)
+ summary := o.GeneralSummary(true, true, true, true)
return fmt.Sprintf(
"Peers detail:"+
@@ -549,6 +555,94 @@ func ParseToFullDetailSummary(overview OutputOverview) string {
)
}
+func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
+ pbFullStatus := proto.FullStatus{
+ ManagementState: &proto.ManagementState{},
+ SignalState: &proto.SignalState{},
+ LocalPeerState: &proto.LocalPeerState{},
+ Peers: []*proto.PeerState{},
+ }
+
+ pbFullStatus.ManagementState.URL = fullStatus.ManagementState.URL
+ pbFullStatus.ManagementState.Connected = fullStatus.ManagementState.Connected
+ if err := fullStatus.ManagementState.Error; err != nil {
+ pbFullStatus.ManagementState.Error = err.Error()
+ }
+
+ pbFullStatus.SignalState.URL = fullStatus.SignalState.URL
+ pbFullStatus.SignalState.Connected = fullStatus.SignalState.Connected
+ if err := fullStatus.SignalState.Error; err != nil {
+ pbFullStatus.SignalState.Error = err.Error()
+ }
+
+ pbFullStatus.LocalPeerState.IP = fullStatus.LocalPeerState.IP
+ pbFullStatus.LocalPeerState.PubKey = fullStatus.LocalPeerState.PubKey
+ pbFullStatus.LocalPeerState.KernelInterface = fullStatus.LocalPeerState.KernelInterface
+ pbFullStatus.LocalPeerState.Fqdn = fullStatus.LocalPeerState.FQDN
+ pbFullStatus.LocalPeerState.RosenpassPermissive = fullStatus.RosenpassState.Permissive
+ pbFullStatus.LocalPeerState.RosenpassEnabled = fullStatus.RosenpassState.Enabled
+ pbFullStatus.LocalPeerState.Networks = maps.Keys(fullStatus.LocalPeerState.Routes)
+ pbFullStatus.NumberOfForwardingRules = int32(fullStatus.NumOfForwardingRules)
+ pbFullStatus.LazyConnectionEnabled = fullStatus.LazyConnectionEnabled
+
+ for _, peerState := range fullStatus.Peers {
+ pbPeerState := &proto.PeerState{
+ IP: peerState.IP,
+ PubKey: peerState.PubKey,
+ ConnStatus: peerState.ConnStatus.String(),
+ ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate),
+ Relayed: peerState.Relayed,
+ LocalIceCandidateType: peerState.LocalIceCandidateType,
+ RemoteIceCandidateType: peerState.RemoteIceCandidateType,
+ LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint,
+ RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint,
+ RelayAddress: peerState.RelayServerAddress,
+ Fqdn: peerState.FQDN,
+ LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake),
+ BytesRx: peerState.BytesRx,
+ BytesTx: peerState.BytesTx,
+ RosenpassEnabled: peerState.RosenpassEnabled,
+ Networks: maps.Keys(peerState.GetRoutes()),
+ Latency: durationpb.New(peerState.Latency),
+ SshHostKey: peerState.SSHHostKey,
+ }
+ pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState)
+ }
+
+ for _, relayState := range fullStatus.Relays {
+ pbRelayState := &proto.RelayState{
+ URI: relayState.URI,
+ Available: relayState.Err == nil,
+ }
+ if err := relayState.Err; err != nil {
+ pbRelayState.Error = err.Error()
+ }
+ pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState)
+ }
+
+ for _, dnsState := range fullStatus.NSGroupStates {
+ var err string
+ if dnsState.Error != nil {
+ err = dnsState.Error.Error()
+ }
+
+ var servers []string
+ for _, server := range dnsState.Servers {
+ servers = append(servers, server.String())
+ }
+
+ pbDnsState := &proto.NSGroupState{
+ Servers: servers,
+ Domains: dnsState.Domains,
+ Enabled: dnsState.Enabled,
+ Error: err,
+ }
+ pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
+ }
+
+ return &pbFullStatus
+}
+
func parsePeers(peers PeersStateOutput, rosenpassEnabled, rosenpassPermissive bool) string {
var (
peersString = ""
diff --git a/client/status/status_test.go b/client/status/status_test.go
index 1dca1e5b1..ad158722b 100644
--- a/client/status/status_test.go
+++ b/client/status/status_test.go
@@ -238,7 +238,7 @@ var overview = OutputOverview{
}
func TestConversionFromFullStatusToOutputOverview(t *testing.T) {
- convertedResult := ConvertToStatusOutputOverview(resp, false, "", nil, nil, nil, "", "")
+ convertedResult := ConvertToStatusOutputOverview(resp.GetFullStatus(), false, resp.GetDaemonVersion(), "", nil, nil, nil, "", "")
assert.Equal(t, overview, convertedResult)
}
@@ -268,7 +268,7 @@ func TestSortingOfPeers(t *testing.T) {
}
func TestParsingToJSON(t *testing.T) {
- jsonString, _ := ParseToJSON(overview)
+ jsonString, _ := overview.JSON()
//@formatter:off
expectedJSONString := `
@@ -404,7 +404,7 @@ func TestParsingToJSON(t *testing.T) {
}
func TestParsingToYAML(t *testing.T) {
- yaml, _ := ParseToYAML(overview)
+ yaml, _ := overview.YAML()
expectedYAML :=
`peers:
@@ -511,7 +511,7 @@ func TestParsingToDetail(t *testing.T) {
lastConnectionUpdate2 := timeAgo(overview.Peers.Details[1].LastStatusUpdate)
lastHandshake2 := timeAgo(overview.Peers.Details[1].LastWireguardHandshake)
- detail := ParseToFullDetailSummary(overview)
+ detail := overview.FullDetailSummary()
expectedDetail := fmt.Sprintf(
`Peers detail:
@@ -575,7 +575,7 @@ Peers count: 2/2 Connected
}
func TestParsingToShortVersion(t *testing.T) {
- shortVersion := ParseGeneralSummary(overview, false, false, false, false)
+ shortVersion := overview.GeneralSummary(false, false, false, false)
expectedString := fmt.Sprintf("OS: %s/%s", runtime.GOOS, runtime.GOARCH) + `
Daemon version: 0.14.1
diff --git a/client/ui/debug.go b/client/ui/debug.go
index 51fa28575..e9bcfde41 100644
--- a/client/ui/debug.go
+++ b/client/ui/debug.go
@@ -18,9 +18,7 @@ import (
"github.com/skratchdot/open-golang/open"
"github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
- nbstatus "github.com/netbirdio/netbird/client/status"
uptypes "github.com/netbirdio/netbird/upload-server/types"
)
@@ -291,19 +289,18 @@ func (s *serviceClient) handleRunForDuration(
return
}
- statusOutput, err := s.collectDebugData(conn, initialState, params, progressUI)
- if err != nil {
+ defer s.restoreServiceState(conn, initialState)
+
+ if err := s.collectDebugData(conn, initialState, params, progressUI); err != nil {
handleError(progressUI, err.Error())
return
}
- if err := s.createDebugBundleFromCollection(conn, params, statusOutput, progressUI); err != nil {
+ if err := s.createDebugBundleFromCollection(conn, params, progressUI); err != nil {
handleError(progressUI, err.Error())
return
}
- s.restoreServiceState(conn, initialState)
-
progressUI.statusLabel.SetText("Bundle created successfully")
}
@@ -417,68 +414,33 @@ func (s *serviceClient) collectDebugData(
state *debugInitialState,
params *debugCollectionParams,
progress *progressUI,
-) (string, error) {
+) error {
ctx, cancel := context.WithTimeout(s.ctx, params.duration)
defer cancel()
var wg sync.WaitGroup
startProgressTracker(ctx, &wg, params.duration, progress)
if err := s.configureServiceForDebug(conn, state, params.enablePersistence); err != nil {
- return "", err
+ return err
}
- pm := profilemanager.NewProfileManager()
- var profName string
- if activeProf, err := pm.GetActiveProfile(); err == nil {
- profName = activeProf.Name
- }
-
- postUpStatus, err := conn.Status(s.ctx, &proto.StatusRequest{GetFullPeerStatus: true})
- if err != nil {
- log.Warnf("Failed to get post-up status: %v", err)
- }
-
- var postUpStatusOutput string
- if postUpStatus != nil {
- overview := nbstatus.ConvertToStatusOutputOverview(postUpStatus, params.anonymize, "", nil, nil, nil, "", profName)
- postUpStatusOutput = nbstatus.ParseToFullDetailSummary(overview)
- }
- headerPostUp := fmt.Sprintf("----- NetBird post-up - Timestamp: %s", time.Now().Format(time.RFC3339))
- statusOutput := fmt.Sprintf("%s\n%s", headerPostUp, postUpStatusOutput)
-
wg.Wait()
progress.progressBar.Hide()
progress.statusLabel.SetText("Collecting debug data...")
- preDownStatus, err := conn.Status(s.ctx, &proto.StatusRequest{GetFullPeerStatus: true})
- if err != nil {
- log.Warnf("Failed to get pre-down status: %v", err)
- }
-
- var preDownStatusOutput string
- if preDownStatus != nil {
- overview := nbstatus.ConvertToStatusOutputOverview(preDownStatus, params.anonymize, "", nil, nil, nil, "", profName)
- preDownStatusOutput = nbstatus.ParseToFullDetailSummary(overview)
- }
- headerPreDown := fmt.Sprintf("----- NetBird pre-down - Timestamp: %s - Duration: %s",
- time.Now().Format(time.RFC3339), params.duration)
- statusOutput = fmt.Sprintf("%s\n%s\n%s", statusOutput, headerPreDown, preDownStatusOutput)
-
- return statusOutput, nil
+ return nil
}
// Create the debug bundle with collected data
func (s *serviceClient) createDebugBundleFromCollection(
conn proto.DaemonServiceClient,
params *debugCollectionParams,
- statusOutput string,
progress *progressUI,
) error {
progress.statusLabel.SetText("Creating debug bundle with collected logs...")
request := &proto.DebugBundleRequest{
Anonymize: params.anonymize,
- Status: statusOutput,
SystemInfo: params.systemInfo,
}
@@ -581,26 +543,8 @@ func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploa
return nil, fmt.Errorf("get client: %v", err)
}
- pm := profilemanager.NewProfileManager()
- var profName string
- if activeProf, err := pm.GetActiveProfile(); err == nil {
- profName = activeProf.Name
- }
-
- statusResp, err := conn.Status(s.ctx, &proto.StatusRequest{GetFullPeerStatus: true})
- if err != nil {
- log.Warnf("failed to get status for debug bundle: %v", err)
- }
-
- var statusOutput string
- if statusResp != nil {
- overview := nbstatus.ConvertToStatusOutputOverview(statusResp, anonymize, "", nil, nil, nil, "", profName)
- statusOutput = nbstatus.ParseToFullDetailSummary(overview)
- }
-
request := &proto.DebugBundleRequest{
Anonymize: anonymize,
- Status: statusOutput,
SystemInfo: systemInfo,
}
diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go
index 238e272fa..26022ffc7 100644
--- a/client/wasm/cmd/main.go
+++ b/client/wasm/cmd/main.go
@@ -9,20 +9,28 @@ import (
"time"
log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/encoding/protojson"
netbird "github.com/netbirdio/netbird/client/embed"
sshdetection "github.com/netbirdio/netbird/client/ssh/detection"
+ nbstatus "github.com/netbirdio/netbird/client/status"
"github.com/netbirdio/netbird/client/wasm/internal/http"
"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 (
clientStartTimeout = 30 * time.Second
clientStopTimeout = 10 * time.Second
+ pingTimeout = 10 * time.Second
defaultLogLevel = "warn"
defaultSSHDetectionTimeout = 20 * time.Second
+
+ icmpEchoRequest = 8
+ icmpCodeEcho = 0
+ pingBufferSize = 1500
)
func main() {
@@ -113,18 +121,45 @@ func createStopMethod(client *netbird.Client) js.Func {
})
}
+// validateSSHArgs validates SSH connection arguments
+func validateSSHArgs(args []js.Value) (host string, port int, username string, err js.Value) {
+ if len(args) < 2 {
+ return "", 0, "", js.ValueOf("error: requires host and port")
+ }
+
+ if args[0].Type() != js.TypeString {
+ return "", 0, "", js.ValueOf("host parameter must be a string")
+ }
+ if args[1].Type() != js.TypeNumber {
+ return "", 0, "", js.ValueOf("port parameter must be a number")
+ }
+
+ host = args[0].String()
+ port = args[1].Int()
+ username = "root"
+
+ if len(args) > 2 {
+ if args[2].Type() == js.TypeString && args[2].String() != "" {
+ username = args[2].String()
+ } else if args[2].Type() != js.TypeString {
+ return "", 0, "", js.ValueOf("username parameter must be a string")
+ }
+ }
+
+ return host, port, username, js.Undefined()
+}
+
// createSSHMethod creates the SSH connection method
func createSSHMethod(client *netbird.Client) js.Func {
return js.FuncOf(func(this js.Value, args []js.Value) any {
- if len(args) < 2 {
- return js.ValueOf("error: requires host and port")
- }
-
- host := args[0].String()
- port := args[1].Int()
- username := "root"
- if len(args) > 2 && args[2].String() != "" {
- username = args[2].String()
+ host, port, username, validationErr := validateSSHArgs(args)
+ if !validationErr.IsUndefined() {
+ if validationErr.Type() == js.TypeString && validationErr.String() == "error: requires host and port" {
+ return validationErr
+ }
+ return createPromise(func(resolve, reject js.Value) {
+ reject.Invoke(validationErr)
+ })
}
var jwtToken string
@@ -154,6 +189,110 @@ func createSSHMethod(client *netbird.Client) js.Func {
})
}
+func performPing(client *netbird.Client, hostname string) {
+ ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
+ defer cancel()
+
+ start := time.Now()
+ conn, err := client.Dial(ctx, "ping", hostname)
+ if err != nil {
+ js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s failed: %v", hostname, err))
+ return
+ }
+ defer func() {
+ if err := conn.Close(); err != nil {
+ log.Debugf("failed to close ping connection: %v", err)
+ }
+ }()
+
+ icmpData := make([]byte, 8)
+ icmpData[0] = icmpEchoRequest
+ icmpData[1] = icmpCodeEcho
+
+ if _, err := conn.Write(icmpData); err != nil {
+ js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s write failed: %v", hostname, err))
+ return
+ }
+
+ buf := make([]byte, pingBufferSize)
+ if _, err := conn.Read(buf); err != nil {
+ js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s read failed: %v", hostname, err))
+ return
+ }
+
+ latency := time.Since(start)
+ js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s: %dms", hostname, latency.Milliseconds()))
+}
+
+func performPingTCP(client *netbird.Client, hostname string, port int) {
+ ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
+ defer cancel()
+
+ address := fmt.Sprintf("%s:%d", hostname, port)
+ start := time.Now()
+ conn, err := client.Dial(ctx, "tcp", address)
+ if err != nil {
+ js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s failed: %v", address, err))
+ return
+ }
+ latency := time.Since(start)
+
+ if err := conn.Close(); err != nil {
+ log.Debugf("failed to close TCP connection: %v", err)
+ }
+
+ js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s succeeded: %dms", address, latency.Milliseconds()))
+}
+
+// createPingMethod creates the ping method
+func createPingMethod(client *netbird.Client) js.Func {
+ return js.FuncOf(func(this js.Value, args []js.Value) any {
+ if len(args) < 1 {
+ return js.ValueOf("error: hostname required")
+ }
+
+ if args[0].Type() != js.TypeString {
+ return createPromise(func(resolve, reject js.Value) {
+ reject.Invoke(js.ValueOf("hostname parameter must be a string"))
+ })
+ }
+
+ hostname := args[0].String()
+ return createPromise(func(resolve, reject js.Value) {
+ performPing(client, hostname)
+ resolve.Invoke(js.Undefined())
+ })
+ })
+}
+
+// createPingTCPMethod creates the pingtcp method
+func createPingTCPMethod(client *netbird.Client) js.Func {
+ return js.FuncOf(func(this js.Value, args []js.Value) any {
+ if len(args) < 2 {
+ return js.ValueOf("error: hostname and port required")
+ }
+
+ if args[0].Type() != js.TypeString {
+ return createPromise(func(resolve, reject js.Value) {
+ reject.Invoke(js.ValueOf("hostname parameter must be a string"))
+ })
+ }
+
+ if args[1].Type() != js.TypeNumber {
+ return createPromise(func(resolve, reject js.Value) {
+ reject.Invoke(js.ValueOf("port parameter must be a number"))
+ })
+ }
+
+ hostname := args[0].String()
+ port := args[1].Int()
+ return createPromise(func(resolve, reject js.Value) {
+ performPingTCP(client, hostname, port)
+ resolve.Invoke(js.Undefined())
+ })
+ })
+}
+
// createProxyRequestMethod creates the proxyRequest method
func createProxyRequestMethod(client *netbird.Client) js.Func {
return js.FuncOf(func(this js.Value, args []js.Value) any {
@@ -162,6 +301,11 @@ func createProxyRequestMethod(client *netbird.Client) js.Func {
}
request := args[0]
+ if request.Type() != js.TypeObject {
+ return createPromise(func(resolve, reject js.Value) {
+ reject.Invoke(js.ValueOf("request parameter must be an object"))
+ })
+ }
return createPromise(func(resolve, reject js.Value) {
response, err := http.ProxyRequest(client, request)
@@ -181,11 +325,141 @@ func createRDPProxyMethod(client *netbird.Client) js.Func {
return js.ValueOf("error: hostname and port required")
}
+ if args[0].Type() != js.TypeString {
+ return createPromise(func(resolve, reject js.Value) {
+ reject.Invoke(js.ValueOf("hostname parameter must be a string"))
+ })
+ }
+ if args[1].Type() != js.TypeString {
+ return createPromise(func(resolve, reject js.Value) {
+ reject.Invoke(js.ValueOf("port parameter must be a string"))
+ })
+ }
+
proxy := rdp.NewRDCleanPathProxy(client)
return proxy.CreateProxy(args[0].String(), args[1].String())
})
}
+// getStatusOverview is a helper to get the status overview
+func getStatusOverview(client *netbird.Client) (nbstatus.OutputOverview, error) {
+ fullStatus, err := client.Status()
+ if err != nil {
+ return nbstatus.OutputOverview{}, err
+ }
+
+ pbFullStatus := fullStatus.ToProto()
+
+ return nbstatus.ConvertToStatusOutputOverview(pbFullStatus, false, version.NetbirdVersion(), "", nil, nil, nil, "", ""), nil
+}
+
+// createStatusMethod creates the status method that returns JSON
+func createStatusMethod(client *netbird.Client) js.Func {
+ return js.FuncOf(func(_ js.Value, args []js.Value) any {
+ return createPromise(func(resolve, reject js.Value) {
+ overview, err := getStatusOverview(client)
+ if err != nil {
+ reject.Invoke(js.ValueOf(err.Error()))
+ return
+ }
+
+ jsonStr, err := overview.JSON()
+ if err != nil {
+ reject.Invoke(js.ValueOf(err.Error()))
+ return
+ }
+ jsonObj := js.Global().Get("JSON").Call("parse", jsonStr)
+ resolve.Invoke(jsonObj)
+ })
+ })
+}
+
+// createStatusSummaryMethod creates the statusSummary method
+func createStatusSummaryMethod(client *netbird.Client) js.Func {
+ return js.FuncOf(func(_ js.Value, args []js.Value) any {
+ return createPromise(func(resolve, reject js.Value) {
+ overview, err := getStatusOverview(client)
+ if err != nil {
+ reject.Invoke(js.ValueOf(err.Error()))
+ return
+ }
+
+ summary := overview.GeneralSummary(false, false, false, false)
+ js.Global().Get("console").Call("log", summary)
+ resolve.Invoke(js.Undefined())
+ })
+ })
+}
+
+// createStatusDetailMethod creates the statusDetail method
+func createStatusDetailMethod(client *netbird.Client) js.Func {
+ return js.FuncOf(func(_ js.Value, args []js.Value) any {
+ return createPromise(func(resolve, reject js.Value) {
+ overview, err := getStatusOverview(client)
+ if err != nil {
+ reject.Invoke(js.ValueOf(err.Error()))
+ return
+ }
+
+ detail := overview.FullDetailSummary()
+ js.Global().Get("console").Call("log", detail)
+ resolve.Invoke(js.Undefined())
+ })
+ })
+}
+
+// createGetSyncResponseMethod creates the getSyncResponse method that returns the latest sync response as JSON
+func createGetSyncResponseMethod(client *netbird.Client) js.Func {
+ return js.FuncOf(func(_ js.Value, args []js.Value) any {
+ return createPromise(func(resolve, reject js.Value) {
+ syncResp, err := client.GetLatestSyncResponse()
+ if err != nil {
+ reject.Invoke(js.ValueOf(err.Error()))
+ return
+ }
+
+ options := protojson.MarshalOptions{
+ EmitUnpopulated: true,
+ UseProtoNames: true,
+ AllowPartial: true,
+ }
+ jsonBytes, err := options.Marshal(syncResp)
+ if err != nil {
+ reject.Invoke(js.ValueOf(fmt.Sprintf("marshal sync response: %v", err)))
+ return
+ }
+
+ jsonObj := js.Global().Get("JSON").Call("parse", string(jsonBytes))
+ resolve.Invoke(jsonObj)
+ })
+ })
+}
+
+// createSetLogLevelMethod creates the setLogLevel method to dynamically change logging level
+func createSetLogLevelMethod(client *netbird.Client) js.Func {
+ return js.FuncOf(func(_ js.Value, args []js.Value) any {
+ if len(args) < 1 {
+ return js.ValueOf("error: log level required")
+ }
+
+ if args[0].Type() != js.TypeString {
+ return createPromise(func(resolve, reject js.Value) {
+ reject.Invoke(js.ValueOf("log level parameter must be a string"))
+ })
+ }
+
+ logLevel := args[0].String()
+ return createPromise(func(resolve, reject js.Value) {
+ if err := client.SetLogLevel(logLevel); err != nil {
+ reject.Invoke(js.ValueOf(fmt.Sprintf("set log level: %v", err)))
+ return
+ }
+ log.Infof("Log level set to: %s", logLevel)
+ resolve.Invoke(js.ValueOf(true))
+ })
+ })
+}
+
// createPromise is a helper to create JavaScript promises
func createPromise(handler func(resolve, reject js.Value)) js.Value {
return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
@@ -237,17 +511,24 @@ func createClientObject(client *netbird.Client) js.Value {
obj["start"] = createStartMethod(client)
obj["stop"] = createStopMethod(client)
+ obj["ping"] = createPingMethod(client)
+ obj["pingtcp"] = createPingTCPMethod(client)
obj["detectSSHServerType"] = createDetectSSHServerMethod(client)
obj["createSSHConnection"] = createSSHMethod(client)
obj["proxyRequest"] = createProxyRequestMethod(client)
obj["createRDPProxy"] = createRDPProxyMethod(client)
+ obj["status"] = createStatusMethod(client)
+ obj["statusSummary"] = createStatusSummaryMethod(client)
+ obj["statusDetail"] = createStatusDetailMethod(client)
+ obj["getSyncResponse"] = createGetSyncResponseMethod(client)
+ obj["setLogLevel"] = createSetLogLevelMethod(client)
return js.ValueOf(obj)
}
// netBirdClientConstructor acts as a JavaScript constructor function
-func netBirdClientConstructor(this js.Value, args []js.Value) any {
- return js.Global().Get("Promise").New(js.FuncOf(func(this js.Value, promiseArgs []js.Value) any {
+func netBirdClientConstructor(_ js.Value, args []js.Value) any {
+ return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
resolve := promiseArgs[0]
reject := promiseArgs[1]
diff --git a/dns/dns.go b/dns/dns.go
index aa0e16eb1..c43e5de00 100644
--- a/dns/dns.go
+++ b/dns/dns.go
@@ -47,8 +47,8 @@ type CustomZone struct {
Records []SimpleRecord
// SearchDomainDisabled indicates whether to add match domains to a search domains list or not
SearchDomainDisabled bool
- // SkipPTRProcess indicates whether a client should process PTR records from custom zones
- SkipPTRProcess bool
+ // NonAuthoritative marks user-created zones
+ NonAuthoritative bool
}
// SimpleRecord provides a simple DNS record specification for CNAME, A and AAAA records
diff --git a/go.mod b/go.mod
index 5c1bd1e8a..80999ca8a 100644
--- a/go.mod
+++ b/go.mod
@@ -70,6 +70,7 @@ require (
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/netbirdio/management-integrations/integrations v0.0.0-20251203183432-d5400f030847
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45
+ github.com/oapi-codegen/runtime v1.1.2
github.com/okta/okta-sdk-golang/v2 v2.18.0
github.com/oschwald/maxminddb-golang v1.12.0
github.com/patrickmn/go-cache v2.1.0+incompatible
@@ -78,8 +79,8 @@ require (
github.com/pion/logging v0.2.4
github.com/pion/randutil v0.1.0
github.com/pion/stun/v2 v2.0.0
- github.com/pion/stun/v3 v3.0.0
- github.com/pion/transport/v3 v3.0.7
+ github.com/pion/stun/v3 v3.1.0
+ github.com/pion/transport/v3 v3.1.1
github.com/pion/turn/v3 v3.0.1
github.com/pkg/sftp v1.13.9
github.com/prometheus/client_golang v1.23.2
@@ -142,6 +143,7 @@ require (
github.com/Microsoft/hcsshim v0.12.3 // indirect
github.com/VictoriaMetrics/metrics v1.40.2 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
+ 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
@@ -242,7 +244,7 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pion/dtls/v2 v2.2.10 // indirect
- github.com/pion/dtls/v3 v3.0.7 // indirect
+ github.com/pion/dtls/v3 v3.0.9 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/transport/v2 v2.2.4 // indirect
github.com/pion/turn/v4 v4.1.1 // indirect
@@ -266,7 +268,7 @@ require (
github.com/valyala/histogram v1.2.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
- github.com/wlynxg/anet v0.0.3 // indirect
+ github.com/wlynxg/anet v0.0.5 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/zeebo/blake3 v0.2.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
diff --git a/go.sum b/go.sum
index 0b1377e2b..e44b8b122 100644
--- a/go.sum
+++ b/go.sum
@@ -35,6 +35,7 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Microsoft/hcsshim v0.12.3 h1:LS9NXqXhMoqNCplK1ApmVSfB4UnVLRDWRapB6EIlxE0=
github.com/Microsoft/hcsshim v0.12.3/go.mod h1:Iyl1WVpZzr+UkzjekHZbV8o5Z9ZkxNGx6CtY2Qg/JVQ=
+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/VictoriaMetrics/metrics v1.40.2 h1:OVSjKcQEx6JAwGeu8/KQm9Su5qJ72TMEW4xYn5vw3Ac=
@@ -43,6 +44,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
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=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g=
github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w=
github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A=
@@ -89,6 +92,7 @@ github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -322,6 +326,7 @@ github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7X
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
+github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -418,6 +423,8 @@ github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXq
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
+github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/okta/okta-sdk-golang/v2 v2.18.0 h1:cfDasMb7CShbZvOrF6n+DnLevWwiHgedWMGJ8M8xKDc=
github.com/okta/okta-sdk-golang/v2 v2.18.0/go.mod h1:dz30v3ctAiMb7jpsCngGfQUAEGm1/NsWT92uTbNDQIs=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -446,8 +453,8 @@ github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7c
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/dtls/v2 v2.2.10 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA=
github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
-github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
-github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
+github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM=
+github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
@@ -457,14 +464,14 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
-github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
-github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
+github.com/pion/stun/v3 v3.1.0 h1:bS1jjT3tGWZ4UPmIUeyalOylamTMTFg1OvXtY/r6seM=
+github.com/pion/stun/v3 v3.1.0/go.mod h1:egmx1CUcfSSGJxQCOjtVlomfPqmQ58BibPyuOWNGQEU=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.4 h1:41JJK6DZQYSeVLxILA2+F4ZkKb4Xd/tFJZRFZQ9QAlo=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
-github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
-github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
+github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
+github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/turn/v3 v3.0.1 h1:wLi7BTQr6/Q20R0vt/lHbjv6y4GChFtC33nkYbasoT8=
github.com/pion/turn/v3 v3.0.1/go.mod h1:MrJDKgqryDyWy1/4NT9TWfXWGMC7UHT6pJIv1+gMeNE=
github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
@@ -524,6 +531,7 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
@@ -580,8 +588,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
-github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
-github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
+github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
+github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
diff --git a/idp/dex/connector.go b/idp/dex/connector.go
new file mode 100644
index 000000000..cad682141
--- /dev/null
+++ b/idp/dex/connector.go
@@ -0,0 +1,356 @@
+// Package dex provides an embedded Dex OIDC identity provider.
+package dex
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/dexidp/dex/storage"
+)
+
+// ConnectorConfig represents the configuration for an identity provider connector
+type ConnectorConfig struct {
+ // ID is the unique identifier for the connector
+ ID string
+ // Name is a human-readable name for the connector
+ Name string
+ // Type is the connector type (oidc, google, microsoft)
+ Type string
+ // Issuer is the OIDC issuer URL (for OIDC-based connectors)
+ Issuer string
+ // ClientID is the OAuth2 client ID
+ ClientID string
+ // ClientSecret is the OAuth2 client secret
+ ClientSecret string
+ // RedirectURI is the OAuth2 redirect URI
+ RedirectURI string
+}
+
+// CreateConnector creates a new connector in Dex storage.
+// It maps the connector config to the appropriate Dex connector type and configuration.
+func (p *Provider) CreateConnector(ctx context.Context, cfg *ConnectorConfig) (*ConnectorConfig, error) {
+ // Fill in the redirect URI if not provided
+ if cfg.RedirectURI == "" {
+ cfg.RedirectURI = p.GetRedirectURI()
+ }
+
+ storageConn, err := p.buildStorageConnector(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("failed to build connector: %w", err)
+ }
+
+ if err := p.storage.CreateConnector(ctx, storageConn); err != nil {
+ return nil, fmt.Errorf("failed to create connector: %w", err)
+ }
+
+ p.logger.Info("connector created", "id", cfg.ID, "type", cfg.Type)
+ return cfg, nil
+}
+
+// GetConnector retrieves a connector by ID from Dex storage.
+func (p *Provider) GetConnector(ctx context.Context, id string) (*ConnectorConfig, error) {
+ conn, err := p.storage.GetConnector(ctx, id)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ return nil, err
+ }
+ return nil, fmt.Errorf("failed to get connector: %w", err)
+ }
+
+ return p.parseStorageConnector(conn)
+}
+
+// ListConnectors returns all connectors from Dex storage (excluding the local connector).
+func (p *Provider) ListConnectors(ctx context.Context) ([]*ConnectorConfig, error) {
+ connectors, err := p.storage.ListConnectors(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list connectors: %w", err)
+ }
+
+ result := make([]*ConnectorConfig, 0, len(connectors))
+ for _, conn := range connectors {
+ // Skip the local password connector
+ if conn.ID == "local" && conn.Type == "local" {
+ continue
+ }
+
+ cfg, err := p.parseStorageConnector(conn)
+ if err != nil {
+ p.logger.Warn("failed to parse connector", "id", conn.ID, "error", err)
+ continue
+ }
+ result = append(result, cfg)
+ }
+
+ return result, nil
+}
+
+// UpdateConnector updates an existing connector in Dex storage.
+// It merges incoming updates with existing values to prevent data loss on partial updates.
+func (p *Provider) UpdateConnector(ctx context.Context, cfg *ConnectorConfig) error {
+ if err := p.storage.UpdateConnector(ctx, cfg.ID, func(old storage.Connector) (storage.Connector, error) {
+ oldCfg, err := p.parseStorageConnector(old)
+ if err != nil {
+ return storage.Connector{}, fmt.Errorf("failed to parse existing connector: %w", err)
+ }
+
+ mergeConnectorConfig(cfg, oldCfg)
+
+ storageConn, err := p.buildStorageConnector(cfg)
+ if err != nil {
+ return storage.Connector{}, fmt.Errorf("failed to build connector: %w", err)
+ }
+ return storageConn, nil
+ }); err != nil {
+ return fmt.Errorf("failed to update connector: %w", err)
+ }
+
+ p.logger.Info("connector updated", "id", cfg.ID, "type", cfg.Type)
+ return nil
+}
+
+// mergeConnectorConfig preserves existing values for empty fields in the update.
+func mergeConnectorConfig(cfg, oldCfg *ConnectorConfig) {
+ if cfg.ClientSecret == "" {
+ cfg.ClientSecret = oldCfg.ClientSecret
+ }
+ if cfg.RedirectURI == "" {
+ cfg.RedirectURI = oldCfg.RedirectURI
+ }
+ if cfg.Issuer == "" && cfg.Type == oldCfg.Type {
+ cfg.Issuer = oldCfg.Issuer
+ }
+ if cfg.ClientID == "" {
+ cfg.ClientID = oldCfg.ClientID
+ }
+ if cfg.Name == "" {
+ cfg.Name = oldCfg.Name
+ }
+}
+
+// DeleteConnector removes a connector from Dex storage.
+func (p *Provider) DeleteConnector(ctx context.Context, id string) error {
+ // Prevent deletion of the local connector
+ if id == "local" {
+ return fmt.Errorf("cannot delete the local password connector")
+ }
+
+ if err := p.storage.DeleteConnector(ctx, id); err != nil {
+ return fmt.Errorf("failed to delete connector: %w", err)
+ }
+
+ p.logger.Info("connector deleted", "id", id)
+ return nil
+}
+
+// GetRedirectURI returns the default redirect URI for connectors.
+func (p *Provider) GetRedirectURI() string {
+ if p.config == nil {
+ return ""
+ }
+ issuer := strings.TrimSuffix(p.config.Issuer, "/")
+ if !strings.HasSuffix(issuer, "/oauth2") {
+ issuer += "/oauth2"
+ }
+ return issuer + "/callback"
+}
+
+// buildStorageConnector creates a storage.Connector from ConnectorConfig.
+// It handles the type-specific configuration for each connector type.
+func (p *Provider) buildStorageConnector(cfg *ConnectorConfig) (storage.Connector, error) {
+ redirectURI := p.resolveRedirectURI(cfg.RedirectURI)
+
+ var dexType string
+ var configData []byte
+ var err error
+
+ switch cfg.Type {
+ case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak":
+ dexType = "oidc"
+ configData, err = buildOIDCConnectorConfig(cfg, redirectURI)
+ case "google":
+ dexType = "google"
+ configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI)
+ case "microsoft":
+ dexType = "microsoft"
+ configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI)
+ default:
+ return storage.Connector{}, fmt.Errorf("unsupported connector type: %s", cfg.Type)
+ }
+ if err != nil {
+ return storage.Connector{}, err
+ }
+
+ return storage.Connector{ID: cfg.ID, Type: dexType, Name: cfg.Name, Config: configData}, nil
+}
+
+// resolveRedirectURI returns the redirect URI, using a default if not provided
+func (p *Provider) resolveRedirectURI(redirectURI string) string {
+ if redirectURI != "" || p.config == nil {
+ return redirectURI
+ }
+ issuer := strings.TrimSuffix(p.config.Issuer, "/")
+ if !strings.HasSuffix(issuer, "/oauth2") {
+ issuer += "/oauth2"
+ }
+ return issuer + "/callback"
+}
+
+// buildOIDCConnectorConfig creates config for OIDC-based connectors
+func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) {
+ oidcConfig := map[string]interface{}{
+ "issuer": cfg.Issuer,
+ "clientID": cfg.ClientID,
+ "clientSecret": cfg.ClientSecret,
+ "redirectURI": redirectURI,
+ "scopes": []string{"openid", "profile", "email"},
+ "insecureEnableGroups": true,
+ //some providers don't return email verified, so we need to skip it if not present (e.g., Entra, Okta, Duo)
+ "insecureSkipEmailVerified": true,
+ }
+ switch cfg.Type {
+ case "zitadel":
+ oidcConfig["getUserInfo"] = true
+ case "entra":
+ oidcConfig["claimMapping"] = map[string]string{"email": "preferred_username"}
+ case "okta":
+ oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"}
+ case "pocketid":
+ oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"}
+ }
+ return encodeConnectorConfig(oidcConfig)
+}
+
+// buildOAuth2ConnectorConfig creates config for OAuth2 connectors (google, microsoft)
+func buildOAuth2ConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) {
+ return encodeConnectorConfig(map[string]interface{}{
+ "clientID": cfg.ClientID,
+ "clientSecret": cfg.ClientSecret,
+ "redirectURI": redirectURI,
+ })
+}
+
+// parseStorageConnector converts a storage.Connector back to ConnectorConfig.
+// It infers the original identity provider type from the Dex connector type and ID.
+func (p *Provider) parseStorageConnector(conn storage.Connector) (*ConnectorConfig, error) {
+ cfg := &ConnectorConfig{
+ ID: conn.ID,
+ Name: conn.Name,
+ }
+
+ if len(conn.Config) == 0 {
+ cfg.Type = conn.Type
+ return cfg, nil
+ }
+
+ var configMap map[string]interface{}
+ if err := decodeConnectorConfig(conn.Config, &configMap); err != nil {
+ return nil, fmt.Errorf("failed to parse connector config: %w", err)
+ }
+
+ // Extract common fields
+ if v, ok := configMap["clientID"].(string); ok {
+ cfg.ClientID = v
+ }
+ if v, ok := configMap["clientSecret"].(string); ok {
+ cfg.ClientSecret = v
+ }
+ if v, ok := configMap["redirectURI"].(string); ok {
+ cfg.RedirectURI = v
+ }
+ if v, ok := configMap["issuer"].(string); ok {
+ cfg.Issuer = v
+ }
+
+ // Infer the original identity provider type from Dex connector type and ID
+ cfg.Type = inferIdentityProviderType(conn.Type, conn.ID, configMap)
+
+ return cfg, nil
+}
+
+// inferIdentityProviderType determines the original identity provider type
+// based on the Dex connector type, connector ID, and configuration.
+func inferIdentityProviderType(dexType, connectorID string, _ map[string]interface{}) string {
+ if dexType != "oidc" {
+ return dexType
+ }
+ return inferOIDCProviderType(connectorID)
+}
+
+// inferOIDCProviderType infers the specific OIDC provider from connector ID
+func inferOIDCProviderType(connectorID string) string {
+ connectorIDLower := strings.ToLower(connectorID)
+ for _, provider := range []string{"pocketid", "zitadel", "entra", "okta", "authentik", "keycloak"} {
+ if strings.Contains(connectorIDLower, provider) {
+ return provider
+ }
+ }
+ return "oidc"
+}
+
+// encodeConnectorConfig serializes connector config to JSON bytes.
+func encodeConnectorConfig(config map[string]interface{}) ([]byte, error) {
+ return json.Marshal(config)
+}
+
+// decodeConnectorConfig deserializes connector config from JSON bytes.
+func decodeConnectorConfig(data []byte, v interface{}) error {
+ return json.Unmarshal(data, v)
+}
+
+// ensureLocalConnector creates a local (password) connector if it doesn't exist
+func ensureLocalConnector(ctx context.Context, stor storage.Storage) error {
+ // Check specifically for the local connector
+ _, err := stor.GetConnector(ctx, "local")
+ if err == nil {
+ // Local connector already exists
+ return nil
+ }
+ if !errors.Is(err, storage.ErrNotFound) {
+ return fmt.Errorf("failed to get local connector: %w", err)
+ }
+
+ // Create a local connector for password authentication
+ localConnector := storage.Connector{
+ ID: "local",
+ Type: "local",
+ Name: "Email",
+ }
+
+ if err := stor.CreateConnector(ctx, localConnector); err != nil {
+ return fmt.Errorf("failed to create local connector: %w", err)
+ }
+
+ return nil
+}
+
+// ensureStaticConnectors creates or updates static connectors in storage
+func ensureStaticConnectors(ctx context.Context, stor storage.Storage, connectors []Connector) error {
+ for _, conn := range connectors {
+ storConn, err := conn.ToStorageConnector()
+ if err != nil {
+ return fmt.Errorf("failed to convert connector %s: %w", conn.ID, err)
+ }
+ _, err = stor.GetConnector(ctx, conn.ID)
+ if err == storage.ErrNotFound {
+ if err := stor.CreateConnector(ctx, storConn); err != nil {
+ return fmt.Errorf("failed to create connector %s: %w", conn.ID, err)
+ }
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("failed to get connector %s: %w", conn.ID, err)
+ }
+ if err := stor.UpdateConnector(ctx, conn.ID, func(old storage.Connector) (storage.Connector, error) {
+ old.Name = storConn.Name
+ old.Config = storConn.Config
+ return old, nil
+ }); err != nil {
+ return fmt.Errorf("failed to update connector %s: %w", conn.ID, err)
+ }
+ }
+ return nil
+}
diff --git a/idp/dex/logrus_handler.go b/idp/dex/logrus_handler.go
new file mode 100644
index 000000000..d911cb417
--- /dev/null
+++ b/idp/dex/logrus_handler.go
@@ -0,0 +1,113 @@
+package dex
+
+import (
+ "context"
+ "log/slog"
+
+ "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/formatter"
+)
+
+// LogrusHandler is an slog.Handler that delegates to logrus.
+// This allows Dex to use the same log format as the rest of NetBird.
+type LogrusHandler struct {
+ logger *logrus.Logger
+ attrs []slog.Attr
+ groups []string
+}
+
+// NewLogrusHandler creates a new slog handler that wraps logrus with NetBird's text formatter.
+func NewLogrusHandler(level slog.Level) *LogrusHandler {
+ logger := logrus.New()
+ formatter.SetTextFormatter(logger)
+
+ // Map slog level to logrus level
+ switch level {
+ case slog.LevelDebug:
+ logger.SetLevel(logrus.DebugLevel)
+ case slog.LevelInfo:
+ logger.SetLevel(logrus.InfoLevel)
+ case slog.LevelWarn:
+ logger.SetLevel(logrus.WarnLevel)
+ case slog.LevelError:
+ logger.SetLevel(logrus.ErrorLevel)
+ default:
+ logger.SetLevel(logrus.WarnLevel)
+ }
+
+ return &LogrusHandler{logger: logger}
+}
+
+// Enabled reports whether the handler handles records at the given level.
+func (h *LogrusHandler) Enabled(_ context.Context, level slog.Level) bool {
+ switch level {
+ case slog.LevelDebug:
+ return h.logger.IsLevelEnabled(logrus.DebugLevel)
+ case slog.LevelInfo:
+ return h.logger.IsLevelEnabled(logrus.InfoLevel)
+ case slog.LevelWarn:
+ return h.logger.IsLevelEnabled(logrus.WarnLevel)
+ case slog.LevelError:
+ return h.logger.IsLevelEnabled(logrus.ErrorLevel)
+ default:
+ return true
+ }
+}
+
+// Handle handles the Record.
+func (h *LogrusHandler) Handle(_ context.Context, r slog.Record) error {
+ fields := make(logrus.Fields)
+
+ // Add pre-set attributes
+ for _, attr := range h.attrs {
+ fields[attr.Key] = attr.Value.Any()
+ }
+
+ // Add record attributes
+ r.Attrs(func(attr slog.Attr) bool {
+ fields[attr.Key] = attr.Value.Any()
+ return true
+ })
+
+ entry := h.logger.WithFields(fields)
+
+ switch r.Level {
+ case slog.LevelDebug:
+ entry.Debug(r.Message)
+ case slog.LevelInfo:
+ entry.Info(r.Message)
+ case slog.LevelWarn:
+ entry.Warn(r.Message)
+ case slog.LevelError:
+ entry.Error(r.Message)
+ default:
+ entry.Info(r.Message)
+ }
+
+ return nil
+}
+
+// WithAttrs returns a new Handler with the given attributes added.
+func (h *LogrusHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs))
+ copy(newAttrs, h.attrs)
+ copy(newAttrs[len(h.attrs):], attrs)
+ return &LogrusHandler{
+ logger: h.logger,
+ attrs: newAttrs,
+ groups: h.groups,
+ }
+}
+
+// WithGroup returns a new Handler with the given group appended to the receiver's groups.
+func (h *LogrusHandler) WithGroup(name string) slog.Handler {
+ newGroups := make([]string, len(h.groups)+1)
+ copy(newGroups, h.groups)
+ newGroups[len(h.groups)] = name
+ return &LogrusHandler{
+ logger: h.logger,
+ attrs: h.attrs,
+ groups: newGroups,
+ }
+}
diff --git a/idp/dex/provider.go b/idp/dex/provider.go
index 09713a226..6c608dbf5 100644
--- a/idp/dex/provider.go
+++ b/idp/dex/provider.go
@@ -4,7 +4,6 @@ package dex
import (
"context"
"encoding/base64"
- "encoding/json"
"errors"
"fmt"
"log/slog"
@@ -130,7 +129,21 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) {
// NewProviderFromYAML creates and initializes the Dex server from a YAMLConfig
func NewProviderFromYAML(ctx context.Context, yamlConfig *YAMLConfig) (*Provider, error) {
- logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+ // Configure log level from config, default to WARN to avoid logging sensitive data (emails)
+ logLevel := slog.LevelWarn
+ if yamlConfig.Logger.Level != "" {
+ switch strings.ToLower(yamlConfig.Logger.Level) {
+ case "debug":
+ logLevel = slog.LevelDebug
+ case "info":
+ logLevel = slog.LevelInfo
+ case "warn", "warning":
+ logLevel = slog.LevelWarn
+ case "error":
+ logLevel = slog.LevelError
+ }
+ }
+ logger := slog.New(NewLogrusHandler(logLevel))
stor, err := yamlConfig.Storage.OpenStorage(logger)
if err != nil {
@@ -231,34 +244,6 @@ func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []st
return nil
}
-// ensureStaticConnectors creates or updates static connectors in storage
-func ensureStaticConnectors(ctx context.Context, stor storage.Storage, connectors []Connector) error {
- for _, conn := range connectors {
- storConn, err := conn.ToStorageConnector()
- if err != nil {
- return fmt.Errorf("failed to convert connector %s: %w", conn.ID, err)
- }
- _, err = stor.GetConnector(ctx, conn.ID)
- if errors.Is(err, storage.ErrNotFound) {
- if err := stor.CreateConnector(ctx, storConn); err != nil {
- return fmt.Errorf("failed to create connector %s: %w", conn.ID, err)
- }
- continue
- }
- if err != nil {
- return fmt.Errorf("failed to get connector %s: %w", conn.ID, err)
- }
- if err := stor.UpdateConnector(ctx, conn.ID, func(old storage.Connector) (storage.Connector, error) {
- old.Name = storConn.Name
- old.Config = storConn.Config
- return old, nil
- }); err != nil {
- return fmt.Errorf("failed to update connector %s: %w", conn.ID, err)
- }
- }
- return nil
-}
-
// buildDexConfig creates a server.Config with defaults applied
func buildDexConfig(yamlConfig *YAMLConfig, stor storage.Storage, logger *slog.Logger) server.Config {
cfg := yamlConfig.ToServerConfig(stor, logger)
@@ -599,290 +584,37 @@ func (p *Provider) ListUsers(ctx context.Context) ([]storage.Password, error) {
return p.storage.ListPasswords(ctx)
}
-// ensureLocalConnector creates a local (password) connector if none exists
-func ensureLocalConnector(ctx context.Context, stor storage.Storage) error {
- connectors, err := stor.ListConnectors(ctx)
+// UpdateUserPassword updates the password for a user identified by userID.
+// The userID can be either an encoded Dex ID (base64 protobuf) or a raw UUID.
+// It verifies the current password before updating.
+func (p *Provider) UpdateUserPassword(ctx context.Context, userID string, oldPassword, newPassword string) error {
+ // Get the user by ID to find their email
+ user, err := p.GetUserByID(ctx, userID)
if err != nil {
- return fmt.Errorf("failed to list connectors: %w", err)
+ return fmt.Errorf("failed to get user: %w", err)
}
- // If any connector exists, we're good
- if len(connectors) > 0 {
- return nil
+ // Verify old password
+ if err := bcrypt.CompareHashAndPassword(user.Hash, []byte(oldPassword)); err != nil {
+ return fmt.Errorf("current password is incorrect")
}
- // Create a local connector for password authentication
- localConnector := storage.Connector{
- ID: "local",
- Type: "local",
- Name: "Email",
- }
-
- if err := stor.CreateConnector(ctx, localConnector); err != nil {
- return fmt.Errorf("failed to create local connector: %w", err)
- }
-
- return nil
-}
-
-// ConnectorConfig represents the configuration for an identity provider connector
-type ConnectorConfig struct {
- // ID is the unique identifier for the connector
- ID string
- // Name is a human-readable name for the connector
- Name string
- // Type is the connector type (oidc, google, microsoft)
- Type string
- // Issuer is the OIDC issuer URL (for OIDC-based connectors)
- Issuer string
- // ClientID is the OAuth2 client ID
- ClientID string
- // ClientSecret is the OAuth2 client secret
- ClientSecret string
- // RedirectURI is the OAuth2 redirect URI
- RedirectURI string
-}
-
-// CreateConnector creates a new connector in Dex storage.
-// It maps the connector config to the appropriate Dex connector type and configuration.
-func (p *Provider) CreateConnector(ctx context.Context, cfg *ConnectorConfig) (*ConnectorConfig, error) {
- // Fill in the redirect URI if not provided
- if cfg.RedirectURI == "" {
- cfg.RedirectURI = p.GetRedirectURI()
- }
-
- storageConn, err := p.buildStorageConnector(cfg)
+ // Hash the new password
+ newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
- return nil, fmt.Errorf("failed to build connector: %w", err)
+ return fmt.Errorf("failed to hash new password: %w", err)
}
- if err := p.storage.CreateConnector(ctx, storageConn); err != nil {
- return nil, fmt.Errorf("failed to create connector: %w", err)
- }
-
- p.logger.Info("connector created", "id", cfg.ID, "type", cfg.Type)
- return cfg, nil
-}
-
-// GetConnector retrieves a connector by ID from Dex storage.
-func (p *Provider) GetConnector(ctx context.Context, id string) (*ConnectorConfig, error) {
- conn, err := p.storage.GetConnector(ctx, id)
- if err != nil {
- if err == storage.ErrNotFound {
- return nil, err
- }
- return nil, fmt.Errorf("failed to get connector: %w", err)
- }
-
- return p.parseStorageConnector(conn)
-}
-
-// ListConnectors returns all connectors from Dex storage (excluding the local connector).
-func (p *Provider) ListConnectors(ctx context.Context) ([]*ConnectorConfig, error) {
- connectors, err := p.storage.ListConnectors(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to list connectors: %w", err)
- }
-
- result := make([]*ConnectorConfig, 0, len(connectors))
- for _, conn := range connectors {
- // Skip the local password connector
- if conn.ID == "local" && conn.Type == "local" {
- continue
- }
-
- cfg, err := p.parseStorageConnector(conn)
- if err != nil {
- p.logger.Warn("failed to parse connector", "id", conn.ID, "error", err)
- continue
- }
- result = append(result, cfg)
- }
-
- return result, nil
-}
-
-// UpdateConnector updates an existing connector in Dex storage.
-func (p *Provider) UpdateConnector(ctx context.Context, cfg *ConnectorConfig) error {
- storageConn, err := p.buildStorageConnector(cfg)
- if err != nil {
- return fmt.Errorf("failed to build connector: %w", err)
- }
-
- if err := p.storage.UpdateConnector(ctx, cfg.ID, func(old storage.Connector) (storage.Connector, error) {
- return storageConn, nil
- }); err != nil {
- return fmt.Errorf("failed to update connector: %w", err)
- }
-
- p.logger.Info("connector updated", "id", cfg.ID, "type", cfg.Type)
- return nil
-}
-
-// DeleteConnector removes a connector from Dex storage.
-func (p *Provider) DeleteConnector(ctx context.Context, id string) error {
- // Prevent deletion of the local connector
- if id == "local" {
- return fmt.Errorf("cannot delete the local password connector")
- }
-
- if err := p.storage.DeleteConnector(ctx, id); err != nil {
- return fmt.Errorf("failed to delete connector: %w", err)
- }
-
- p.logger.Info("connector deleted", "id", id)
- return nil
-}
-
-// buildStorageConnector creates a storage.Connector from ConnectorConfig.
-// It handles the type-specific configuration for each connector type.
-func (p *Provider) buildStorageConnector(cfg *ConnectorConfig) (storage.Connector, error) {
- redirectURI := p.resolveRedirectURI(cfg.RedirectURI)
-
- var dexType string
- var configData []byte
- var err error
-
- switch cfg.Type {
- case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak":
- dexType = "oidc"
- configData, err = buildOIDCConnectorConfig(cfg, redirectURI)
- case "google":
- dexType = "google"
- configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI)
- case "microsoft":
- dexType = "microsoft"
- configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI)
- default:
- return storage.Connector{}, fmt.Errorf("unsupported connector type: %s", cfg.Type)
- }
- if err != nil {
- return storage.Connector{}, err
- }
-
- return storage.Connector{ID: cfg.ID, Type: dexType, Name: cfg.Name, Config: configData}, nil
-}
-
-// resolveRedirectURI returns the redirect URI, using a default if not provided
-func (p *Provider) resolveRedirectURI(redirectURI string) string {
- if redirectURI != "" || p.config == nil {
- return redirectURI
- }
- issuer := strings.TrimSuffix(p.config.Issuer, "/")
- if !strings.HasSuffix(issuer, "/oauth2") {
- issuer += "/oauth2"
- }
- return issuer + "/callback"
-}
-
-// buildOIDCConnectorConfig creates config for OIDC-based connectors
-func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) {
- oidcConfig := map[string]interface{}{
- "issuer": cfg.Issuer,
- "clientID": cfg.ClientID,
- "clientSecret": cfg.ClientSecret,
- "redirectURI": redirectURI,
- "scopes": []string{"openid", "profile", "email"},
- }
- switch cfg.Type {
- case "zitadel":
- oidcConfig["getUserInfo"] = true
- case "entra":
- oidcConfig["insecureSkipEmailVerified"] = true
- oidcConfig["claimMapping"] = map[string]string{"email": "preferred_username"}
- case "okta":
- oidcConfig["insecureSkipEmailVerified"] = true
- }
- return encodeConnectorConfig(oidcConfig)
-}
-
-// buildOAuth2ConnectorConfig creates config for OAuth2 connectors (google, microsoft)
-func buildOAuth2ConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) {
- return encodeConnectorConfig(map[string]interface{}{
- "clientID": cfg.ClientID,
- "clientSecret": cfg.ClientSecret,
- "redirectURI": redirectURI,
+ // Update the password in storage
+ err = p.storage.UpdatePassword(ctx, user.Email, func(old storage.Password) (storage.Password, error) {
+ old.Hash = newHash
+ return old, nil
})
-}
-
-// parseStorageConnector converts a storage.Connector back to ConnectorConfig.
-// It infers the original identity provider type from the Dex connector type and ID.
-func (p *Provider) parseStorageConnector(conn storage.Connector) (*ConnectorConfig, error) {
- cfg := &ConnectorConfig{
- ID: conn.ID,
- Name: conn.Name,
+ if err != nil {
+ return fmt.Errorf("failed to update password: %w", err)
}
- if len(conn.Config) == 0 {
- cfg.Type = conn.Type
- return cfg, nil
- }
-
- var configMap map[string]interface{}
- if err := decodeConnectorConfig(conn.Config, &configMap); err != nil {
- return nil, fmt.Errorf("failed to parse connector config: %w", err)
- }
-
- // Extract common fields
- if v, ok := configMap["clientID"].(string); ok {
- cfg.ClientID = v
- }
- if v, ok := configMap["clientSecret"].(string); ok {
- cfg.ClientSecret = v
- }
- if v, ok := configMap["redirectURI"].(string); ok {
- cfg.RedirectURI = v
- }
- if v, ok := configMap["issuer"].(string); ok {
- cfg.Issuer = v
- }
-
- // Infer the original identity provider type from Dex connector type and ID
- cfg.Type = inferIdentityProviderType(conn.Type, conn.ID, configMap)
-
- return cfg, nil
-}
-
-// inferIdentityProviderType determines the original identity provider type
-// based on the Dex connector type, connector ID, and configuration.
-func inferIdentityProviderType(dexType, connectorID string, _ map[string]interface{}) string {
- if dexType != "oidc" {
- return dexType
- }
- return inferOIDCProviderType(connectorID)
-}
-
-// inferOIDCProviderType infers the specific OIDC provider from connector ID
-func inferOIDCProviderType(connectorID string) string {
- connectorIDLower := strings.ToLower(connectorID)
- for _, provider := range []string{"pocketid", "zitadel", "entra", "okta", "authentik", "keycloak"} {
- if strings.Contains(connectorIDLower, provider) {
- return provider
- }
- }
- return "oidc"
-}
-
-// encodeConnectorConfig serializes connector config to JSON bytes.
-func encodeConnectorConfig(config map[string]interface{}) ([]byte, error) {
- return json.Marshal(config)
-}
-
-// decodeConnectorConfig deserializes connector config from JSON bytes.
-func decodeConnectorConfig(data []byte, v interface{}) error {
- return json.Unmarshal(data, v)
-}
-
-// GetRedirectURI returns the default redirect URI for connectors.
-func (p *Provider) GetRedirectURI() string {
- if p.config == nil {
- return ""
- }
- issuer := strings.TrimSuffix(p.config.Issuer, "/")
- if !strings.HasSuffix(issuer, "/oauth2") {
- issuer += "/oauth2"
- }
- return issuer + "/callback"
+ return nil
}
// GetIssuer returns the OIDC issuer URL.
diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh
index b693f807e..25599997c 100755
--- a/infrastructure_files/getting-started.sh
+++ b/infrastructure_files/getting-started.sh
@@ -9,6 +9,16 @@ set -e
# Sed pattern to strip base64 padding characters
SED_STRIP_PADDING='s/=//g'
+# Constants for repeated string literals
+readonly MSG_STARTING_SERVICES="\nStarting NetBird services\n"
+readonly MSG_DONE="\nDone!\n"
+readonly MSG_NEXT_STEPS="Next steps:"
+readonly MSG_SEPARATOR="=========================================="
+
+############################################
+# Utility Functions
+############################################
+
check_docker_compose() {
if command -v docker-compose &> /dev/null
then
@@ -72,13 +82,103 @@ read_nb_domain() {
return 0
}
-get_turn_external_ip() {
- TURN_EXTERNAL_IP_CONFIG="#external-ip="
- IP=$(curl -s -4 https://jsonip.com | jq -r '.ip')
- if [[ "x-$IP" != "x-" ]]; then
- TURN_EXTERNAL_IP_CONFIG="external-ip=$IP"
+read_reverse_proxy_type() {
+ echo "" > /dev/stderr
+ echo "Which reverse proxy will you use?" > /dev/stderr
+ echo " [0] Built-in Caddy (recommended - automatic TLS)" > /dev/stderr
+ echo " [1] Traefik (labels added to containers)" > /dev/stderr
+ echo " [2] Nginx (generates config template)" > /dev/stderr
+ echo " [3] Nginx Proxy Manager (generates config + instructions)" > /dev/stderr
+ echo " [4] External Caddy (generates Caddyfile snippet)" > /dev/stderr
+ echo " [5] Other/Manual (displays setup documentation)" > /dev/stderr
+ echo "" > /dev/stderr
+ echo -n "Enter choice [0-5] (default: 0): " > /dev/stderr
+ read -r CHOICE < /dev/tty
+
+ if [[ -z "$CHOICE" ]]; then
+ CHOICE="0"
fi
- echo "$TURN_EXTERNAL_IP_CONFIG"
+
+ if [[ ! "$CHOICE" =~ ^[0-5]$ ]]; then
+ echo "Invalid choice. Please enter a number between 0 and 5." > /dev/stderr
+ read_reverse_proxy_type
+ return
+ fi
+
+ echo "$CHOICE"
+ return 0
+}
+
+read_traefik_network() {
+ echo "" > /dev/stderr
+ echo "If you have an existing Traefik instance, enter its external network name." > /dev/stderr
+ echo -n "External network (leave empty to create 'netbird' network): " > /dev/stderr
+ read -r NETWORK < /dev/tty
+ echo "$NETWORK"
+ return 0
+}
+
+read_traefik_entrypoint() {
+ echo "" > /dev/stderr
+ echo "Enter the name of your Traefik HTTPS entrypoint." > /dev/stderr
+ echo -n "HTTPS entrypoint name (default: websecure): " > /dev/stderr
+ read -r ENTRYPOINT < /dev/tty
+ if [[ -z "$ENTRYPOINT" ]]; then
+ ENTRYPOINT="websecure"
+ fi
+ echo "$ENTRYPOINT"
+ return 0
+}
+
+read_traefik_certresolver() {
+ echo "" > /dev/stderr
+ echo "Enter the name of your Traefik certificate resolver (for automatic TLS)." > /dev/stderr
+ echo "Leave empty if you handle TLS termination elsewhere or use a wildcard cert." > /dev/stderr
+ echo -n "Certificate resolver name (e.g., letsencrypt): " > /dev/stderr
+ read -r RESOLVER < /dev/tty
+ echo "$RESOLVER"
+ return 0
+}
+
+read_port_binding_preference() {
+ echo "" > /dev/stderr
+ echo "Should container ports be bound to localhost only (127.0.0.1)?" > /dev/stderr
+ echo "Choose 'yes' if your reverse proxy runs on the same host (more secure)." > /dev/stderr
+ echo -n "Bind to localhost only? [Y/n]: " > /dev/stderr
+ read -r CHOICE < /dev/tty
+
+ if [[ "$CHOICE" =~ ^[Nn]$ ]]; then
+ echo "false"
+ else
+ echo "true"
+ fi
+ return 0
+}
+
+read_proxy_docker_network() {
+ local proxy_name="$1"
+ echo "" > /dev/stderr
+ echo "Is ${proxy_name} running in Docker?" > /dev/stderr
+ echo "If yes, enter the Docker network ${proxy_name} is on (NetBird will join it)." > /dev/stderr
+ echo -n "Docker network (leave empty if not in Docker): " > /dev/stderr
+ read -r NETWORK < /dev/tty
+ echo "$NETWORK"
+ return 0
+}
+
+get_bind_address() {
+ if [[ "$BIND_LOCALHOST_ONLY" == "true" ]]; then
+ echo "127.0.0.1"
+ else
+ echo "0.0.0.0"
+ fi
+ return 0
+}
+
+get_upstream_host() {
+ # Always return 127.0.0.1 for health checks and upstream targets
+ # Cannot use 0.0.0.0 as a connection target
+ echo "127.0.0.1"
return 0
}
@@ -106,20 +206,67 @@ wait_management() {
return 0
}
-init_environment() {
+wait_management_direct() {
+ set +e
+ local upstream_host=$(get_upstream_host)
+ echo -n "Waiting for Management server to become ready"
+ counter=1
+ while true; do
+ # Check the embedded IdP endpoint directly (no reverse proxy)
+ if curl -sk -f -o /dev/null "http://${upstream_host}:${MANAGEMENT_HOST_PORT}/oauth2/.well-known/openid-configuration" 2>/dev/null; then
+ break
+ fi
+ if [[ $counter -eq 60 ]]; then
+ echo ""
+ echo "Taking too long. Checking logs..."
+ $DOCKER_COMPOSE_COMMAND logs --tail=20 management
+ fi
+ echo -n " ."
+ sleep 2
+ counter=$((counter + 1))
+ done
+ echo " done"
+ set -e
+ return 0
+}
+
+############################################
+# Initialization and Configuration
+############################################
+
+initialize_default_values() {
CADDY_SECURE_DOMAIN=""
NETBIRD_PORT=80
NETBIRD_HTTP_PROTOCOL="http"
NETBIRD_RELAY_PROTO="rel"
- TURN_USER="self"
- TURN_PASSWORD=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
# Note: DataStoreEncryptionKey must keep base64 padding (=) for Go's base64.StdEncoding
DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32)
- TURN_MIN_PORT=49152
- TURN_MAX_PORT=65535
- TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip)
+ NETBIRD_STUN_PORT=3478
+ # Docker images
+ CADDY_IMAGE="caddy"
+ DASHBOARD_IMAGE="netbirdio/dashboard:latest"
+ SIGNAL_IMAGE="netbirdio/signal:latest"
+ RELAY_IMAGE="netbirdio/relay:latest"
+ MANAGEMENT_IMAGE="netbirdio/management:latest"
+
+ # Reverse proxy configuration
+ REVERSE_PROXY_TYPE="0"
+ TRAEFIK_EXTERNAL_NETWORK=""
+ TRAEFIK_ENTRYPOINT="websecure"
+ TRAEFIK_CERTRESOLVER=""
+ DASHBOARD_HOST_PORT="8080"
+ MANAGEMENT_HOST_PORT="8081"
+ SIGNAL_HOST_PORT="8083"
+ SIGNAL_GRPC_PORT="10000"
+ RELAY_HOST_PORT="8084"
+ BIND_LOCALHOST_ONLY="true"
+ EXTERNAL_PROXY_NETWORK=""
+ return 0
+}
+
+configure_domain() {
if ! check_nb_domain "$NETBIRD_DOMAIN"; then
NETBIRD_DOMAIN=$(read_nb_domain)
fi
@@ -132,41 +279,168 @@ init_environment() {
NETBIRD_HTTP_PROTOCOL="https"
NETBIRD_RELAY_PROTO="rels"
fi
+ return 0
+}
- check_jq
+configure_reverse_proxy() {
+ # Prompt for reverse proxy type
+ REVERSE_PROXY_TYPE=$(read_reverse_proxy_type)
- DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+ # Handle Traefik-specific prompts
+ if [[ "$REVERSE_PROXY_TYPE" == "1" ]]; then
+ TRAEFIK_EXTERNAL_NETWORK=$(read_traefik_network)
+ TRAEFIK_ENTRYPOINT=$(read_traefik_entrypoint)
+ TRAEFIK_CERTRESOLVER=$(read_traefik_certresolver)
+ fi
+ # Handle port binding for external proxy options (2-5)
+ if [[ "$REVERSE_PROXY_TYPE" -ge 2 ]]; then
+ BIND_LOCALHOST_ONLY=$(read_port_binding_preference)
+ fi
+
+ # Handle Docker network prompts for external proxies (options 2-4)
+ case "$REVERSE_PROXY_TYPE" in
+ 2) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Nginx") ;;
+ 3) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Nginx Proxy Manager") ;;
+ 4) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Caddy") ;;
+ *) ;; # No network prompt for other options
+ esac
+ return 0
+}
+
+check_existing_installation() {
if [[ -f management.json ]]; then
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
- echo " rm -f docker-compose.yml Caddyfile dashboard.env turnserver.conf management.json relay.env"
+ echo " rm -f docker-compose.yml Caddyfile dashboard.env management.json relay.env nginx-netbird.conf caddyfile-netbird.txt npm-advanced-config.txt"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
-
- echo Rendering initial files...
- render_docker_compose > docker-compose.yml
- render_caddyfile > Caddyfile
- render_dashboard_env > dashboard.env
- render_management_json > management.json
- render_turn_server_conf > turnserver.conf
- render_relay_env > relay.env
-
- echo -e "\nStarting NetBird services\n"
- $DOCKER_COMPOSE_COMMAND up -d
-
- # Wait for management (and embedded IdP) to be ready
- sleep 3
- wait_management
-
- echo -e "\nDone!\n"
- echo "You can access the NetBird dashboard at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN"
- echo "Follow the onboarding steps to set up your NetBird instance."
return 0
}
+generate_configuration_files() {
+ echo Rendering initial files...
+
+ # Render docker-compose and proxy config based on selection
+ case "$REVERSE_PROXY_TYPE" in
+ 0)
+ render_docker_compose > docker-compose.yml
+ render_caddyfile > Caddyfile
+ ;;
+ 1)
+ render_docker_compose_traefik > docker-compose.yml
+ ;;
+ 2)
+ render_docker_compose_exposed_ports > docker-compose.yml
+ render_nginx_conf > nginx-netbird.conf
+ ;;
+ 3)
+ render_docker_compose_exposed_ports > docker-compose.yml
+ render_npm_advanced_config > npm-advanced-config.txt
+ ;;
+ 4)
+ render_docker_compose_exposed_ports > docker-compose.yml
+ render_external_caddyfile > caddyfile-netbird.txt
+ ;;
+ 5)
+ render_docker_compose_exposed_ports > docker-compose.yml
+ ;;
+ *)
+ echo "Invalid reverse proxy type: $REVERSE_PROXY_TYPE" > /dev/stderr
+ exit 1
+ ;;
+ esac
+
+ # Common files for all configurations
+ render_dashboard_env > dashboard.env
+ render_management_json > management.json
+ render_relay_env > relay.env
+ return 0
+}
+
+start_services_and_show_instructions() {
+ # For built-in Caddy and Traefik, start containers immediately
+ # For NPM, start containers first (NPM needs services running to create proxy)
+ # For other external proxies, show instructions first and wait for user confirmation
+ if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then
+ # Built-in Caddy - handles everything automatically
+ echo -e "$MSG_STARTING_SERVICES"
+ $DOCKER_COMPOSE_COMMAND up -d
+
+ sleep 3
+ wait_management
+
+ echo -e "$MSG_DONE"
+ print_post_setup_instructions
+ elif [[ "$REVERSE_PROXY_TYPE" == "1" ]]; then
+ # Traefik - start containers first, then show instructions
+ # Traefik discovers services via Docker labels, so containers must be running
+ echo -e "$MSG_STARTING_SERVICES"
+ $DOCKER_COMPOSE_COMMAND up -d
+
+ sleep 3
+ wait_management_direct
+
+ echo -e "$MSG_DONE"
+ print_post_setup_instructions
+ echo ""
+ echo "NetBird containers are running. Once Traefik is connected, access the dashboard at:"
+ echo " $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN"
+ elif [[ "$REVERSE_PROXY_TYPE" == "3" ]]; then
+ # NPM - start containers first, then show instructions
+ # NPM requires backend services to be running before creating proxy hosts
+ echo -e "$MSG_STARTING_SERVICES"
+ $DOCKER_COMPOSE_COMMAND up -d
+
+ sleep 3
+ wait_management_direct
+
+ echo -e "$MSG_DONE"
+ print_post_setup_instructions
+ echo ""
+ echo "NetBird containers are running. Configure NPM as shown above, then access:"
+ echo " $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN"
+ else
+ # External proxies (nginx, external Caddy, other) - need manual config first
+ print_post_setup_instructions
+
+ echo ""
+ echo -n "Press Enter when your reverse proxy is configured (or Ctrl+C to exit)... "
+ read -r < /dev/tty
+
+ echo -e "$MSG_STARTING_SERVICES"
+ $DOCKER_COMPOSE_COMMAND up -d
+
+ sleep 3
+ wait_management_direct
+
+ echo -e "$MSG_DONE"
+ echo "NetBird is now running. Access the dashboard at:"
+ echo " $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN"
+ fi
+ return 0
+}
+
+init_environment() {
+ initialize_default_values
+ configure_domain
+ configure_reverse_proxy
+
+ check_jq
+ DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+
+ check_existing_installation
+ generate_configuration_files
+ start_services_and_show_instructions
+ return 0
+}
+
+############################################
+# Configuration File Renderers
+############################################
+
render_caddyfile() {
cat < ${upstream_host}:${RELAY_HOST_PORT}"
+ echo " (HTTP with WebSocket upgrade)"
+ echo ""
+ echo " /ws-proxy/signal* -> ${upstream_host}:${SIGNAL_HOST_PORT}"
+ echo " (HTTP with WebSocket upgrade)"
+ echo ""
+ echo " /signalexchange.SignalExchange/* -> ${upstream_host}:${SIGNAL_GRPC_PORT}"
+ echo " (gRPC/h2c - plaintext HTTP/2)"
+ echo ""
+ echo " /api/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}"
+ echo " (HTTP)"
+ echo ""
+ echo " /ws-proxy/management* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}"
+ echo " (HTTP with WebSocket upgrade)"
+ echo ""
+ echo " /management.ManagementService/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}"
+ echo " (gRPC/h2c - plaintext HTTP/2)"
+ echo ""
+ echo " /oauth2/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}"
+ echo " (HTTP - embedded IdP)"
+ echo ""
+ echo " /* -> ${upstream_host}:${DASHBOARD_HOST_PORT}"
+ echo " (HTTP - catch-all for dashboard)"
+ echo ""
+ echo "IMPORTANT: gRPC routes require HTTP/2 (h2c) upstream support."
+ echo "Long-running connections need extended timeouts (recommend 1 day)."
+ return 0
+}
+
+print_post_setup_instructions() {
+ case "$REVERSE_PROXY_TYPE" in
+ 0)
+ print_caddy_instructions
+ ;;
+ 1)
+ print_traefik_instructions
+ ;;
+ 2)
+ print_nginx_instructions
+ ;;
+ 3)
+ print_npm_instructions
+ ;;
+ 4)
+ print_external_caddy_instructions
+ ;;
+ 5)
+ print_manual_instructions
+ ;;
+ *)
+ echo "Unknown reverse proxy type: $REVERSE_PROXY_TYPE" > /dev/stderr
+ ;;
+ esac
+ return 0
+}
+
init_environment
diff --git a/management/cmd/management.go b/management/cmd/management.go
index 5391b0866..7da04074b 100644
--- a/management/cmd/management.go
+++ b/management/cmd/management.go
@@ -143,7 +143,7 @@ func loadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Confi
applyCommandLineOverrides(loadedConfig)
// Apply EmbeddedIdP config to HttpConfig if embedded IdP is enabled
- err := applyEmbeddedIdPConfig(loadedConfig)
+ err := applyEmbeddedIdPConfig(ctx, loadedConfig)
if err != nil {
return nil, err
}
@@ -177,7 +177,7 @@ func applyCommandLineOverrides(cfg *nbconfig.Config) {
// applyEmbeddedIdPConfig populates HttpConfig and EmbeddedIdP storage from config when embedded IdP is enabled.
// This allows users to only specify EmbeddedIdP config without duplicating values in HttpConfig.
-func applyEmbeddedIdPConfig(cfg *nbconfig.Config) error {
+func applyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error {
if cfg.EmbeddedIdP == nil || !cfg.EmbeddedIdP.Enabled {
return nil
}
@@ -190,10 +190,8 @@ func applyEmbeddedIdPConfig(cfg *nbconfig.Config) error {
// Enable user deletion from IDP by default if EmbeddedIdP is enabled
userDeleteFromIDPEnabled = true
- // Ensure HttpConfig exists
- if cfg.HttpConfig == nil {
- cfg.HttpConfig = &nbconfig.HttpServerConfig{}
- }
+ // Set LocalAddress for embedded IdP if enabled, used for internal JWT validation
+ cfg.EmbeddedIdP.LocalAddress = fmt.Sprintf("localhost:%d", mgmtPort)
// Set storage defaults based on Datadir
if cfg.EmbeddedIdP.Storage.Type == "" {
@@ -205,40 +203,22 @@ func applyEmbeddedIdPConfig(cfg *nbconfig.Config) error {
issuer := cfg.EmbeddedIdP.Issuer
- // Set AuthIssuer from EmbeddedIdP issuer
- if cfg.HttpConfig.AuthIssuer == "" {
- cfg.HttpConfig.AuthIssuer = issuer
+ if cfg.HttpConfig != nil {
+ log.WithContext(ctx).Warnf("overriding HttpConfig with EmbeddedIdP config. " +
+ "HttpConfig is ignored when EmbeddedIdP is enabled. Please remove HttpConfig section from the config file")
+ } else {
+ // Ensure HttpConfig exists. We need it for backwards compatibility with the old config format.
+ cfg.HttpConfig = &nbconfig.HttpServerConfig{}
}
- // Set AuthAudience to the dashboard client ID
- if cfg.HttpConfig.AuthAudience == "" {
- cfg.HttpConfig.AuthAudience = "netbird-dashboard"
- }
-
- // Set CLIAuthAudience to the client app client ID
- if cfg.HttpConfig.CLIAuthAudience == "" {
- cfg.HttpConfig.CLIAuthAudience = "netbird-cli"
- }
-
- // Set AuthUserIDClaim to "sub" (standard OIDC claim)
- if cfg.HttpConfig.AuthUserIDClaim == "" {
- cfg.HttpConfig.AuthUserIDClaim = "sub"
- }
-
- // Set AuthKeysLocation to the JWKS endpoint
- if cfg.HttpConfig.AuthKeysLocation == "" {
- cfg.HttpConfig.AuthKeysLocation = issuer + "/keys"
- }
-
- // Set OIDCConfigEndpoint to the discovery endpoint
- if cfg.HttpConfig.OIDCConfigEndpoint == "" {
- cfg.HttpConfig.OIDCConfigEndpoint = issuer + "/.well-known/openid-configuration"
- }
-
- // Copy SignKeyRefreshEnabled from EmbeddedIdP config
- if cfg.EmbeddedIdP.SignKeyRefreshEnabled {
- cfg.HttpConfig.IdpSignKeyRefreshEnabled = true
- }
+ // Set HttpConfig values from EmbeddedIdP
+ cfg.HttpConfig.AuthIssuer = issuer
+ cfg.HttpConfig.AuthAudience = "netbird-dashboard"
+ cfg.HttpConfig.CLIAuthAudience = "netbird-cli"
+ cfg.HttpConfig.AuthUserIDClaim = "sub"
+ cfg.HttpConfig.AuthKeysLocation = issuer + "/keys"
+ cfg.HttpConfig.OIDCConfigEndpoint = issuer + "/.well-known/openid-configuration"
+ cfg.HttpConfig.IdpSignKeyRefreshEnabled = true
return nil
}
@@ -246,7 +226,12 @@ func applyEmbeddedIdPConfig(cfg *nbconfig.Config) error {
// applyOIDCConfig fetches and applies OIDC configuration if endpoint is specified
func applyOIDCConfig(ctx context.Context, cfg *nbconfig.Config) error {
oidcEndpoint := cfg.HttpConfig.OIDCConfigEndpoint
- if oidcEndpoint == "" || cfg.EmbeddedIdP != nil {
+ if oidcEndpoint == "" {
+ return nil
+ }
+
+ if cfg.EmbeddedIdP != nil && cfg.EmbeddedIdP.Enabled {
+ // skip OIDC config fetching if EmbeddedIdP is enabled as it is unnecessary given it is embedded
return nil
}
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index f051e5331..d46737c26 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -20,6 +20,7 @@ import (
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/account"
@@ -175,7 +176,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
dnsCache := &cache.DNSConfigCache{}
dnsDomain := c.GetDNSDomain(account.Settings)
- customZone := account.GetPeersCustomZone(ctx, dnsDomain)
+ peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
groupIDToUserIDs := account.GetActiveGroupUsers()
@@ -197,6 +198,12 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
+ accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
+ return fmt.Errorf("failed to get account zones: %v", err)
+ }
+
for _, peer := range account.Peers {
if !c.peersUpdateManager.HasChannel(peer.ID) {
log.WithContext(ctx).Tracef("peer %s doesn't have a channel, skipping network map update", peer.ID)
@@ -223,9 +230,9 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
var remotePeerNetworkMap *types.NetworkMap
if c.experimentalNetworkMap(accountID) {
- remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, p.AccountID, p.ID, approvedPeersMap, customZone, c.accountManagerMetrics)
+ remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, p.AccountID, p.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics)
} else {
- remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, p.ID, customZone, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
+ remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
}
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
@@ -318,7 +325,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
dnsCache := &cache.DNSConfigCache{}
dnsDomain := c.GetDNSDomain(account.Settings)
- customZone := account.GetPeersCustomZone(ctx, dnsDomain)
+ peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
groupIDToUserIDs := account.GetActiveGroupUsers()
@@ -335,12 +342,18 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
return err
}
+ accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
+ return err
+ }
+
var remotePeerNetworkMap *types.NetworkMap
if c.experimentalNetworkMap(accountId) {
- remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, customZone, c.accountManagerMetrics)
+ remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics)
} else {
- remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, peerId, customZone, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
+ remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
}
proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
@@ -434,7 +447,14 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
}
log.WithContext(ctx).Debugf("getPeerPostureChecks took %s", time.Since(startPosture))
- customZone := account.GetPeersCustomZone(ctx, c.GetDNSDomain(account.Settings))
+ accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
+ return nil, nil, nil, 0, err
+ }
+
+ dnsDomain := c.GetDNSDomain(account.Settings)
+ peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers)
if err != nil {
@@ -445,11 +465,11 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
var networkMap *types.NetworkMap
if c.experimentalNetworkMap(accountID) {
- networkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, customZone, c.accountManagerMetrics)
+ networkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics)
} else {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
- networkMap = account.GetPeerNetworkMap(ctx, peer.ID, customZone, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, account.GetActiveGroupUsers())
+ networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, account.GetActiveGroupUsers())
}
proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
@@ -472,7 +492,8 @@ func (c *Controller) getPeerNetworkMapExp(
accountId string,
peerId string,
validatedPeers map[string]struct{},
- customZone nbdns.CustomZone,
+ peersCustomZone nbdns.CustomZone,
+ accountZones []*zones.Zone,
metrics *telemetry.AccountManagerMetrics,
) *types.NetworkMap {
account := c.getAccountFromHolderOrInit(ctx, accountId)
@@ -483,7 +504,7 @@ func (c *Controller) getPeerNetworkMapExp(
}
}
- return account.GetPeerNetworkMapExp(ctx, peerId, customZone, validatedPeers, metrics)
+ return account.GetPeerNetworkMapExp(ctx, peerId, peersCustomZone, accountZones, validatedPeers, metrics)
}
func (c *Controller) onPeersAddedUpdNetworkMapCache(account *types.Account, peerIds ...string) {
@@ -798,7 +819,15 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
if err != nil {
return nil, err
}
- customZone := account.GetPeersCustomZone(ctx, c.GetDNSDomain(account.Settings))
+
+ accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
+ return nil, err
+ }
+
+ dnsDomain := c.GetDNSDomain(account.Settings)
+ peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers)
if err != nil {
@@ -809,11 +838,11 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
var networkMap *types.NetworkMap
if c.experimentalNetworkMap(peer.AccountID) {
- networkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peerID, validatedPeers, customZone, nil)
+ networkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peerID, validatedPeers, peersCustomZone, accountZones, nil)
} else {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
- networkMap = account.GetPeerNetworkMap(ctx, peer.ID, customZone, validatedPeers, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
+ networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
}
proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go
index 3ed51a5c3..caef362cb 100644
--- a/management/internals/controllers/network_map/controller/repository.go
+++ b/management/internals/controllers/network_map/controller/repository.go
@@ -3,6 +3,7 @@ package controller
import (
"context"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
@@ -14,6 +15,7 @@ type Repository interface {
GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error)
GetPeersByIDs(ctx context.Context, accountID string, peerIDs []string) (map[string]*peer.Peer, error)
GetPeerByID(ctx context.Context, accountID string, peerID string) (*peer.Peer, error)
+ GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error)
}
type repository struct {
@@ -47,3 +49,7 @@ func (r *repository) GetPeersByIDs(ctx context.Context, accountID string, peerID
func (r *repository) GetPeerByID(ctx context.Context, accountID string, peerID string) (*peer.Peer, error) {
return r.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
}
+
+func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
+ return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID)
+}
diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go
index 4935c608e..1551689b4 100644
--- a/management/internals/modules/peers/manager.go
+++ b/management/internals/modules/peers/manager.go
@@ -31,6 +31,7 @@ type Manager interface {
SetNetworkMapController(networkMapController network_map.Controller)
SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator)
SetAccountManager(accountManager account.Manager)
+ GetPeerID(ctx context.Context, peerKey string) (string, error)
}
type managerImpl struct {
@@ -167,3 +168,7 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs
return nil
}
+
+func (m *managerImpl) GetPeerID(ctx context.Context, peerKey string) (string, error) {
+ return m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey)
+}
diff --git a/management/internals/modules/peers/manager_mock.go b/management/internals/modules/peers/manager_mock.go
index 2e3651e88..6feedca2e 100644
--- a/management/internals/modules/peers/manager_mock.go
+++ b/management/internals/modules/peers/manager_mock.go
@@ -97,6 +97,21 @@ func (mr *MockManagerMockRecorder) GetPeerAccountID(ctx, peerID interface{}) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerAccountID", reflect.TypeOf((*MockManager)(nil).GetPeerAccountID), ctx, peerID)
}
+// GetPeerID mocks base method.
+func (m *MockManager) GetPeerID(ctx context.Context, peerKey string) (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPeerID", ctx, peerKey)
+ ret0, _ := ret[0].(string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetPeerID indicates an expected call of GetPeerID.
+func (mr *MockManagerMockRecorder) GetPeerID(ctx, peerKey interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerID", reflect.TypeOf((*MockManager)(nil).GetPeerID), ctx, peerKey)
+}
+
// GetPeersByGroupIDs mocks base method.
func (m *MockManager) GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) {
m.ctrl.T.Helper()
diff --git a/management/internals/modules/zones/interface.go b/management/internals/modules/zones/interface.go
new file mode 100644
index 000000000..8e2306230
--- /dev/null
+++ b/management/internals/modules/zones/interface.go
@@ -0,0 +1,13 @@
+package zones
+
+import (
+ "context"
+)
+
+type Manager interface {
+ GetAllZones(ctx context.Context, accountID, userID string) ([]*Zone, error)
+ GetZone(ctx context.Context, accountID, userID, zone string) (*Zone, error)
+ CreateZone(ctx context.Context, accountID, userID string, zone *Zone) (*Zone, error)
+ UpdateZone(ctx context.Context, accountID, userID string, zone *Zone) (*Zone, error)
+ DeleteZone(ctx context.Context, accountID, userID, zoneID string) error
+}
diff --git a/management/internals/modules/zones/manager/api.go b/management/internals/modules/zones/manager/api.go
new file mode 100644
index 000000000..919d77d61
--- /dev/null
+++ b/management/internals/modules/zones/manager/api.go
@@ -0,0 +1,161 @@
+package manager
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/gorilla/mux"
+
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ nbcontext "github.com/netbirdio/netbird/management/server/context"
+ "github.com/netbirdio/netbird/shared/management/http/api"
+ "github.com/netbirdio/netbird/shared/management/http/util"
+ "github.com/netbirdio/netbird/shared/management/status"
+)
+
+type handler struct {
+ manager zones.Manager
+}
+
+func RegisterEndpoints(router *mux.Router, manager zones.Manager) {
+ h := &handler{
+ manager: manager,
+ }
+
+ router.HandleFunc("/dns/zones", h.getAllZones).Methods("GET", "OPTIONS")
+ router.HandleFunc("/dns/zones", h.createZone).Methods("POST", "OPTIONS")
+ router.HandleFunc("/dns/zones/{zoneId}", h.getZone).Methods("GET", "OPTIONS")
+ router.HandleFunc("/dns/zones/{zoneId}", h.updateZone).Methods("PUT", "OPTIONS")
+ router.HandleFunc("/dns/zones/{zoneId}", h.deleteZone).Methods("DELETE", "OPTIONS")
+}
+
+func (h *handler) getAllZones(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ allZones, err := h.manager.GetAllZones(r.Context(), userAuth.AccountId, userAuth.UserId)
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ apiZones := make([]*api.Zone, 0, len(allZones))
+ for _, zone := range allZones {
+ apiZones = append(apiZones, zone.ToAPIResponse())
+ }
+
+ util.WriteJSONObject(r.Context(), w, apiZones)
+}
+
+func (h *handler) createZone(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ var req api.PostApiDnsZonesJSONRequestBody
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
+ return
+ }
+
+ zone := new(zones.Zone)
+ zone.FromAPIRequest(&req)
+
+ if err = zone.Validate(); err != nil {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w)
+ return
+ }
+
+ createdZone, err := h.manager.CreateZone(r.Context(), userAuth.AccountId, userAuth.UserId, zone)
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ util.WriteJSONObject(r.Context(), w, createdZone.ToAPIResponse())
+}
+
+func (h *handler) getZone(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ zoneID := mux.Vars(r)["zoneId"]
+ if zoneID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w)
+ return
+ }
+
+ zone, err := h.manager.GetZone(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID)
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ util.WriteJSONObject(r.Context(), w, zone.ToAPIResponse())
+}
+
+func (h *handler) updateZone(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ zoneID := mux.Vars(r)["zoneId"]
+ if zoneID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w)
+ return
+ }
+
+ var req api.PutApiDnsZonesZoneIdJSONRequestBody
+ if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
+ util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
+ return
+ }
+
+ zone := new(zones.Zone)
+ zone.FromAPIRequest(&req)
+ zone.ID = zoneID
+
+ if err = zone.Validate(); err != nil {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w)
+ return
+ }
+
+ updatedZone, err := h.manager.UpdateZone(r.Context(), userAuth.AccountId, userAuth.UserId, zone)
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ util.WriteJSONObject(r.Context(), w, updatedZone.ToAPIResponse())
+}
+
+func (h *handler) deleteZone(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ zoneID := mux.Vars(r)["zoneId"]
+ if zoneID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w)
+ return
+ }
+
+ if err = h.manager.DeleteZone(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID); err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
+}
diff --git a/management/internals/modules/zones/manager/manager.go b/management/internals/modules/zones/manager/manager.go
new file mode 100644
index 000000000..8548dd48c
--- /dev/null
+++ b/management/internals/modules/zones/manager/manager.go
@@ -0,0 +1,229 @@
+package manager
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ "github.com/netbirdio/netbird/management/server/account"
+ "github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/permissions/modules"
+ "github.com/netbirdio/netbird/management/server/permissions/operations"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/shared/management/status"
+)
+
+type managerImpl struct {
+ store store.Store
+ accountManager account.Manager
+ permissionsManager permissions.Manager
+ dnsDomain string
+}
+
+func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, dnsDomain string) zones.Manager {
+ return &managerImpl{
+ store: store,
+ accountManager: accountManager,
+ permissionsManager: permissionsManager,
+ dnsDomain: dnsDomain,
+ }
+}
+
+func (m *managerImpl) GetAllZones(ctx context.Context, accountID, userID string) ([]*zones.Zone, error) {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ return m.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID)
+}
+
+func (m *managerImpl) GetZone(ctx context.Context, accountID, userID, zoneID string) (*zones.Zone, error) {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ return m.store.GetZoneByID(ctx, store.LockingStrengthNone, accountID, zoneID)
+}
+
+func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, zone *zones.Zone) (*zones.Zone, error) {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ if err = m.validateZoneDomainConflict(ctx, accountID, zone.Domain); err != nil {
+ return nil, err
+ }
+
+ zone = zones.NewZone(accountID, zone.Name, zone.Domain, zone.Enabled, zone.EnableSearchDomain, zone.DistributionGroups)
+ err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ existingZone, err := transaction.GetZoneByDomain(ctx, accountID, zone.Domain)
+ if err != nil {
+ if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound {
+ return fmt.Errorf("failed to check existing zone: %w", err)
+ }
+ }
+ if existingZone != nil {
+ return status.Errorf(status.AlreadyExists, "zone with domain %s already exists", zone.Domain)
+ }
+
+ for _, groupID := range zone.DistributionGroups {
+ _, err = transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID)
+ if err != nil {
+ return status.Errorf(status.InvalidArgument, "%s", err.Error())
+ }
+ }
+
+ if err = transaction.CreateZone(ctx, zone); err != nil {
+ return fmt.Errorf("failed to create zone: %w", err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneCreated, zone.EventMeta())
+
+ return zone, nil
+}
+
+func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, updatedZone *zones.Zone) (*zones.Zone, error) {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, updatedZone.ID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get zone: %w", err)
+ }
+
+ if zone.Domain != updatedZone.Domain {
+ return nil, status.Errorf(status.InvalidArgument, "zone domain cannot be updated")
+ }
+
+ zone.Name = updatedZone.Name
+ zone.Enabled = updatedZone.Enabled
+ zone.EnableSearchDomain = updatedZone.EnableSearchDomain
+ zone.DistributionGroups = updatedZone.DistributionGroups
+
+ err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ for _, groupID := range zone.DistributionGroups {
+ _, err = transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID)
+ if err != nil {
+ return status.Errorf(status.InvalidArgument, "%s", err.Error())
+ }
+ }
+
+ if err = transaction.UpdateZone(ctx, zone); err != nil {
+ return fmt.Errorf("failed to update zone: %w", err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneUpdated, zone.EventMeta())
+
+ go m.accountManager.UpdateAccountPeers(ctx, accountID)
+
+ return zone, nil
+}
+
+func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID string) error {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
+ if err != nil {
+ return status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return status.NewPermissionDeniedError()
+ }
+
+ zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID)
+ if err != nil {
+ return fmt.Errorf("failed to get zone: %w", err)
+ }
+
+ var eventsToStore []func()
+ err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ records, err := transaction.GetZoneDNSRecords(ctx, store.LockingStrengthNone, accountID, zoneID)
+ if err != nil {
+ return fmt.Errorf("failed to get records: %w", err)
+ }
+
+ err = transaction.DeleteZoneDNSRecords(ctx, accountID, zoneID)
+ if err != nil {
+ return fmt.Errorf("failed to delete zone dns records: %w", err)
+ }
+
+ err = transaction.DeleteZone(ctx, accountID, zoneID)
+ if err != nil {
+ return fmt.Errorf("failed to delete zone: %w", err)
+ }
+
+ err = transaction.IncrementNetworkSerial(ctx, accountID)
+ if err != nil {
+ return fmt.Errorf("failed to increment network serial: %w", err)
+ }
+
+ for _, record := range records {
+ eventsToStore = append(eventsToStore, func() {
+ meta := record.EventMeta(zone.ID, zone.Name)
+ m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordDeleted, meta)
+ })
+ }
+
+ eventsToStore = append(eventsToStore, func() {
+ m.accountManager.StoreEvent(ctx, userID, zoneID, accountID, activity.DNSZoneDeleted, zone.EventMeta())
+ })
+
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ for _, event := range eventsToStore {
+ event()
+ }
+
+ go m.accountManager.UpdateAccountPeers(ctx, accountID)
+
+ return nil
+}
+
+func (m *managerImpl) validateZoneDomainConflict(ctx context.Context, accountID, domain string) error {
+ if m.dnsDomain != "" && m.dnsDomain == domain {
+ return status.Errorf(status.InvalidArgument, "zone domain %s conflicts with peer DNS domain", domain)
+ }
+
+ settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return err
+ }
+
+ if settings.DNSDomain != "" && settings.DNSDomain == domain {
+ return status.Errorf(status.InvalidArgument, "zone domain %s conflicts with peer DNS domain", domain)
+ }
+
+ return nil
+}
diff --git a/management/internals/modules/zones/manager/manager_test.go b/management/internals/modules/zones/manager/manager_test.go
new file mode 100644
index 000000000..b45ec7874
--- /dev/null
+++ b/management/internals/modules/zones/manager/manager_test.go
@@ -0,0 +1,553 @@
+package manager
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
+ "github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/mock_server"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/permissions/modules"
+ "github.com/netbirdio/netbird/management/server/permissions/operations"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/shared/management/status"
+)
+
+const (
+ testAccountID = "test-account-id"
+ testUserID = "test-user-id"
+ testZoneID = "test-zone-id"
+ testGroupID = "test-group-id"
+ testDNSDomain = "netbird.selfhosted"
+)
+
+func setupTest(t *testing.T) (*managerImpl, store.Store, *mock_server.MockAccountManager, *permissions.MockManager, *gomock.Controller, func()) {
+ t.Helper()
+
+ ctx := context.Background()
+ testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
+ require.NoError(t, err)
+
+ err = testStore.SaveAccount(ctx, &types.Account{
+ Id: testAccountID,
+ Groups: map[string]*types.Group{
+ testGroupID: {
+ ID: testGroupID,
+ Name: "Test Group",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ ctrl := gomock.NewController(t)
+ mockAccountManager := &mock_server.MockAccountManager{}
+ mockPermissionsManager := permissions.NewMockManager(ctrl)
+
+ manager := &managerImpl{
+ store: testStore,
+ accountManager: mockAccountManager,
+ permissionsManager: mockPermissionsManager,
+ dnsDomain: testDNSDomain,
+ }
+
+ return manager, testStore, mockAccountManager, mockPermissionsManager, ctrl, cleanup
+}
+
+func TestManagerImpl_GetAllZones(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success", func(t *testing.T) {
+ manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ zone1 := zones.NewZone(testAccountID, "Zone 1", "zone1.example.com", true, true, []string{testGroupID})
+ err := testStore.CreateZone(ctx, zone1)
+ require.NoError(t, err)
+
+ zone2 := zones.NewZone(testAccountID, "Zone 2", "zone2.example.com", false, false, []string{testGroupID})
+ err = testStore.CreateZone(ctx, zone2)
+ require.NoError(t, err)
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(true, nil)
+
+ result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
+ require.NoError(t, err)
+ assert.Len(t, result, 2)
+ assert.Equal(t, zone1.ID, result[0].ID)
+ assert.Equal(t, zone2.ID, result[1].ID)
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(false, nil)
+
+ result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+
+ t.Run("permission validation error", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(false, status.Errorf(status.Internal, "permission check failed"))
+
+ result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ })
+}
+
+func TestManagerImpl_GetZone(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success", func(t *testing.T) {
+ manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ zone := zones.NewZone(testAccountID, "Test Zone", "test.example.com", true, true, []string{testGroupID})
+ err := testStore.CreateZone(ctx, zone)
+ require.NoError(t, err)
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(true, nil)
+
+ result, err := manager.GetZone(ctx, testAccountID, testUserID, zone.ID)
+ require.NoError(t, err)
+ assert.Equal(t, zone.ID, result.ID)
+ assert.Equal(t, zone.Name, result.Name)
+ assert.Equal(t, zone.Domain, result.Domain)
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(false, nil)
+
+ result, err := manager.GetZone(ctx, testAccountID, testUserID, testZoneID)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+}
+
+func TestManagerImpl_CreateZone(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success", func(t *testing.T) {
+ manager, _, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ inputZone := &zones.Zone{
+ Name: "New Zone",
+ Domain: "new.example.com",
+ Enabled: true,
+ EnableSearchDomain: true,
+ DistributionGroups: []string{testGroupID},
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ assert.Equal(t, testUserID, initiatorID)
+ assert.Equal(t, testAccountID, accountID)
+ assert.Equal(t, activity.DNSZoneCreated, activityID)
+ }
+
+ result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
+ require.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.NotEmpty(t, result.ID)
+ assert.Equal(t, testAccountID, result.AccountID)
+ assert.Equal(t, inputZone.Name, result.Name)
+ assert.Equal(t, inputZone.Domain, result.Domain)
+ assert.Equal(t, inputZone.Enabled, result.Enabled)
+ assert.Equal(t, inputZone.EnableSearchDomain, result.EnableSearchDomain)
+ assert.Equal(t, inputZone.DistributionGroups, result.DistributionGroups)
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ inputZone := &zones.Zone{
+ Name: "New Zone",
+ Domain: "new.example.com",
+ DistributionGroups: []string{testGroupID},
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(false, nil)
+
+ result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+
+ t.Run("invalid group", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ inputZone := &zones.Zone{
+ Name: "New Zone",
+ Domain: "new.example.com",
+ DistributionGroups: []string{"invalid-group"},
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ })
+
+ t.Run("duplicate domain", func(t *testing.T) {
+ manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ existingZone := zones.NewZone(testAccountID, "Existing Zone", "duplicate.example.com", true, false, []string{testGroupID})
+ err := testStore.CreateZone(ctx, existingZone)
+ require.NoError(t, err)
+
+ inputZone := &zones.Zone{
+ Name: "New Zone",
+ Domain: "duplicate.example.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{testGroupID},
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "zone with domain duplicate.example.com already exists")
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.AlreadyExists, s.Type())
+ })
+
+ t.Run("peer DNS domain conflict", func(t *testing.T) {
+ manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ account, err := testStore.GetAccount(ctx, testAccountID)
+ require.NoError(t, err)
+ account.Settings.DNSDomain = "peers.example.com"
+ err = testStore.SaveAccount(ctx, account)
+ require.NoError(t, err)
+
+ inputZone := &zones.Zone{
+ Name: "Test Zone",
+ Domain: "peers.example.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{testGroupID},
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "zone domain peers.example.com conflicts with peer DNS domain")
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.InvalidArgument, s.Type())
+ })
+
+ t.Run("default DNS domain conflict", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ inputZone := &zones.Zone{
+ Name: "Test Zone",
+ Domain: testDNSDomain,
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{testGroupID},
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), fmt.Sprintf("zone domain %s conflicts with peer DNS domain", testDNSDomain))
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.InvalidArgument, s.Type())
+ })
+}
+
+func TestManagerImpl_UpdateZone(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success", func(t *testing.T) {
+ manager, testStore, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ existingZone := zones.NewZone(testAccountID, "Old Name", "example.com", false, false, []string{testGroupID})
+ err := testStore.CreateZone(ctx, existingZone)
+ require.NoError(t, err)
+
+ updatedZone := &zones.Zone{
+ ID: existingZone.ID,
+ Name: "Updated Name",
+ Domain: "example.com",
+ Enabled: true,
+ EnableSearchDomain: true,
+ DistributionGroups: []string{testGroupID},
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
+ Return(true, nil)
+
+ storeEventCalled := false
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ storeEventCalled = true
+ assert.Equal(t, testUserID, initiatorID)
+ assert.Equal(t, existingZone.ID, targetID)
+ assert.Equal(t, testAccountID, accountID)
+ assert.Equal(t, activity.DNSZoneUpdated, activityID)
+ }
+
+ result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
+ require.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.Equal(t, updatedZone.Name, result.Name)
+ assert.Equal(t, updatedZone.Enabled, result.Enabled)
+ assert.Equal(t, updatedZone.EnableSearchDomain, result.EnableSearchDomain)
+ assert.True(t, storeEventCalled, "StoreEvent should have been called")
+ })
+
+ t.Run("domain change not allowed", func(t *testing.T) {
+ manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ existingZone := zones.NewZone(testAccountID, "Test Zone", "example.com", true, true, []string{testGroupID})
+ err := testStore.CreateZone(ctx, existingZone)
+ require.NoError(t, err)
+
+ updatedZone := &zones.Zone{
+ ID: existingZone.ID,
+ Name: "Test Zone",
+ Domain: "different.com",
+ Enabled: true,
+ EnableSearchDomain: true,
+ DistributionGroups: []string{testGroupID},
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
+ Return(true, nil)
+
+ result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "zone domain cannot be updated")
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.InvalidArgument, s.Type())
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ updatedZone := &zones.Zone{
+ ID: testZoneID,
+ Name: "Updated Name",
+ Domain: "example.com",
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
+ Return(false, nil)
+
+ result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+
+ t.Run("zone not found", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ updatedZone := &zones.Zone{
+ ID: "non-existent-zone",
+ Name: "Updated Name",
+ Domain: "example.com",
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
+ Return(true, nil)
+
+ result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ })
+}
+
+func TestManagerImpl_DeleteZone(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success with records", func(t *testing.T) {
+ manager, testStore, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ zone := zones.NewZone(testAccountID, "Test Zone", "example.com", true, true, []string{testGroupID})
+ err := testStore.CreateZone(ctx, zone)
+ require.NoError(t, err)
+
+ record1 := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err = testStore.CreateDNSRecord(ctx, record1)
+ require.NoError(t, err)
+
+ record2 := records.NewRecord(testAccountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.2", 300)
+ err = testStore.CreateDNSRecord(ctx, record2)
+ require.NoError(t, err)
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
+ Return(true, nil)
+
+ storeEventCallCount := 0
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ storeEventCallCount++
+ assert.Equal(t, testUserID, initiatorID)
+ assert.Equal(t, testAccountID, accountID)
+ }
+
+ err = manager.DeleteZone(ctx, testAccountID, testUserID, zone.ID)
+ require.NoError(t, err)
+ assert.Equal(t, 3, storeEventCallCount)
+
+ _, err = testStore.GetZoneByID(ctx, store.LockingStrengthNone, testAccountID, zone.ID)
+ require.Error(t, err)
+
+ zoneRecords, err := testStore.GetZoneDNSRecords(ctx, store.LockingStrengthNone, testAccountID, zone.ID)
+ require.NoError(t, err)
+ assert.Empty(t, zoneRecords)
+ })
+
+ t.Run("success without records", func(t *testing.T) {
+ manager, testStore, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ zone := zones.NewZone(testAccountID, "Test Zone", "example.com", true, true, []string{testGroupID})
+ err := testStore.CreateZone(ctx, zone)
+ require.NoError(t, err)
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
+ Return(true, nil)
+
+ storeEventCalled := false
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ storeEventCalled = true
+ assert.Equal(t, testUserID, initiatorID)
+ assert.Equal(t, zone.ID, targetID)
+ assert.Equal(t, testAccountID, accountID)
+ assert.Equal(t, activity.DNSZoneDeleted, activityID)
+ }
+
+ err = manager.DeleteZone(ctx, testAccountID, testUserID, zone.ID)
+ require.NoError(t, err)
+ assert.True(t, storeEventCalled, "StoreEvent should have been called")
+
+ _, err = testStore.GetZoneByID(ctx, store.LockingStrengthNone, testAccountID, zone.ID)
+ require.Error(t, err)
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
+ Return(false, nil)
+
+ err := manager.DeleteZone(ctx, testAccountID, testUserID, testZoneID)
+ require.Error(t, err)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+
+ t.Run("zone not found", func(t *testing.T) {
+ manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
+ Return(true, nil)
+
+ err := manager.DeleteZone(ctx, testAccountID, testUserID, "non-existent-zone")
+ require.Error(t, err)
+ })
+}
diff --git a/management/internals/modules/zones/records/interface.go b/management/internals/modules/zones/records/interface.go
new file mode 100644
index 000000000..ceb8c5318
--- /dev/null
+++ b/management/internals/modules/zones/records/interface.go
@@ -0,0 +1,13 @@
+package records
+
+import (
+ "context"
+)
+
+type Manager interface {
+ GetAllRecords(ctx context.Context, accountID, userID, zoneID string) ([]*Record, error)
+ GetRecord(ctx context.Context, accountID, userID, zoneID, recordID string) (*Record, error)
+ CreateRecord(ctx context.Context, accountID, userID, zoneID string, record *Record) (*Record, error)
+ UpdateRecord(ctx context.Context, accountID, userID, zoneID string, record *Record) (*Record, error)
+ DeleteRecord(ctx context.Context, accountID, userID, zoneID, recordID string) error
+}
diff --git a/management/internals/modules/zones/records/manager/api.go b/management/internals/modules/zones/records/manager/api.go
new file mode 100644
index 000000000..f8ecfef7d
--- /dev/null
+++ b/management/internals/modules/zones/records/manager/api.go
@@ -0,0 +1,191 @@
+package manager
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/gorilla/mux"
+
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
+ nbcontext "github.com/netbirdio/netbird/management/server/context"
+ "github.com/netbirdio/netbird/shared/management/http/api"
+ "github.com/netbirdio/netbird/shared/management/http/util"
+ "github.com/netbirdio/netbird/shared/management/status"
+)
+
+type handler struct {
+ manager records.Manager
+}
+
+func RegisterEndpoints(router *mux.Router, manager records.Manager) {
+ h := &handler{
+ manager: manager,
+ }
+
+ router.HandleFunc("/dns/zones/{zoneId}/records", h.getAllRecords).Methods("GET", "OPTIONS")
+ router.HandleFunc("/dns/zones/{zoneId}/records", h.createRecord).Methods("POST", "OPTIONS")
+ router.HandleFunc("/dns/zones/{zoneId}/records/{recordId}", h.getRecord).Methods("GET", "OPTIONS")
+ router.HandleFunc("/dns/zones/{zoneId}/records/{recordId}", h.updateRecord).Methods("PUT", "OPTIONS")
+ router.HandleFunc("/dns/zones/{zoneId}/records/{recordId}", h.deleteRecord).Methods("DELETE", "OPTIONS")
+}
+
+func (h *handler) getAllRecords(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ zoneID := mux.Vars(r)["zoneId"]
+ if zoneID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w)
+ return
+ }
+
+ allRecords, err := h.manager.GetAllRecords(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID)
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ apiRecords := make([]*api.DNSRecord, 0, len(allRecords))
+ for _, record := range allRecords {
+ apiRecords = append(apiRecords, record.ToAPIResponse())
+ }
+
+ util.WriteJSONObject(r.Context(), w, apiRecords)
+}
+
+func (h *handler) createRecord(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ zoneID := mux.Vars(r)["zoneId"]
+ if zoneID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w)
+ return
+ }
+
+ var req api.PostApiDnsZonesZoneIdRecordsJSONRequestBody
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
+ return
+ }
+
+ record := new(records.Record)
+ record.FromAPIRequest(&req)
+
+ if err = record.Validate(); err != nil {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w)
+ return
+ }
+
+ createdRecord, err := h.manager.CreateRecord(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID, record)
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ util.WriteJSONObject(r.Context(), w, createdRecord.ToAPIResponse())
+}
+
+func (h *handler) getRecord(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ zoneID := mux.Vars(r)["zoneId"]
+ if zoneID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w)
+ return
+ }
+
+ recordID := mux.Vars(r)["recordId"]
+ if recordID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "record ID is required"), w)
+ return
+ }
+
+ record, err := h.manager.GetRecord(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID, recordID)
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ util.WriteJSONObject(r.Context(), w, record.ToAPIResponse())
+}
+
+func (h *handler) updateRecord(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ zoneID := mux.Vars(r)["zoneId"]
+ if zoneID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w)
+ return
+ }
+
+ recordID := mux.Vars(r)["recordId"]
+ if recordID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "record ID is required"), w)
+ return
+ }
+
+ var req api.PutApiDnsZonesZoneIdRecordsRecordIdJSONRequestBody
+ if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
+ util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
+ return
+ }
+
+ record := new(records.Record)
+ record.FromAPIRequest(&req)
+ record.ID = recordID
+
+ if err = record.Validate(); err != nil {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w)
+ return
+ }
+
+ updatedRecord, err := h.manager.UpdateRecord(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID, record)
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ util.WriteJSONObject(r.Context(), w, updatedRecord.ToAPIResponse())
+}
+
+func (h *handler) deleteRecord(w http.ResponseWriter, r *http.Request) {
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ zoneID := mux.Vars(r)["zoneId"]
+ if zoneID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w)
+ return
+ }
+
+ recordID := mux.Vars(r)["recordId"]
+ if recordID == "" {
+ util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "record ID is required"), w)
+ return
+ }
+
+ if err = h.manager.DeleteRecord(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID, recordID); err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
+}
diff --git a/management/internals/modules/zones/records/manager/manager.go b/management/internals/modules/zones/records/manager/manager.go
new file mode 100644
index 000000000..5374a2ef2
--- /dev/null
+++ b/management/internals/modules/zones/records/manager/manager.go
@@ -0,0 +1,236 @@
+package manager
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
+ "github.com/netbirdio/netbird/management/server/account"
+ "github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/permissions/modules"
+ "github.com/netbirdio/netbird/management/server/permissions/operations"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/shared/management/status"
+)
+
+type managerImpl struct {
+ store store.Store
+ accountManager account.Manager
+ permissionsManager permissions.Manager
+}
+
+func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager) records.Manager {
+ return &managerImpl{
+ store: store,
+ accountManager: accountManager,
+ permissionsManager: permissionsManager,
+ }
+}
+
+func (m *managerImpl) GetAllRecords(ctx context.Context, accountID, userID, zoneID string) ([]*records.Record, error) {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ return m.store.GetZoneDNSRecords(ctx, store.LockingStrengthNone, accountID, zoneID)
+}
+
+func (m *managerImpl) GetRecord(ctx context.Context, accountID, userID, zoneID, recordID string) (*records.Record, error) {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ return m.store.GetDNSRecordByID(ctx, store.LockingStrengthNone, accountID, zoneID, recordID)
+}
+
+func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneID string, record *records.Record) (*records.Record, error) {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ var zone *zones.Zone
+
+ record = records.NewRecord(accountID, zoneID, record.Name, record.Type, record.Content, record.TTL)
+ err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID)
+ if err != nil {
+ return fmt.Errorf("failed to get zone: %w", err)
+ }
+
+ err = validateRecordConflicts(ctx, transaction, zone, record)
+ if err != nil {
+ return err
+ }
+
+ if err = transaction.CreateDNSRecord(ctx, record); err != nil {
+ return fmt.Errorf("failed to create dns record: %w", err)
+ }
+
+ err = transaction.IncrementNetworkSerial(ctx, accountID)
+ if err != nil {
+ return fmt.Errorf("failed to increment network serial: %w", err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ meta := record.EventMeta(zone.ID, zone.Name)
+ m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordCreated, meta)
+
+ go m.accountManager.UpdateAccountPeers(ctx, accountID)
+
+ return record, nil
+}
+
+func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneID string, updatedRecord *records.Record) (*records.Record, error) {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ var zone *zones.Zone
+ var record *records.Record
+
+ err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID)
+ if err != nil {
+ return fmt.Errorf("failed to get zone: %w", err)
+ }
+
+ record, err = transaction.GetDNSRecordByID(ctx, store.LockingStrengthUpdate, accountID, zoneID, updatedRecord.ID)
+ if err != nil {
+ return fmt.Errorf("failed to get record: %w", err)
+ }
+
+ hasChanges := record.Name != updatedRecord.Name || record.Type != updatedRecord.Type || record.Content != updatedRecord.Content
+
+ record.Name = updatedRecord.Name
+ record.Type = updatedRecord.Type
+ record.Content = updatedRecord.Content
+ record.TTL = updatedRecord.TTL
+
+ if hasChanges {
+ if err = validateRecordConflicts(ctx, transaction, zone, record); err != nil {
+ return err
+ }
+ }
+
+ if err = transaction.UpdateDNSRecord(ctx, record); err != nil {
+ return fmt.Errorf("failed to update dns record: %w", err)
+ }
+
+ err = transaction.IncrementNetworkSerial(ctx, accountID)
+ if err != nil {
+ return fmt.Errorf("failed to increment network serial: %w", err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ meta := record.EventMeta(zone.ID, zone.Name)
+ m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordUpdated, meta)
+
+ go m.accountManager.UpdateAccountPeers(ctx, accountID)
+
+ return record, nil
+}
+
+func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneID, recordID string) error {
+ ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
+ if err != nil {
+ return status.NewPermissionValidationError(err)
+ }
+ if !ok {
+ return status.NewPermissionDeniedError()
+ }
+
+ var record *records.Record
+ var zone *zones.Zone
+
+ err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID)
+ if err != nil {
+ return fmt.Errorf("failed to get zone: %w", err)
+ }
+
+ record, err = transaction.GetDNSRecordByID(ctx, store.LockingStrengthUpdate, accountID, zoneID, recordID)
+ if err != nil {
+ return fmt.Errorf("failed to get record: %w", err)
+ }
+
+ err = transaction.DeleteDNSRecord(ctx, accountID, zoneID, recordID)
+ if err != nil {
+ return fmt.Errorf("failed to delete dns record: %w", err)
+ }
+
+ err = transaction.IncrementNetworkSerial(ctx, accountID)
+ if err != nil {
+ return fmt.Errorf("failed to increment network serial: %w", err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ meta := record.EventMeta(zone.ID, zone.Name)
+ m.accountManager.StoreEvent(ctx, userID, recordID, accountID, activity.DNSRecordDeleted, meta)
+
+ go m.accountManager.UpdateAccountPeers(ctx, accountID)
+
+ return nil
+}
+
+// validateRecordConflicts checks for duplicate records and CNAME conflicts
+func validateRecordConflicts(ctx context.Context, transaction store.Store, zone *zones.Zone, record *records.Record) error {
+ if record.Name != zone.Domain && !strings.HasSuffix(record.Name, "."+zone.Domain) {
+ return status.Errorf(status.InvalidArgument, "record name does not belong to zone")
+ }
+
+ existingRecords, err := transaction.GetZoneDNSRecordsByName(ctx, store.LockingStrengthNone, zone.AccountID, zone.ID, record.Name)
+ if err != nil {
+ return fmt.Errorf("failed to check existing records: %w", err)
+ }
+
+ for _, existing := range existingRecords {
+ if existing.ID == record.ID {
+ continue
+ }
+
+ if existing.Type == record.Type && existing.Content == record.Content {
+ return status.Errorf(status.AlreadyExists, "identical record already exists")
+ }
+
+ if record.Type == records.RecordTypeCNAME || existing.Type == records.RecordTypeCNAME {
+ return status.Errorf(status.InvalidArgument,
+ "An A, AAAA, or CNAME record with name %s already exists", record.Name)
+ }
+ }
+
+ return nil
+}
diff --git a/management/internals/modules/zones/records/manager/manager_test.go b/management/internals/modules/zones/records/manager/manager_test.go
new file mode 100644
index 000000000..0a962e0f4
--- /dev/null
+++ b/management/internals/modules/zones/records/manager/manager_test.go
@@ -0,0 +1,573 @@
+package manager
+
+import (
+ "context"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
+ "github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/mock_server"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/permissions/modules"
+ "github.com/netbirdio/netbird/management/server/permissions/operations"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/shared/management/status"
+)
+
+const (
+ testAccountID = "test-account-id"
+ testUserID = "test-user-id"
+ testRecordID = "test-record-id"
+ testGroupID = "test-group-id"
+)
+
+func setupTest(t *testing.T) (*managerImpl, store.Store, *zones.Zone, *mock_server.MockAccountManager, *permissions.MockManager, *gomock.Controller, func()) {
+ t.Helper()
+
+ ctx := context.Background()
+ testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
+ require.NoError(t, err)
+
+ err = testStore.SaveAccount(ctx, &types.Account{
+ Id: testAccountID,
+ Groups: map[string]*types.Group{
+ testGroupID: {
+ ID: testGroupID,
+ Name: "Test Group",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ zone := zones.NewZone(testAccountID, "Test Zone", "example.com", true, true, []string{testGroupID})
+ err = testStore.CreateZone(ctx, zone)
+ require.NoError(t, err)
+
+ ctrl := gomock.NewController(t)
+ mockAccountManager := &mock_server.MockAccountManager{}
+ mockPermissionsManager := permissions.NewMockManager(ctrl)
+
+ manager := &managerImpl{
+ store: testStore,
+ accountManager: mockAccountManager,
+ permissionsManager: mockPermissionsManager,
+ }
+
+ return manager, testStore, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup
+}
+
+func TestManagerImpl_GetAllRecords(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success", func(t *testing.T) {
+ manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ record1 := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err := testStore.CreateDNSRecord(ctx, record1)
+ require.NoError(t, err)
+
+ record2 := records.NewRecord(testAccountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.2", 300)
+ err = testStore.CreateDNSRecord(ctx, record2)
+ require.NoError(t, err)
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(true, nil)
+
+ result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
+ require.NoError(t, err)
+ assert.Len(t, result, 2)
+ assert.Equal(t, record1.ID, result[0].ID)
+ assert.Equal(t, record2.ID, result[1].ID)
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(false, nil)
+
+ result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+
+ t.Run("permission validation error", func(t *testing.T) {
+ manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(false, status.Errorf(status.Internal, "permission check failed"))
+
+ result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ })
+}
+
+func TestManagerImpl_GetRecord(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success", func(t *testing.T) {
+ manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ record := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err := testStore.CreateDNSRecord(ctx, record)
+ require.NoError(t, err)
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(true, nil)
+
+ result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, record.ID)
+ require.NoError(t, err)
+ assert.Equal(t, record.ID, result.ID)
+ assert.Equal(t, record.Name, result.Name)
+ assert.Equal(t, record.Type, result.Type)
+ assert.Equal(t, record.Content, result.Content)
+ assert.Equal(t, record.TTL, result.TTL)
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
+ Return(false, nil)
+
+ result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+}
+
+func TestManagerImpl_CreateRecord(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success - A record", func(t *testing.T) {
+ manager, _, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ inputRecord := &records.Record{
+ Name: "api.example.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ assert.Equal(t, testUserID, initiatorID)
+ assert.Equal(t, testAccountID, accountID)
+ assert.Equal(t, activity.DNSRecordCreated, activityID)
+ }
+
+ result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
+ require.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.NotEmpty(t, result.ID)
+ assert.Equal(t, testAccountID, result.AccountID)
+ assert.Equal(t, zone.ID, result.ZoneID)
+ assert.Equal(t, inputRecord.Name, result.Name)
+ assert.Equal(t, inputRecord.Type, result.Type)
+ assert.Equal(t, inputRecord.Content, result.Content)
+ assert.Equal(t, inputRecord.TTL, result.TTL)
+ })
+
+ t.Run("success - AAAA record", func(t *testing.T) {
+ manager, _, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ inputRecord := &records.Record{
+ Name: "ipv6.example.com",
+ Type: records.RecordTypeAAAA,
+ Content: "2001:db8::1",
+ TTL: 600,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ assert.Equal(t, testUserID, initiatorID)
+ assert.Equal(t, testAccountID, accountID)
+ assert.Equal(t, activity.DNSRecordCreated, activityID)
+ }
+
+ result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
+ require.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.Equal(t, inputRecord.Type, result.Type)
+ assert.Equal(t, inputRecord.Content, result.Content)
+ })
+
+ t.Run("success - CNAME record", func(t *testing.T) {
+ manager, _, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ inputRecord := &records.Record{
+ Name: "www.example.com",
+ Type: records.RecordTypeCNAME,
+ Content: "example.com",
+ TTL: 300,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ assert.Equal(t, testUserID, initiatorID)
+ assert.Equal(t, testAccountID, accountID)
+ assert.Equal(t, activity.DNSRecordCreated, activityID)
+ }
+
+ result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
+ require.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.Equal(t, inputRecord.Type, result.Type)
+ assert.Equal(t, inputRecord.Content, result.Content)
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ inputRecord := &records.Record{
+ Name: "api.example.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(false, nil)
+
+ result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+
+ t.Run("record name not in zone", func(t *testing.T) {
+ manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ inputRecord := &records.Record{
+ Name: "api.different.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "does not belong to zone")
+ })
+
+ t.Run("duplicate record", func(t *testing.T) {
+ manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ existingRecord := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err := testStore.CreateDNSRecord(ctx, existingRecord)
+ require.NoError(t, err)
+
+ inputRecord := &records.Record{
+ Name: "api.example.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "identical record already exists")
+ })
+
+ t.Run("CNAME conflict with existing A record", func(t *testing.T) {
+ manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ existingRecord := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err := testStore.CreateDNSRecord(ctx, existingRecord)
+ require.NoError(t, err)
+
+ inputRecord := &records.Record{
+ Name: "api.example.com",
+ Type: records.RecordTypeCNAME,
+ Content: "example.com",
+ TTL: 300,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
+ Return(true, nil)
+
+ result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "already exists")
+ })
+}
+
+func TestManagerImpl_UpdateRecord(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success", func(t *testing.T) {
+ manager, testStore, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ existingRecord := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err := testStore.CreateDNSRecord(ctx, existingRecord)
+ require.NoError(t, err)
+
+ updatedRecord := &records.Record{
+ ID: existingRecord.ID,
+ Name: "api.example.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.100", // Changed IP
+ TTL: 600, // Changed TTL
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
+ Return(true, nil)
+
+ storeEventCalled := false
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ storeEventCalled = true
+ assert.Equal(t, testUserID, initiatorID)
+ assert.Equal(t, existingRecord.ID, targetID)
+ assert.Equal(t, testAccountID, accountID)
+ assert.Equal(t, activity.DNSRecordUpdated, activityID)
+ }
+
+ result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
+ require.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.Equal(t, updatedRecord.Content, result.Content)
+ assert.Equal(t, updatedRecord.TTL, result.TTL)
+ assert.True(t, storeEventCalled, "StoreEvent should have been called")
+ })
+
+ t.Run("update only TTL - no validation", func(t *testing.T) {
+ manager, testStore, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ existingRecord := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err := testStore.CreateDNSRecord(ctx, existingRecord)
+ require.NoError(t, err)
+
+ updatedRecord := &records.Record{
+ ID: existingRecord.ID,
+ Name: existingRecord.Name,
+ Type: existingRecord.Type,
+ Content: existingRecord.Content,
+ TTL: 600, // Only TTL changed
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
+ Return(true, nil)
+
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ // Event should be stored
+ }
+
+ result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
+ require.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.Equal(t, 600, result.TTL)
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ updatedRecord := &records.Record{
+ ID: testRecordID,
+ Name: "api.example.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.100",
+ TTL: 600,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
+ Return(false, nil)
+
+ result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+
+ t.Run("record not found", func(t *testing.T) {
+ manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ updatedRecord := &records.Record{
+ ID: "non-existent-record",
+ Name: "api.example.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.100",
+ TTL: 600,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
+ Return(true, nil)
+
+ result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ })
+
+ t.Run("update creates duplicate", func(t *testing.T) {
+ manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ record1 := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err := testStore.CreateDNSRecord(ctx, record1)
+ require.NoError(t, err)
+
+ record2 := records.NewRecord(testAccountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.2", 300)
+ err = testStore.CreateDNSRecord(ctx, record2)
+ require.NoError(t, err)
+
+ updatedRecord := &records.Record{
+ ID: record2.ID,
+ Name: "api.example.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ }
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
+ Return(true, nil)
+
+ result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "identical record already exists")
+ })
+}
+
+func TestManagerImpl_DeleteRecord(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("success", func(t *testing.T) {
+ manager, testStore, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ record := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err := testStore.CreateDNSRecord(ctx, record)
+ require.NoError(t, err)
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
+ Return(true, nil)
+
+ storeEventCalled := false
+ mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
+ storeEventCalled = true
+ assert.Equal(t, testUserID, initiatorID)
+ assert.Equal(t, record.ID, targetID)
+ assert.Equal(t, testAccountID, accountID)
+ assert.Equal(t, activity.DNSRecordDeleted, activityID)
+ }
+
+ err = manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, record.ID)
+ require.NoError(t, err)
+ assert.True(t, storeEventCalled, "StoreEvent should have been called")
+
+ _, err = testStore.GetDNSRecordByID(ctx, store.LockingStrengthNone, testAccountID, zone.ID, record.ID)
+ require.Error(t, err)
+ })
+
+ t.Run("permission denied", func(t *testing.T) {
+ manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
+ Return(false, nil)
+
+ err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID)
+ require.Error(t, err)
+ s, ok := status.FromError(err)
+ assert.True(t, ok)
+ assert.Equal(t, status.PermissionDenied, s.Type())
+ })
+
+ t.Run("record not found", func(t *testing.T) {
+ manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t)
+ defer cleanup()
+ defer ctrl.Finish()
+
+ mockPermissionsManager.EXPECT().
+ ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
+ Return(true, nil)
+
+ err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, "non-existent-record")
+ require.Error(t, err)
+ })
+}
diff --git a/management/internals/modules/zones/records/record.go b/management/internals/modules/zones/records/record.go
new file mode 100644
index 000000000..e44de08f4
--- /dev/null
+++ b/management/internals/modules/zones/records/record.go
@@ -0,0 +1,129 @@
+package records
+
+import (
+ "errors"
+ "net"
+
+ "github.com/rs/xid"
+
+ "github.com/netbirdio/netbird/management/server/util"
+ "github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+type RecordType string
+
+const (
+ RecordTypeA RecordType = "A"
+ RecordTypeAAAA RecordType = "AAAA"
+ RecordTypeCNAME RecordType = "CNAME"
+)
+
+type Record struct {
+ AccountID string `gorm:"index"`
+ ZoneID string `gorm:"index"`
+ ID string `gorm:"primaryKey"`
+ Name string
+ Type RecordType
+ Content string
+ TTL int
+}
+
+func NewRecord(accountID, zoneID, name string, recordType RecordType, content string, ttl int) *Record {
+ return &Record{
+ ID: xid.New().String(),
+ AccountID: accountID,
+ ZoneID: zoneID,
+ Name: name,
+ Type: recordType,
+ Content: content,
+ TTL: ttl,
+ }
+}
+
+func (r *Record) ToAPIResponse() *api.DNSRecord {
+ recordType := api.DNSRecordType(r.Type)
+ return &api.DNSRecord{
+ Id: r.ID,
+ Name: r.Name,
+ Type: recordType,
+ Content: r.Content,
+ Ttl: r.TTL,
+ }
+}
+
+func (r *Record) FromAPIRequest(req *api.DNSRecordRequest) {
+ r.Name = req.Name
+ r.Type = RecordType(req.Type)
+ r.Content = req.Content
+ r.TTL = req.Ttl
+}
+
+func (r *Record) Validate() error {
+ if r.Name == "" {
+ return errors.New("record name is required")
+ }
+
+ if !util.IsValidDomain(r.Name) {
+ return errors.New("invalid record name format")
+ }
+
+ if r.Type == "" {
+ return errors.New("record type is required")
+ }
+
+ switch r.Type {
+ case RecordTypeA:
+ if err := validateIPv4(r.Content); err != nil {
+ return err
+ }
+ case RecordTypeAAAA:
+ if err := validateIPv6(r.Content); err != nil {
+ return err
+ }
+ case RecordTypeCNAME:
+ if !util.IsValidDomain(r.Content) {
+ return errors.New("invalid CNAME record format")
+ }
+ default:
+ return errors.New("invalid record type, must be A, AAAA, or CNAME")
+ }
+
+ if r.TTL < 0 {
+ return errors.New("TTL cannot be negative")
+ }
+
+ return nil
+}
+
+func (r *Record) EventMeta(zoneID, zoneName string) map[string]any {
+ return map[string]any{
+ "name": r.Name,
+ "type": string(r.Type),
+ "content": r.Content,
+ "ttl": r.TTL,
+ "zone_id": zoneID,
+ "zone_name": zoneName,
+ }
+}
+
+func validateIPv4(content string) error {
+ if content == "" {
+ return errors.New("A record is required") //nolint:staticcheck
+ }
+ ip := net.ParseIP(content)
+ if ip == nil || ip.To4() == nil {
+ return errors.New("A record must be a valid IPv4 address") //nolint:staticcheck
+ }
+ return nil
+}
+
+func validateIPv6(content string) error {
+ if content == "" {
+ return errors.New("AAAA record is required")
+ }
+ ip := net.ParseIP(content)
+ if ip == nil || ip.To4() != nil {
+ return errors.New("AAAA record must be a valid IPv6 address")
+ }
+ return nil
+}
diff --git a/management/internals/modules/zones/zone.go b/management/internals/modules/zones/zone.go
new file mode 100644
index 000000000..27adac1ac
--- /dev/null
+++ b/management/internals/modules/zones/zone.go
@@ -0,0 +1,89 @@
+package zones
+
+import (
+ "errors"
+
+ "github.com/rs/xid"
+
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
+ "github.com/netbirdio/netbird/management/server/util"
+ "github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+type Zone struct {
+ ID string `gorm:"primaryKey"`
+ AccountID string `gorm:"index"`
+ Name string
+ Domain string
+ Enabled bool
+ EnableSearchDomain bool
+ DistributionGroups []string `gorm:"serializer:json"`
+ Records []*records.Record `gorm:"foreignKey:ZoneID;references:ID"`
+}
+
+func NewZone(accountID, name, domain string, enabled, enableSearchDomain bool, distributionGroups []string) *Zone {
+ return &Zone{
+ ID: xid.New().String(),
+ AccountID: accountID,
+ Name: name,
+ Domain: domain,
+ Enabled: enabled,
+ EnableSearchDomain: enableSearchDomain,
+ DistributionGroups: distributionGroups,
+ }
+}
+
+func (z *Zone) ToAPIResponse() *api.Zone {
+ apiRecords := make([]api.DNSRecord, 0, len(z.Records))
+ for _, record := range z.Records {
+ if apiRecord := record.ToAPIResponse(); apiRecord != nil {
+ apiRecords = append(apiRecords, *apiRecord)
+ }
+ }
+
+ return &api.Zone{
+ DistributionGroups: z.DistributionGroups,
+ Domain: z.Domain,
+ EnableSearchDomain: z.EnableSearchDomain,
+ Enabled: z.Enabled,
+ Id: z.ID,
+ Name: z.Name,
+ Records: apiRecords,
+ }
+}
+
+func (z *Zone) FromAPIRequest(req *api.ZoneRequest) {
+ z.Name = req.Name
+ z.Domain = req.Domain
+ z.EnableSearchDomain = req.EnableSearchDomain
+ z.DistributionGroups = req.DistributionGroups
+
+ enabled := true
+ if req.Enabled != nil {
+ enabled = *req.Enabled
+ }
+ z.Enabled = enabled
+}
+
+func (z *Zone) Validate() error {
+ if z.Name == "" {
+ return errors.New("zone name is required")
+ }
+ if len(z.Name) > 255 {
+ return errors.New("zone name exceeds maximum length of 255 characters")
+ }
+
+ if !util.IsValidDomain(z.Domain) {
+ return errors.New("invalid zone domain format")
+ }
+
+ if len(z.DistributionGroups) == 0 {
+ return errors.New("at least one distribution group is required")
+ }
+
+ return nil
+}
+
+func (z *Zone) EventMeta() map[string]any {
+ return map[string]any{"name": z.Name, "domain": z.Domain}
+}
diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go
index b2afe214e..55af17fdf 100644
--- a/management/internals/server/boot.go
+++ b/management/internals/server/boot.go
@@ -92,7 +92,7 @@ func (s *BaseServer) EventStore() activity.Store {
func (s *BaseServer) APIHandler() http.Handler {
return Create(s, func() http.Handler {
- httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.NetworkMapController(), s.IdpManager())
+ httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager())
if err != nil {
log.Fatalf("failed to create API handler: %v", err)
}
@@ -144,7 +144,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
}
gRPCAPIHandler := grpc.NewServer(gRPCOpts...)
- srv, err := nbgrpc.NewServer(s.Config, s.AccountManager(), s.SettingsManager(), s.SecretsManager(), s.Metrics(), s.AuthManager(), s.IntegratedValidator(), s.NetworkMapController(), s.OAuthConfigProvider())
+ srv, err := nbgrpc.NewServer(s.Config, s.AccountManager(), s.SettingsManager(), s.JobManager(), s.SecretsManager(), s.Metrics(), s.AuthManager(), s.IntegratedValidator(), s.NetworkMapController(), s.OAuthConfigProvider())
if err != nil {
log.Fatalf("failed to create management server: %v", err)
}
diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go
index 688ae5241..4ea86900a 100644
--- a/management/internals/server/controllers.go
+++ b/management/internals/server/controllers.go
@@ -6,6 +6,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/management-integrations/integrations"
+
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
nmapcontroller "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
@@ -16,6 +17,7 @@ import (
"github.com/netbirdio/netbird/management/server/auth"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
)
func (s *BaseServer) PeersUpdateManager() network_map.PeersUpdateManager {
@@ -24,6 +26,12 @@ func (s *BaseServer) PeersUpdateManager() network_map.PeersUpdateManager {
})
}
+func (s *BaseServer) JobManager() *job.Manager {
+ return Create(s, func() *job.Manager {
+ return job.NewJobManager(s.Metrics(), s.Store(), s.PeersManager())
+ })
+}
+
func (s *BaseServer) IntegratedValidator() integrated_validator.IntegratedValidator {
return Create(s, func() integrated_validator.IntegratedValidator {
integratedPeerValidator, err := integrations.NewIntegratedValidator(
@@ -68,7 +76,8 @@ func (s *BaseServer) AuthManager() auth.Manager {
if len(audiences) > 0 {
audience = audiences[0] // Use the first client ID as the primary audience
}
- keysLocation = oauthProvider.GetKeysLocation()
+ // Use localhost keys location for internal validation (management has embedded Dex)
+ keysLocation = oauthProvider.GetLocalKeysLocation()
signingKeyRefreshEnabled = true
issuer = oauthProvider.GetIssuer()
userIDClaim = oauthProvider.GetUserIDClaim()
diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go
index d179f2b68..b51e2ebb2 100644
--- a/management/internals/server/modules.go
+++ b/management/internals/server/modules.go
@@ -8,6 +8,10 @@ import (
"github.com/netbirdio/management-integrations/integrations"
"github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
+ recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager"
"github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/geolocation"
@@ -83,7 +87,7 @@ func (s *BaseServer) PeersManager() peers.Manager {
func (s *BaseServer) AccountManager() account.Manager {
return Create(s, func() account.Manager {
- accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.ProxyController(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy)
+ accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.JobManager(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.ProxyController(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy)
if err != nil {
log.Fatalf("failed to create account manager: %v", err)
}
@@ -158,3 +162,15 @@ func (s *BaseServer) NetworksManager() networks.Manager {
return networks.NewManager(s.Store(), s.PermissionsManager(), s.ResourcesManager(), s.RoutesManager(), s.AccountManager())
})
}
+
+func (s *BaseServer) ZonesManager() zones.Manager {
+ return Create(s, func() zones.Manager {
+ return zonesManager.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.DNSDomain())
+ })
+}
+
+func (s *BaseServer) RecordsManager() records.Manager {
+ return Create(s, func() records.Manager {
+ return recordsManager.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager())
+ })
+}
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index 455e6bd58..c74fa2660 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -374,8 +374,10 @@ func shouldUsePortRange(rule *proto.FirewallRule) bool {
// Helper function to convert nbdns.CustomZone to proto.CustomZone
func convertToProtoCustomZone(zone nbdns.CustomZone) *proto.CustomZone {
protoZone := &proto.CustomZone{
- Domain: zone.Domain,
- Records: make([]*proto.SimpleRecord, 0, len(zone.Records)),
+ Domain: zone.Domain,
+ Records: make([]*proto.SimpleRecord, 0, len(zone.Records)),
+ SearchDomainDisabled: zone.SearchDomainDisabled,
+ NonAuthoritative: zone.NonAuthoritative,
}
for _, record := range zone.Records {
protoZone.Records = append(protoZone.Records, &proto.SimpleRecord{
@@ -432,9 +434,16 @@ func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfi
if config.CLIAuthAudience != "" {
audience = config.CLIAuthAudience
}
+
+ audiences := []string{config.AuthAudience}
+ if config.CLIAuthAudience != "" && config.CLIAuthAudience != config.AuthAudience {
+ audiences = append(audiences, config.CLIAuthAudience)
+ }
+
return &proto.JWTConfig{
Issuer: issuer,
Audience: audience,
+ Audiences: audiences,
KeysLocation: keysLocation,
}
}
diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go
index 701271345..1e75caf95 100644
--- a/management/internals/shared/grpc/conversion_test.go
+++ b/management/internals/shared/grpc/conversion_test.go
@@ -6,9 +6,12 @@ import (
"reflect"
"testing"
+ "github.com/stretchr/testify/assert"
+
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+ nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
)
func TestToProtocolDNSConfigWithCache(t *testing.T) {
@@ -148,3 +151,52 @@ func generateTestData(size int) nbdns.Config {
return config
}
+
+func TestBuildJWTConfig_Audiences(t *testing.T) {
+ tests := []struct {
+ name string
+ authAudience string
+ cliAuthAudience string
+ expectedAudiences []string
+ expectedAudience string
+ }{
+ {
+ name: "only_auth_audience",
+ authAudience: "dashboard-aud",
+ cliAuthAudience: "",
+ expectedAudiences: []string{"dashboard-aud"},
+ expectedAudience: "dashboard-aud",
+ },
+ {
+ name: "both_audiences_different",
+ authAudience: "dashboard-aud",
+ cliAuthAudience: "cli-aud",
+ expectedAudiences: []string{"dashboard-aud", "cli-aud"},
+ expectedAudience: "cli-aud",
+ },
+ {
+ name: "both_audiences_same",
+ authAudience: "same-aud",
+ cliAuthAudience: "same-aud",
+ expectedAudiences: []string{"same-aud"},
+ expectedAudience: "same-aud",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ config := &nbconfig.HttpServerConfig{
+ AuthIssuer: "https://issuer.example.com",
+ AuthAudience: tc.authAudience,
+ CLIAuthAudience: tc.cliAuthAudience,
+ }
+
+ result := buildJWTConfig(config, nil)
+
+ assert.NotNil(t, result)
+ assert.Equal(t, tc.expectedAudiences, result.Audiences, "audiences should match expected")
+ //nolint:staticcheck // SA1019: Testing backwards compatibility - Audience field must still be populated
+ assert.Equal(t, tc.expectedAudience, result.Audience, "audience should match expected")
+ })
+ }
+}
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index 801c15158..1ff0243f4 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "io"
"net"
"net/netip"
"os"
@@ -26,6 +27,7 @@ import (
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/idp"
+ "github.com/netbirdio/netbird/management/server/job"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
"github.com/netbirdio/netbird/management/server/store"
@@ -57,6 +59,7 @@ type Server struct {
accountManager account.Manager
settingsManager settings.Manager
proto.UnimplementedManagementServiceServer
+ jobManager *job.Manager
config *nbconfig.Config
secretsManager SecretsManager
appMetrics telemetry.AppMetrics
@@ -82,6 +85,7 @@ func NewServer(
config *nbconfig.Config,
accountManager account.Manager,
settingsManager settings.Manager,
+ jobManager *job.Manager,
secretsManager SecretsManager,
appMetrics telemetry.AppMetrics,
authManager auth.Manager,
@@ -114,6 +118,7 @@ func NewServer(
}
return &Server{
+ jobManager: jobManager,
accountManager: accountManager,
settingsManager: settingsManager,
config: config,
@@ -169,6 +174,40 @@ func getRealIP(ctx context.Context) net.IP {
return nil
}
+func (s *Server) Job(srv proto.ManagementService_JobServer) error {
+ reqStart := time.Now()
+ ctx := srv.Context()
+
+ peerKey, err := s.handleHandshake(ctx, srv)
+ if err != nil {
+ return err
+ }
+
+ accountID, err := s.accountManager.GetAccountIDForPeerKey(ctx, peerKey.String())
+ if err != nil {
+ // nolint:staticcheck
+ ctx = context.WithValue(ctx, nbContext.AccountIDKey, "UNKNOWN")
+ log.WithContext(ctx).Tracef("peer %s is not registered", peerKey.String())
+ if errStatus, ok := internalStatus.FromError(err); ok && errStatus.Type() == internalStatus.NotFound {
+ return status.Errorf(codes.PermissionDenied, "peer is not registered")
+ }
+ return err
+ }
+ // nolint:staticcheck
+ ctx = context.WithValue(ctx, nbContext.AccountIDKey, accountID)
+ peer, err := s.accountManager.GetStore().GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerKey.String())
+ if err != nil {
+ return status.Errorf(codes.Unauthenticated, "peer is not registered")
+ }
+
+ s.startResponseReceiver(ctx, srv)
+
+ updates := s.jobManager.CreateJobChannel(ctx, accountID, peer.ID)
+ log.WithContext(ctx).Debugf("Job: took %v", time.Since(reqStart))
+
+ return s.sendJobsLoop(ctx, accountID, peerKey, peer, updates, srv)
+}
+
// Sync validates the existence of a connecting peer, sends an initial state (all available for the connecting peers) and
// notifies the connected peer of any updates (e.g. new peers under the same account)
func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_SyncServer) error {
@@ -289,6 +328,70 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv)
}
+func (s *Server) handleHandshake(ctx context.Context, srv proto.ManagementService_JobServer) (wgtypes.Key, error) {
+ hello, err := srv.Recv()
+ if err != nil {
+ return wgtypes.Key{}, status.Errorf(codes.InvalidArgument, "missing hello: %v", err)
+ }
+
+ jobReq := &proto.JobRequest{}
+ peerKey, err := s.parseRequest(ctx, hello, jobReq)
+ if err != nil {
+ return wgtypes.Key{}, err
+ }
+
+ return peerKey, nil
+}
+
+func (s *Server) startResponseReceiver(ctx context.Context, srv proto.ManagementService_JobServer) {
+ go func() {
+ for {
+ msg, err := srv.Recv()
+ if err != nil {
+ if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
+ return
+ }
+ log.WithContext(ctx).Warnf("recv job response error: %v", err)
+ return
+ }
+
+ jobResp := &proto.JobResponse{}
+ if _, err := s.parseRequest(ctx, msg, jobResp); err != nil {
+ log.WithContext(ctx).Warnf("invalid job response: %v", err)
+ continue
+ }
+
+ if err := s.jobManager.HandleResponse(ctx, jobResp, msg.WgPubKey); err != nil {
+ log.WithContext(ctx).Errorf("handle job response failed: %v", err)
+ }
+ }
+ }()
+}
+
+func (s *Server) sendJobsLoop(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates *job.Channel, srv proto.ManagementService_JobServer) error {
+ // todo figure out better error handling strategy
+ defer s.jobManager.CloseChannel(ctx, accountID, peer.ID)
+
+ for {
+ event, err := updates.Event(ctx)
+ if err != nil {
+ if errors.Is(err, job.ErrJobChannelClosed) {
+ log.WithContext(ctx).Debugf("jobs channel for peer %s was closed", peerKey.String())
+ return nil
+ }
+
+ // happens when connection drops, e.g. client disconnects
+ log.WithContext(ctx).Debugf("stream of peer %s has been closed", peerKey.String())
+ return ctx.Err()
+ }
+
+ if err := s.sendJob(ctx, peerKey, event, srv); err != nil {
+ log.WithContext(ctx).Warnf("send job failed: %v", err)
+ return nil
+ }
+ }
+}
+
// handleUpdates sends updates to the connected peer until the updates channel is closed.
func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer) error {
log.WithContext(ctx).Tracef("starting to handle updates for peer %s", peerKey.String())
@@ -306,7 +409,6 @@ func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wg
return nil
}
log.WithContext(ctx).Debugf("received an update for peer %s", peerKey.String())
-
if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv); err != nil {
log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err)
return err
@@ -336,7 +438,7 @@ func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtyp
s.cancelPeerRoutines(ctx, accountID, peer)
return status.Errorf(codes.Internal, "failed processing update message")
}
- err = srv.SendMsg(&proto.EncryptedMessage{
+ err = srv.Send(&proto.EncryptedMessage{
WgPubKey: key.PublicKey().String(),
Body: encryptedResp,
})
@@ -348,6 +450,31 @@ func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtyp
return nil
}
+// sendJob encrypts the update message using the peer key and the server's wireguard key,
+// then sends the encrypted message to the connected peer via the sync server.
+func (s *Server) sendJob(ctx context.Context, peerKey wgtypes.Key, job *job.Event, srv proto.ManagementService_JobServer) error {
+ wgKey, err := s.secretsManager.GetWGKey()
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to get wg key for peer %s: %v", peerKey.String(), err)
+ return status.Errorf(codes.Internal, "failed processing job message")
+ }
+
+ encryptedResp, err := encryption.EncryptMessage(peerKey, wgKey, job.Request)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to encrypt job for peer %s: %v", peerKey.String(), err)
+ return status.Errorf(codes.Internal, "failed processing job message")
+ }
+ err = srv.Send(&proto.EncryptedMessage{
+ WgPubKey: wgKey.PublicKey().String(),
+ Body: encryptedResp,
+ })
+ if err != nil {
+ return status.Errorf(codes.Internal, "failed sending job message")
+ }
+ log.WithContext(ctx).Debugf("sent a job to peer: %s", peerKey.String())
+ return nil
+}
+
func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer) {
unlock := s.acquirePeerLockByUID(ctx, peer.Key)
defer unlock()
@@ -690,8 +817,8 @@ func (s *Server) IsHealthy(ctx context.Context, req *proto.Empty) (*proto.Empty,
// sendInitialSync sends initial proto.SyncResponse to the peer requesting synchronization
func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*posture.Checks, srv proto.ManagementService_SyncServer, dnsFwdPort int64) error {
var err error
-
var turnToken *Token
+
if s.config.TURNConfig != nil && s.config.TURNConfig.TimeBasedCredentials {
turnToken, err = s.secretsManager.GenerateTurnToken()
if err != nil {
diff --git a/management/server/account.go b/management/server/account.go
index 9785f446c..d453b87c3 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -15,6 +15,7 @@ import (
"sync"
"time"
+ "github.com/netbirdio/netbird/management/server/job"
"github.com/netbirdio/netbird/shared/auth"
cacheStore "github.com/eko/gocache/lib/v4/store"
@@ -70,6 +71,7 @@ type DefaultAccountManager struct {
// cacheLoading keeps the accountIDs that are currently reloading. The accountID has to be removed once cache has been reloaded
cacheLoading map[string]chan struct{}
networkMapController network_map.Controller
+ jobManager *job.Manager
idpManager idp.Manager
cacheManager *nbcache.AccountUserDataCache
externalCacheManager nbcache.UserDataCache
@@ -178,6 +180,7 @@ func BuildManager(
config *nbconfig.Config,
store store.Store,
networkMapController network_map.Controller,
+ jobManager *job.Manager,
idpManager idp.Manager,
singleAccountModeDomain string,
eventStore activity.Store,
@@ -200,6 +203,7 @@ func BuildManager(
config: config,
geo: geo,
networkMapController: networkMapController,
+ jobManager: jobManager,
idpManager: idpManager,
ctx: context.Background(),
cacheMux: sync.Mutex{},
@@ -295,7 +299,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
return err
}
- if err = am.validateSettingsUpdate(ctx, newSettings, oldSettings, userID, accountID); err != nil {
+ if err = am.validateSettingsUpdate(ctx, transaction, newSettings, oldSettings, userID, accountID); err != nil {
return err
}
@@ -388,7 +392,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
return newSettings, nil
}
-func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, newSettings, oldSettings *types.Settings, userID, accountID string) error {
+func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, transaction store.Store, newSettings, oldSettings *types.Settings, userID, accountID string) error {
halfYearLimit := 180 * 24 * time.Hour
if newSettings.PeerLoginExpiration > halfYearLimit {
return status.Errorf(status.InvalidArgument, "peer login expiration can't be larger than 180 days")
@@ -402,6 +406,18 @@ func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, new
return status.Errorf(status.InvalidArgument, "invalid domain \"%s\" provided for DNS domain", newSettings.DNSDomain)
}
+ if newSettings.DNSDomain != oldSettings.DNSDomain && newSettings.DNSDomain != "" {
+ existingZone, err := transaction.GetZoneByDomain(ctx, accountID, newSettings.DNSDomain)
+ if err != nil {
+ if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound {
+ return fmt.Errorf("failed to check existing zone: %w", err)
+ }
+ }
+ if existingZone != nil {
+ return status.Errorf(status.InvalidArgument, "peer DNS domain %s conflicts with existing custom DNS zone", newSettings.DNSDomain)
+ }
+ }
+
return am.integratedPeerValidator.ValidateExtraSettings(ctx, newSettings.Extra, oldSettings.Extra, userID, accountID)
}
diff --git a/management/server/account/manager.go b/management/server/account/manager.go
index 7680a8464..11af67358 100644
--- a/management/server/account/manager.go
+++ b/management/server/account/manager.go
@@ -32,6 +32,7 @@ type Manager interface {
CreateUser(ctx context.Context, accountID, initiatorUserID string, key *types.UserInfo) (*types.UserInfo, error)
DeleteUser(ctx context.Context, accountID, initiatorUserID string, targetUserID string) error
DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error
+ UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error
InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error
ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error)
RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error
@@ -129,4 +130,7 @@ type Manager interface {
CreateIdentityProvider(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error
+ CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error
+ GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error)
+ GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error)
}
diff --git a/management/server/account_test.go b/management/server/account_test.go
index b5f15ed98..86cc69e8b 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -27,6 +27,7 @@ import (
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
"github.com/netbirdio/netbird/management/internals/modules/peers"
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/internals/server/config"
nbAccount "github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
@@ -34,6 +35,7 @@ import (
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
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"
@@ -397,7 +399,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
}
customZone := account.GetPeersCustomZone(context.Background(), "netbird.io")
- networkMap := account.GetPeerNetworkMap(context.Background(), testCase.peerID, customZone, validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
+ networkMap := account.GetPeerNetworkMap(context.Background(), testCase.peerID, customZone, nil, validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
assert.Len(t, networkMap.Peers, len(testCase.expectedPeers))
assert.Len(t, networkMap.OfflinePeers, len(testCase.expectedOfflinePeers))
}
@@ -1676,7 +1678,7 @@ func TestAccount_GetRoutesToSync(t *testing.T) {
},
}
- routes := account.GetRoutesToSync(context.Background(), "peer-2", []*nbpeer.Peer{{Key: "peer-1"}, {Key: "peer-3"}})
+ routes := account.GetRoutesToSync(context.Background(), "peer-2", []*nbpeer.Peer{{Key: "peer-1"}, {Key: "peer-3"}}, account.GetPeerGroups("peer-2"))
assert.Len(t, routes, 2)
routeIDs := make(map[route.ID]struct{}, 2)
@@ -1686,7 +1688,7 @@ func TestAccount_GetRoutesToSync(t *testing.T) {
assert.Contains(t, routeIDs, route.ID("route-2"))
assert.Contains(t, routeIDs, route.ID("route-3"))
- emptyRoutes := account.GetRoutesToSync(context.Background(), "peer-3", []*nbpeer.Peer{{Key: "peer-1"}, {Key: "peer-2"}})
+ emptyRoutes := account.GetRoutesToSync(context.Background(), "peer-3", []*nbpeer.Peer{{Key: "peer-1"}, {Key: "peer-2"}}, account.GetPeerGroups("peer-3"))
assert.Len(t, emptyRoutes, 0)
}
@@ -2095,6 +2097,35 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerApproval(t *testing.T)
}
}
+func TestDefaultAccountManager_UpdateAccountSettings_DNSDomainConflict(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")
+
+ ctx := context.Background()
+ err = manager.Store.CreateZone(ctx, &zones.Zone{
+ ID: "test-zone-id",
+ AccountID: accountID,
+ Name: "Test Zone",
+ Domain: "custom.example.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{},
+ })
+ require.NoError(t, err, "unable to create custom DNS zone")
+
+ _, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{
+ DNSDomain: "custom.example.com",
+ PeerLoginExpiration: time.Hour,
+ PeerLoginExpirationEnabled: false,
+ Extra: &types.ExtraSettings{},
+ })
+ require.Error(t, err, "expecting to fail when DNS domain conflicts with custom zone")
+ assert.Contains(t, err.Error(), "conflicts with existing custom DNS zone")
+}
+
func TestAccount_GetExpiredPeers(t *testing.T) {
type test struct {
name string
@@ -2993,13 +3024,14 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
AnyTimes()
permissionsManager := permissions.NewManager(store)
+ peersManager := peers.NewManager(store, permissionsManager)
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, store)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
- manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
if err != nil {
return nil, nil, err
}
diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go
index 7b939ddff..e9eaa644b 100644
--- a/management/server/activity/codes.go
+++ b/management/server/activity/codes.go
@@ -187,6 +187,18 @@ const (
IdentityProviderUpdated Activity = 94
IdentityProviderDeleted Activity = 95
+ DNSZoneCreated Activity = 96
+ DNSZoneUpdated Activity = 97
+ DNSZoneDeleted Activity = 98
+
+ DNSRecordCreated Activity = 99
+ DNSRecordUpdated Activity = 100
+ DNSRecordDeleted Activity = 101
+
+ JobCreatedByUser Activity = 102
+
+ UserPasswordChanged Activity = 103
+
AccountDeleted Activity = 99999
)
@@ -303,6 +315,18 @@ var activityMap = map[Activity]Code{
IdentityProviderCreated: {"Identity provider created", "identityprovider.create"},
IdentityProviderUpdated: {"Identity provider updated", "identityprovider.update"},
IdentityProviderDeleted: {"Identity provider deleted", "identityprovider.delete"},
+
+ DNSZoneCreated: {"DNS zone created", "dns.zone.create"},
+ DNSZoneUpdated: {"DNS zone updated", "dns.zone.update"},
+ DNSZoneDeleted: {"DNS zone deleted", "dns.zone.delete"},
+
+ DNSRecordCreated: {"DNS zone record created", "dns.zone.record.create"},
+ DNSRecordUpdated: {"DNS zone record updated", "dns.zone.record.update"},
+ DNSRecordDeleted: {"DNS zone record deleted", "dns.zone.record.delete"},
+
+ JobCreatedByUser: {"Create Job for peer", "peer.job.create"},
+
+ UserPasswordChanged: {"User password changed", "user.password.change"},
}
// StringCode returns a string code of the activity
diff --git a/management/server/cache/idp.go b/management/server/cache/idp.go
index 19dfc0f38..6ec42e217 100644
--- a/management/server/cache/idp.go
+++ b/management/server/cache/idp.go
@@ -26,6 +26,8 @@ type UserDataCache interface {
Get(ctx context.Context, key string) (*idp.UserData, error)
Set(ctx context.Context, key string, value *idp.UserData, expiration time.Duration) error
Delete(ctx context.Context, key string) error
+ GetUsers(ctx context.Context, key string) ([]*idp.UserData, error)
+ SetUsers(ctx context.Context, key string, users []*idp.UserData, expiration time.Duration) error
}
// UserDataCacheImpl is a struct that implements the UserDataCache interface.
@@ -51,6 +53,29 @@ func (u *UserDataCacheImpl) Delete(ctx context.Context, key string) error {
return u.cache.Delete(ctx, key)
}
+func (u *UserDataCacheImpl) GetUsers(ctx context.Context, key string) ([]*idp.UserData, error) {
+ var users []*idp.UserData
+ v, err := u.cache.Get(ctx, key, &users)
+ if err != nil {
+ return nil, err
+ }
+
+ switch v := v.(type) {
+ case []*idp.UserData:
+ return v, nil
+ case *[]*idp.UserData:
+ return *v, nil
+ case []byte:
+ return unmarshalUserData(v)
+ }
+
+ return nil, fmt.Errorf("unexpected type: %T", v)
+}
+
+func (u *UserDataCacheImpl) SetUsers(ctx context.Context, key string, users []*idp.UserData, expiration time.Duration) error {
+ return u.cache.Set(ctx, key, users, store.WithExpiration(expiration))
+}
+
// NewUserDataCache creates a new UserDataCacheImpl object.
func NewUserDataCache(store store.StoreInterface) *UserDataCacheImpl {
simpleCache := cache.New[any](store)
diff --git a/management/server/dns_test.go b/management/server/dns_test.go
index d1da79380..bd0755d0d 100644
--- a/management/server/dns_test.go
+++ b/management/server/dns_test.go
@@ -16,6 +16,7 @@ import (
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "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/management/server/store"
@@ -221,13 +222,14 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) {
// return empty extra settings for expected calls to UpdateAccountPeers
settingsMockManager.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil).AnyTimes()
permissionsManager := permissions.NewManager(store)
+ peersManager := peers.NewManager(store, permissionsManager)
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, store)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
- return BuildManager(context.Background(), nil, store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
}
func createDNSStore(t *testing.T) (store.Store, error) {
diff --git a/management/server/http/handler.go b/management/server/http/handler.go
index bbd6b4750..64f914afe 100644
--- a/management/server/http/handler.go
+++ b/management/server/http/handler.go
@@ -15,7 +15,10 @@ import (
"github.com/netbirdio/management-integrations/integrations"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
-
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
+ recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/settings"
@@ -56,7 +59,7 @@ const (
)
// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints.
-func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager) (http.Handler, error) {
+func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager) (http.Handler, error) {
// Register bypass paths for unauthenticated endpoints
if err := bypass.AddBypassPath("/api/instance"); err != nil {
@@ -138,6 +141,8 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
dns.AddEndpoints(accountManager, router)
events.AddEndpoints(accountManager, router)
networks.AddEndpoints(networksManager, resourceManager, routerManager, groupsManager, accountManager, router)
+ zonesManager.RegisterEndpoints(router, zManager)
+ recordsManager.RegisterEndpoints(router, rManager)
idp.AddEndpoints(accountManager, router)
instance.AddEndpoints(instanceManager, router)
diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go
index a5c9ab0ac..53d8ab055 100644
--- a/management/server/http/handlers/peers/peers_handler.go
+++ b/management/server/http/handlers/peers/peers_handler.go
@@ -10,6 +10,7 @@ import (
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
+ "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
@@ -35,6 +36,9 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMap
Methods("GET", "PUT", "DELETE", "OPTIONS")
router.HandleFunc("/peers/{peerId}/accessible-peers", peersHandler.GetAccessiblePeers).Methods("GET", "OPTIONS")
router.HandleFunc("/peers/{peerId}/temporary-access", peersHandler.CreateTemporaryAccess).Methods("POST", "OPTIONS")
+ router.HandleFunc("/peers/{peerId}/jobs", peersHandler.ListJobs).Methods("GET", "OPTIONS")
+ router.HandleFunc("/peers/{peerId}/jobs", peersHandler.CreateJob).Methods("POST", "OPTIONS")
+ router.HandleFunc("/peers/{peerId}/jobs/{jobId}", peersHandler.GetJob).Methods("GET", "OPTIONS")
}
// NewHandler creates a new peers Handler
@@ -45,6 +49,99 @@ func NewHandler(accountManager account.Manager, networkMapController network_map
}
}
+func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ userAuth, err := nbcontext.GetUserAuthFromContext(ctx)
+ if err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+
+ vars := mux.Vars(r)
+ peerID := vars["peerId"]
+
+ req := &api.JobRequest{}
+ if err := json.NewDecoder(r.Body).Decode(req); err != nil {
+ util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
+ return
+ }
+
+ job, err := types.NewJob(userAuth.UserId, userAuth.AccountId, peerID, req)
+ if err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+ if err := h.accountManager.CreatePeerJob(ctx, userAuth.AccountId, peerID, userAuth.UserId, job); err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+
+ resp, err := toSingleJobResponse(job)
+ if err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+
+ util.WriteJSONObject(ctx, w, resp)
+}
+
+func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ userAuth, err := nbcontext.GetUserAuthFromContext(ctx)
+ if err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+
+ vars := mux.Vars(r)
+ peerID := vars["peerId"]
+
+ jobs, err := h.accountManager.GetAllPeerJobs(ctx, userAuth.AccountId, userAuth.UserId, peerID)
+ if err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+
+ respBody := make([]*api.JobResponse, 0, len(jobs))
+ for _, job := range jobs {
+ resp, err := toSingleJobResponse(job)
+ if err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+ respBody = append(respBody, resp)
+ }
+
+ util.WriteJSONObject(ctx, w, respBody)
+}
+
+func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ userAuth, err := nbcontext.GetUserAuthFromContext(ctx)
+ if err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+
+ vars := mux.Vars(r)
+ peerID := vars["peerId"]
+ jobID := vars["jobId"]
+
+ job, err := h.accountManager.GetPeerJobByID(ctx, userAuth.AccountId, userAuth.UserId, peerID, jobID)
+ if err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+
+ resp, err := toSingleJobResponse(job)
+ if err != nil {
+ util.WriteError(ctx, err, w)
+ return
+ }
+
+ util.WriteJSONObject(ctx, w, resp)
+}
+
func (h *Handler) getPeer(ctx context.Context, accountID, peerID, userID string, w http.ResponseWriter) {
peer, err := h.accountManager.GetPeer(ctx, accountID, peerID, userID)
if err != nil {
@@ -298,8 +395,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
dnsDomain := h.networkMapController.GetDNSDomain(account.Settings)
- customZone := account.GetPeersCustomZone(r.Context(), dnsDomain)
- netMap := account.GetPeerNetworkMap(r.Context(), peerID, customZone, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
+ netMap := account.GetPeerNetworkMap(r.Context(), peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
util.WriteJSONObject(r.Context(), w, toAccessiblePeers(netMap, dnsDomain))
}
@@ -521,6 +617,28 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
}
}
+func toSingleJobResponse(job *types.Job) (*api.JobResponse, error) {
+ workload, err := job.BuildWorkloadResponse()
+ if err != nil {
+ return nil, err
+ }
+
+ var failed *string
+ if job.FailedReason != "" {
+ failed = &job.FailedReason
+ }
+
+ return &api.JobResponse{
+ Id: job.ID,
+ CreatedAt: job.CreatedAt,
+ CompletedAt: job.CompletedAt,
+ TriggeredBy: job.TriggeredBy,
+ Status: api.JobResponseStatus(job.Status),
+ FailedReason: failed,
+ Workload: *workload,
+ }, nil
+}
+
func fqdn(peer *nbpeer.Peer, dnsDomain string) string {
fqdn := peer.FQDN(dnsDomain)
if fqdn == "" {
diff --git a/management/server/http/handlers/users/users_handler.go b/management/server/http/handlers/users/users_handler.go
index 7669d7404..40ad585d2 100644
--- a/management/server/http/handlers/users/users_handler.go
+++ b/management/server/http/handlers/users/users_handler.go
@@ -33,6 +33,7 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router) {
router.HandleFunc("/users/{userId}/invite", userHandler.inviteUser).Methods("POST", "OPTIONS")
router.HandleFunc("/users/{userId}/approve", userHandler.approveUser).Methods("POST", "OPTIONS")
router.HandleFunc("/users/{userId}/reject", userHandler.rejectUser).Methods("DELETE", "OPTIONS")
+ router.HandleFunc("/users/{userId}/password", userHandler.changePassword).Methods("PUT", "OPTIONS")
addUsersTokensEndpoint(accountManager, router)
}
@@ -410,3 +411,46 @@ func (h *handler) rejectUser(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
}
+
+// passwordChangeRequest represents the request body for password change
+type passwordChangeRequest struct {
+ OldPassword string `json:"old_password"`
+ NewPassword string `json:"new_password"`
+}
+
+// changePassword is a PUT request to change user's password.
+// Only available when embedded IDP is enabled.
+// Users can only change their own password.
+func (h *handler) changePassword(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut {
+ util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
+ return
+ }
+
+ vars := mux.Vars(r)
+ targetUserID := vars["userId"]
+ if len(targetUserID) == 0 {
+ util.WriteErrorResponse("invalid user ID", http.StatusBadRequest, w)
+ return
+ }
+
+ userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ var req passwordChangeRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
+ return
+ }
+
+ err = h.accountManager.UpdateUserPassword(r.Context(), userAuth.AccountId, userAuth.UserId, targetUserID, req.OldPassword, req.NewPassword)
+ if err != nil {
+ util.WriteError(r.Context(), err, w)
+ return
+ }
+
+ util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
+}
diff --git a/management/server/http/handlers/users/users_handler_test.go b/management/server/http/handlers/users/users_handler_test.go
index 37f0a6c1d..aa77dd843 100644
--- a/management/server/http/handlers/users/users_handler_test.go
+++ b/management/server/http/handlers/users/users_handler_test.go
@@ -856,3 +856,118 @@ func TestRejectUserEndpoint(t *testing.T) {
})
}
}
+
+func TestChangePasswordEndpoint(t *testing.T) {
+ tt := []struct {
+ name string
+ expectedStatus int
+ requestBody string
+ targetUserID string
+ currentUserID string
+ mockError error
+ expectMockNotCalled bool
+ }{
+ {
+ name: "successful password change",
+ expectedStatus: http.StatusOK,
+ requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`,
+ targetUserID: existingUserID,
+ currentUserID: existingUserID,
+ mockError: nil,
+ },
+ {
+ name: "missing old password",
+ expectedStatus: http.StatusUnprocessableEntity,
+ requestBody: `{"new_password": "NewPass456!"}`,
+ targetUserID: existingUserID,
+ currentUserID: existingUserID,
+ mockError: status.Errorf(status.InvalidArgument, "old password is required"),
+ },
+ {
+ name: "missing new password",
+ expectedStatus: http.StatusUnprocessableEntity,
+ requestBody: `{"old_password": "OldPass123!"}`,
+ targetUserID: existingUserID,
+ currentUserID: existingUserID,
+ mockError: status.Errorf(status.InvalidArgument, "new password is required"),
+ },
+ {
+ name: "wrong old password",
+ expectedStatus: http.StatusUnprocessableEntity,
+ requestBody: `{"old_password": "WrongPass!", "new_password": "NewPass456!"}`,
+ targetUserID: existingUserID,
+ currentUserID: existingUserID,
+ mockError: status.Errorf(status.InvalidArgument, "invalid password"),
+ },
+ {
+ name: "embedded IDP not enabled",
+ expectedStatus: http.StatusPreconditionFailed,
+ requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`,
+ targetUserID: existingUserID,
+ currentUserID: existingUserID,
+ mockError: status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider"),
+ },
+ {
+ name: "invalid JSON request",
+ expectedStatus: http.StatusBadRequest,
+ requestBody: `{invalid json}`,
+ targetUserID: existingUserID,
+ currentUserID: existingUserID,
+ expectMockNotCalled: true,
+ },
+ }
+
+ for _, tc := range tt {
+ t.Run(tc.name, func(t *testing.T) {
+ mockCalled := false
+ am := &mock_server.MockAccountManager{}
+ am.UpdateUserPasswordFunc = func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
+ mockCalled = true
+ return tc.mockError
+ }
+
+ handler := newHandler(am)
+ router := mux.NewRouter()
+ router.HandleFunc("/users/{userId}/password", handler.changePassword).Methods("PUT")
+
+ reqPath := "/users/" + tc.targetUserID + "/password"
+ req, err := http.NewRequest("PUT", reqPath, bytes.NewBufferString(tc.requestBody))
+ require.NoError(t, err)
+
+ userAuth := auth.UserAuth{
+ AccountId: existingAccountID,
+ UserId: tc.currentUserID,
+ }
+ ctx := nbcontext.SetUserAuthInContext(req.Context(), userAuth)
+ req = req.WithContext(ctx)
+
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+
+ assert.Equal(t, tc.expectedStatus, rr.Code)
+
+ if tc.expectMockNotCalled {
+ assert.False(t, mockCalled, "mock should not have been called")
+ }
+ })
+ }
+}
+
+func TestChangePasswordEndpoint_WrongMethod(t *testing.T) {
+ am := &mock_server.MockAccountManager{}
+ handler := newHandler(am)
+
+ req, err := http.NewRequest("POST", "/users/test-user/password", bytes.NewBufferString(`{}`))
+ require.NoError(t, err)
+
+ userAuth := auth.UserAuth{
+ AccountId: existingAccountID,
+ UserId: existingUserID,
+ }
+ req = nbcontext.SetUserAuthInRequest(req, userAuth)
+
+ rr := httptest.NewRecorder()
+ handler.changePassword(rr, req)
+
+ assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
+}
diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go
index 966a6802a..257347153 100644
--- a/management/server/http/middleware/auth_middleware.go
+++ b/management/server/http/middleware/auth_middleware.go
@@ -178,7 +178,7 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts []
m.patUsageTracker.IncrementUsage(token)
}
- if m.rateLimiter != nil {
+ if m.rateLimiter != nil && !isTerraformRequest(r) {
if !m.rateLimiter.Allow(token) {
return r, status.Errorf(status.TooManyRequests, "too many requests")
}
@@ -214,6 +214,11 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts []
return nbcontext.SetUserAuthInRequest(r, userAuth), nil
}
+func isTerraformRequest(r *http.Request) bool {
+ ua := strings.ToLower(r.Header.Get("User-Agent"))
+ return strings.Contains(ua, "terraform")
+}
+
// getTokenFromJWTRequest is a "TokenExtractor" that takes auth header parts and extracts
// the JWT token from the Authorization header.
func getTokenFromJWTRequest(authHeaderParts []string) (string, error) {
diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go
index ba4d16796..05ca59419 100644
--- a/management/server/http/middleware/auth_middleware_test.go
+++ b/management/server/http/middleware/auth_middleware_test.go
@@ -508,6 +508,103 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request after cleanup should be rate limited again")
})
+
+ t.Run("Terraform User Agent Not Rate Limited", func(t *testing.T) {
+ rateLimitConfig := &RateLimiterConfig{
+ RequestsPerMinute: 1,
+ Burst: 1,
+ CleanupInterval: 5 * time.Minute,
+ LimiterTTL: 10 * time.Minute,
+ }
+
+ authMiddleware := NewAuthMiddleware(
+ mockAuth,
+ func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
+ return userAuth.AccountId, userAuth.UserId, nil
+ },
+ func(ctx context.Context, userAuth nbauth.UserAuth) error {
+ return nil
+ },
+ func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
+ return &types.User{}, nil
+ },
+ rateLimitConfig,
+ nil,
+ )
+
+ handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ // Test various Terraform user agent formats
+ terraformUserAgents := []string{
+ "Terraform/1.5.0",
+ "terraform/1.0.0",
+ "Terraform-Provider/2.0.0",
+ "Mozilla/5.0 (compatible; Terraform/1.3.0)",
+ }
+
+ for _, userAgent := range terraformUserAgents {
+ t.Run("UserAgent: "+userAgent, func(t *testing.T) {
+ successCount := 0
+ for i := 0; i < 10; i++ {
+ req := httptest.NewRequest("GET", "http://testing/test", nil)
+ req.Header.Set("Authorization", "Token "+PAT)
+ req.Header.Set("User-Agent", userAgent)
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+ if rec.Code == http.StatusOK {
+ successCount++
+ }
+ }
+
+ assert.Equal(t, 10, successCount, "All Terraform user agent requests should succeed (not rate limited)")
+ })
+ }
+ })
+
+ t.Run("Non-Terraform User Agent With PAT Is Rate Limited", func(t *testing.T) {
+ rateLimitConfig := &RateLimiterConfig{
+ RequestsPerMinute: 1,
+ Burst: 1,
+ CleanupInterval: 5 * time.Minute,
+ LimiterTTL: 10 * time.Minute,
+ }
+
+ authMiddleware := NewAuthMiddleware(
+ mockAuth,
+ func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
+ return userAuth.AccountId, userAuth.UserId, nil
+ },
+ func(ctx context.Context, userAuth nbauth.UserAuth) error {
+ return nil
+ },
+ func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
+ return &types.User{}, nil
+ },
+ rateLimitConfig,
+ nil,
+ )
+
+ handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "http://testing/test", nil)
+ req.Header.Set("Authorization", "Token "+PAT)
+ req.Header.Set("User-Agent", "curl/7.68.0")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusOK, rec.Code, "First request should succeed")
+
+ req = httptest.NewRequest("GET", "http://testing/test", nil)
+ req.Header.Set("Authorization", "Token "+PAT)
+ req.Header.Set("User-Agent", "curl/7.68.0")
+ rec = httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request should be rate limited")
+ })
}
func TestAuthMiddleware_Handler_Child(t *testing.T) {
diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go
index 656f72997..9339c3541 100644
--- a/management/server/http/testing/testing_tools/channel/channel.go
+++ b/management/server/http/testing/testing_tools/channel/channel.go
@@ -10,6 +10,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/netbirdio/management-integrations/integrations"
+
+ zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager"
+ recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
@@ -18,6 +21,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/peers"
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
"github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/account"
@@ -70,11 +74,14 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
userManager := users.NewManager(store)
permissionsManager := permissions.NewManager(store)
settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager)
+ 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, peers.NewManager(store, permissionsManager)), &config.Config{})
- am, err := server.BuildManager(ctx, nil, store, networkMapController, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false)
+ 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)
}
@@ -92,9 +99,10 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
resourcesManagerMock := resources.NewManagerMock()
routersManagerMock := routers.NewManagerMock()
groupsManagerMock := groups.NewManagerMock()
- peersManager := peers.NewManager(store, permissionsManager)
+ 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, networkMapController, nil)
+ apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil)
if err != nil {
t.Fatalf("Failed to create API handler: %v", err)
}
diff --git a/management/server/identity_provider.go b/management/server/identity_provider.go
index 6649c3953..8fd96c238 100644
--- a/management/server/identity_provider.go
+++ b/management/server/identity_provider.go
@@ -2,7 +2,13 @@ package server
import (
"context"
+ "encoding/json"
"errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
"github.com/dexidp/dex/storage"
"github.com/rs/xid"
@@ -17,6 +23,69 @@ import (
"github.com/netbirdio/netbird/shared/management/status"
)
+// oidcProviderJSON represents the OpenID Connect discovery document
+type oidcProviderJSON struct {
+ Issuer string `json:"issuer"`
+}
+
+// validateOIDCIssuer validates the OIDC issuer by fetching the OpenID configuration
+// and verifying that the returned issuer matches the configured one.
+func validateOIDCIssuer(ctx context.Context, issuer string) error {
+ wellKnown := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
+
+ httpClient := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil)
+ if err != nil {
+ return fmt.Errorf("%w: %v", types.ErrIdentityProviderIssuerUnreachable, err)
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("%w: %v", types.ErrIdentityProviderIssuerUnreachable, err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("%w: unable to read response body: %v", types.ErrIdentityProviderIssuerUnreachable, err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%w: %s: %s", types.ErrIdentityProviderIssuerUnreachable, resp.Status, body)
+ }
+
+ var p oidcProviderJSON
+ if err := json.Unmarshal(body, &p); err != nil {
+ return fmt.Errorf("%w: failed to decode provider discovery object: %v", types.ErrIdentityProviderIssuerUnreachable, err)
+ }
+
+ if p.Issuer != issuer {
+ return fmt.Errorf("%w: expected %q got %q", types.ErrIdentityProviderIssuerMismatch, issuer, p.Issuer)
+ }
+
+ return nil
+}
+
+// validateIdentityProviderConfig validates the identity provider configuration including
+// basic validation and OIDC issuer verification.
+func validateIdentityProviderConfig(ctx context.Context, idpConfig *types.IdentityProvider) error {
+ if err := idpConfig.Validate(); err != nil {
+ return status.Errorf(status.InvalidArgument, "%s", err.Error())
+ }
+
+ // Validate the issuer by calling the OIDC discovery endpoint
+ if idpConfig.Issuer != "" {
+ if err := validateOIDCIssuer(ctx, idpConfig.Issuer); err != nil {
+ return status.Errorf(status.InvalidArgument, "%s", err.Error())
+ }
+ }
+
+ return nil
+}
+
// GetIdentityProviders returns all identity providers for an account
func (am *DefaultAccountManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) {
ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
@@ -82,8 +151,8 @@ func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, acc
return nil, status.NewPermissionDeniedError()
}
- if err := idpConfig.Validate(); err != nil {
- return nil, status.Errorf(status.InvalidArgument, "%s", err.Error())
+ if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil {
+ return nil, err
}
embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
@@ -119,8 +188,8 @@ func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, acc
return nil, status.NewPermissionDeniedError()
}
- if err := idpConfig.Validate(); err != nil {
- return nil, status.Errorf(status.InvalidArgument, "%s", err.Error())
+ if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil {
+ return nil, err
}
embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go
index d637c4a8f..9fce6b9c0 100644
--- a/management/server/identity_provider_test.go
+++ b/management/server/identity_provider_test.go
@@ -2,6 +2,10 @@ package server
import (
"context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
"path/filepath"
"testing"
@@ -17,6 +21,7 @@ import (
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "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/management/server/store"
@@ -76,11 +81,12 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update
AnyTimes()
permissionsManager := permissions.NewManager(testStore)
+ peersManager := peers.NewManager(testStore, permissionsManager)
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, testStore)
- networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peers.NewManager(testStore, permissionsManager)), &config.Config{})
- manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{})
+ manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
if err != nil {
return nil, nil, err
}
@@ -200,3 +206,109 @@ func TestDefaultAccountManager_UpdateIdentityProvider_Validation(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "name is required")
}
+
+func TestValidateOIDCIssuer(t *testing.T) {
+ tests := []struct {
+ name string
+ setupServer func() *httptest.Server
+ expectedErr error
+ expectedErrMsg string
+ }{
+ {
+ name: "issuer mismatch",
+ setupServer: func() *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := oidcProviderJSON{Issuer: "https://different-issuer.com"}
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+ }))
+ },
+ expectedErr: types.ErrIdentityProviderIssuerMismatch,
+ expectedErrMsg: "does not match",
+ },
+ {
+ name: "server returns non-200 status",
+ setupServer: func() *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte("not found"))
+ }))
+ },
+ expectedErr: types.ErrIdentityProviderIssuerUnreachable,
+ expectedErrMsg: "404",
+ },
+ {
+ name: "server returns invalid JSON",
+ setupServer: func() *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte("invalid json"))
+ }))
+ },
+ expectedErr: types.ErrIdentityProviderIssuerUnreachable,
+ expectedErrMsg: "failed to decode",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := tt.setupServer()
+ defer server.Close()
+
+ err := validateOIDCIssuer(context.Background(), server.URL)
+
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, tt.expectedErr), "expected error %v, got %v", tt.expectedErr, err)
+ if tt.expectedErrMsg != "" {
+ assert.Contains(t, err.Error(), tt.expectedErrMsg)
+ }
+ })
+ }
+}
+
+func TestValidateOIDCIssuer_Success(t *testing.T) {
+ // Create a server that returns its own URL as the issuer
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/.well-known/openid-configuration" {
+ http.NotFound(w, r)
+ return
+ }
+ resp := oidcProviderJSON{Issuer: server.URL}
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ err := validateOIDCIssuer(context.Background(), server.URL)
+ require.NoError(t, err)
+}
+
+func TestValidateOIDCIssuer_UnreachableServer(t *testing.T) {
+ // Use a URL that will definitely fail to connect
+ err := validateOIDCIssuer(context.Background(), "http://localhost:59999")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, types.ErrIdentityProviderIssuerUnreachable))
+}
+
+func TestValidateOIDCIssuer_TrailingSlash(t *testing.T) {
+ // Test that trailing slashes are handled correctly
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/.well-known/openid-configuration" {
+ http.NotFound(w, r)
+ return
+ }
+ // Return issuer without trailing slash
+ resp := oidcProviderJSON{Issuer: server.URL}
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ // Pass issuer with trailing slash
+ err := validateOIDCIssuer(context.Background(), server.URL+"/")
+ // This should fail because the issuer returned doesn't have trailing slash
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, types.ErrIdentityProviderIssuerMismatch))
+}
diff --git a/management/server/idp/auth0.go b/management/server/idp/auth0.go
index 1eb8434d3..0d4461e89 100644
--- a/management/server/idp/auth0.go
+++ b/management/server/idp/auth0.go
@@ -135,10 +135,11 @@ func NewAuth0Manager(config Auth0ClientConfig, appMetrics telemetry.AppMetrics)
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
httpTransport.MaxIdleConns = 5
- httpClient := &http.Client{
- Timeout: 10 * time.Second,
+ httpClient := &http.Client{
+ Timeout: idpTimeout(),
Transport: httpTransport,
}
+
helper := JsonParser{}
if config.AuthIssuer == "" {
diff --git a/management/server/idp/authentik.go b/management/server/idp/authentik.go
index 2f87a9bba..0f30cc63d 100644
--- a/management/server/idp/authentik.go
+++ b/management/server/idp/authentik.go
@@ -48,16 +48,15 @@ type AuthentikCredentials struct {
}
// NewAuthentikManager creates a new instance of the AuthentikManager.
-func NewAuthentikManager(config AuthentikClientConfig,
- appMetrics telemetry.AppMetrics) (*AuthentikManager, error) {
+func NewAuthentikManager(config AuthentikClientConfig, appMetrics telemetry.AppMetrics) (*AuthentikManager, error) {
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
httpTransport.MaxIdleConns = 5
httpClient := &http.Client{
- Timeout: 10 * time.Second,
+ Timeout: idpTimeout(),
Transport: httpTransport,
}
-
+
helper := JsonParser{}
if config.ClientID == "" {
diff --git a/management/server/idp/azure.go b/management/server/idp/azure.go
index 393a39e3e..e098424b5 100644
--- a/management/server/idp/azure.go
+++ b/management/server/idp/azure.go
@@ -57,10 +57,11 @@ func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics)
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
httpTransport.MaxIdleConns = 5
- httpClient := &http.Client{
- Timeout: 10 * time.Second,
+ httpClient := &http.Client{
+ Timeout: idpTimeout(),
Transport: httpTransport,
}
+
helper := JsonParser{}
if config.ClientID == "" {
diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go
index 963b5ae3d..79859525b 100644
--- a/management/server/idp/embedded.go
+++ b/management/server/idp/embedded.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
+ "strings"
"github.com/dexidp/dex/storage"
"github.com/google/uuid"
@@ -19,7 +20,7 @@ const (
staticClientCLI = "netbird-cli"
defaultCLIRedirectURL1 = "http://localhost:53000/"
defaultCLIRedirectURL2 = "http://localhost:54000/"
- defaultScopes = "openid profile email offline_access"
+ defaultScopes = "openid profile email"
defaultUserIDClaim = "sub"
)
@@ -27,8 +28,11 @@ const (
type EmbeddedIdPConfig struct {
// Enabled indicates whether the embedded IDP is enabled
Enabled bool
- // Issuer is the OIDC issuer URL (e.g., "http://localhost:3002/oauth2")
+ // Issuer is the OIDC issuer URL (e.g., "https://management.netbird.io/oauth2")
Issuer string
+ // LocalAddress is the management server's local listen address (e.g., ":8080" or "localhost:8080")
+ // Used for internal JWT validation to avoid external network calls
+ LocalAddress string
// Storage configuration for the IdP database
Storage EmbeddedStorageConfig
// DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client
@@ -146,7 +150,12 @@ var _ OAuthConfigProvider = (*EmbeddedIdPManager)(nil)
// OAuthConfigProvider defines the interface for OAuth configuration needed by auth flows.
type OAuthConfigProvider interface {
GetIssuer() string
+ // GetKeysLocation returns the public JWKS endpoint URL (uses external issuer URL)
GetKeysLocation() string
+ // GetLocalKeysLocation returns the localhost JWKS endpoint URL for internal use.
+ // Management server has embedded Dex and can validate tokens via localhost,
+ // avoiding external network calls and DNS resolution issues during startup.
+ GetLocalKeysLocation() string
GetClientIDs() []string
GetUserIDClaim() string
GetTokenEndpoint() string
@@ -391,7 +400,6 @@ func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email,
// InviteUserByID resends an invitation to a user.
func (m *EmbeddedIdPManager) InviteUserByID(ctx context.Context, userID string) error {
- // TODO: implement
return fmt.Errorf("not implemented")
}
@@ -423,6 +431,33 @@ func (m *EmbeddedIdPManager) DeleteUser(ctx context.Context, userID string) erro
return nil
}
+// UpdateUserPassword updates the password for a user in the embedded IdP.
+// It verifies that the current user is changing their own password and
+// validates the current password before updating to the new password.
+func (m *EmbeddedIdPManager) UpdateUserPassword(ctx context.Context, currentUserID, targetUserID string, oldPassword, newPassword string) error {
+ // Verify the user is changing their own password
+ if currentUserID != targetUserID {
+ return fmt.Errorf("users can only change their own password")
+ }
+
+ // Verify the new password is different from the old password
+ if oldPassword == newPassword {
+ return fmt.Errorf("new password must be different from current password")
+ }
+
+ err := m.provider.UpdateUserPassword(ctx, targetUserID, oldPassword, newPassword)
+ if err != nil {
+ if m.appMetrics != nil {
+ m.appMetrics.IDPMetrics().CountRequestError()
+ }
+ return err
+ }
+
+ log.WithContext(ctx).Debugf("updated password for user %s in embedded IdP", targetUserID)
+
+ return nil
+}
+
// CreateConnector creates a new identity provider connector in Dex.
// Returns the created connector config with the redirect URL populated.
func (m *EmbeddedIdPManager) CreateConnector(ctx context.Context, cfg *dex.ConnectorConfig) (*dex.ConnectorConfig, error) {
@@ -440,15 +475,8 @@ func (m *EmbeddedIdPManager) ListConnectors(ctx context.Context) ([]*dex.Connect
}
// UpdateConnector updates an existing identity provider connector.
+// Field preservation for partial updates is handled by Provider.UpdateConnector.
func (m *EmbeddedIdPManager) UpdateConnector(ctx context.Context, cfg *dex.ConnectorConfig) error {
- // Preserve existing secret if not provided in update
- if cfg.ClientSecret == "" {
- existing, err := m.provider.GetConnector(ctx, cfg.ID)
- if err != nil {
- return fmt.Errorf("failed to get existing connector: %w", err)
- }
- cfg.ClientSecret = existing.ClientSecret
- }
return m.provider.UpdateConnector(ctx, cfg)
}
@@ -500,6 +528,22 @@ func (m *EmbeddedIdPManager) GetKeysLocation() string {
return m.provider.GetKeysLocation()
}
+// GetLocalKeysLocation returns the localhost JWKS endpoint URL for internal token validation.
+// Uses the LocalAddress from config (management server's listen address) since embedded Dex
+// is served by the management HTTP server, not a standalone Dex server.
+func (m *EmbeddedIdPManager) GetLocalKeysLocation() string {
+ addr := m.config.LocalAddress
+ if addr == "" {
+ return ""
+ }
+ // Construct localhost URL from listen address
+ // addr is in format ":port" or "host:port" or "localhost:port"
+ if strings.HasPrefix(addr, ":") {
+ return fmt.Sprintf("http://localhost%s/oauth2/keys", addr)
+ }
+ return fmt.Sprintf("http://%s/oauth2/keys", addr)
+}
+
// GetClientIDs returns the OAuth2 client IDs configured for this provider.
func (m *EmbeddedIdPManager) GetClientIDs() []string {
return []string{staticClientDashboard, staticClientCLI}
diff --git a/management/server/idp/embedded_test.go b/management/server/idp/embedded_test.go
index cfd9c2b54..d8d3009dd 100644
--- a/management/server/idp/embedded_test.go
+++ b/management/server/idp/embedded_test.go
@@ -247,3 +247,126 @@ func TestEmbeddedIdPManager_UserIDFormat_MatchesJWT(t *testing.T) {
t.Logf(" Raw UUID: %s", rawUserID)
t.Logf(" Connector: %s", connectorID)
}
+
+func TestEmbeddedIdPManager_UpdateUserPassword(t *testing.T) {
+ ctx := context.Background()
+
+ tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
+ require.NoError(t, err)
+ defer os.RemoveAll(tmpDir)
+
+ config := &EmbeddedIdPConfig{
+ Enabled: true,
+ Issuer: "http://localhost:5556/dex",
+ Storage: EmbeddedStorageConfig{
+ Type: "sqlite3",
+ Config: EmbeddedStorageTypeConfig{
+ File: filepath.Join(tmpDir, "dex.db"),
+ },
+ },
+ }
+
+ manager, err := NewEmbeddedIdPManager(ctx, config, nil)
+ require.NoError(t, err)
+ defer func() { _ = manager.Stop(ctx) }()
+
+ // Create a user with a known password
+ email := "password-test@example.com"
+ name := "Password Test User"
+ initialPassword := "InitialPass123!"
+
+ userData, err := manager.CreateUserWithPassword(ctx, email, initialPassword, name)
+ require.NoError(t, err)
+ require.NotNil(t, userData)
+
+ userID := userData.ID
+
+ t.Run("successful password change", func(t *testing.T) {
+ newPassword := "NewSecurePass456!"
+ err := manager.UpdateUserPassword(ctx, userID, userID, initialPassword, newPassword)
+ require.NoError(t, err)
+
+ // Verify the new password works by changing it again
+ anotherPassword := "AnotherPass789!"
+ err = manager.UpdateUserPassword(ctx, userID, userID, newPassword, anotherPassword)
+ require.NoError(t, err)
+ })
+
+ t.Run("wrong old password", func(t *testing.T) {
+ err := manager.UpdateUserPassword(ctx, userID, userID, "wrongpassword", "NewPass123!")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "current password is incorrect")
+ })
+
+ t.Run("cannot change other user password", func(t *testing.T) {
+ otherUserID := "other-user-id"
+ err := manager.UpdateUserPassword(ctx, userID, otherUserID, "oldpass", "newpass")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "users can only change their own password")
+ })
+
+ t.Run("same password rejected", func(t *testing.T) {
+ samePassword := "SamePass123!"
+ err := manager.UpdateUserPassword(ctx, userID, userID, samePassword, samePassword)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "new password must be different")
+ })
+}
+
+func TestEmbeddedIdPManager_GetLocalKeysLocation(t *testing.T) {
+ ctx := context.Background()
+
+ tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
+ require.NoError(t, err)
+ defer os.RemoveAll(tmpDir)
+
+ tests := []struct {
+ name string
+ localAddress string
+ expected string
+ }{
+ {
+ name: "localhost with port",
+ localAddress: "localhost:8080",
+ expected: "http://localhost:8080/oauth2/keys",
+ },
+ {
+ name: "localhost with https port",
+ localAddress: "localhost:443",
+ expected: "http://localhost:443/oauth2/keys",
+ },
+ {
+ name: "port only format",
+ localAddress: ":8080",
+ expected: "http://localhost:8080/oauth2/keys",
+ },
+ {
+ name: "empty address",
+ localAddress: "",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ config := &EmbeddedIdPConfig{
+ Enabled: true,
+ Issuer: "http://localhost:5556/dex",
+ LocalAddress: tt.localAddress,
+ Storage: EmbeddedStorageConfig{
+ Type: "sqlite3",
+ Config: EmbeddedStorageTypeConfig{
+ File: filepath.Join(tmpDir, "dex-"+tt.name+".db"),
+ },
+ },
+ }
+
+ manager, err := NewEmbeddedIdPManager(ctx, config, nil)
+ require.NoError(t, err)
+ defer func() { _ = manager.Stop(ctx) }()
+
+ result := manager.GetLocalKeysLocation()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
diff --git a/management/server/idp/google_workspace.go b/management/server/idp/google_workspace.go
index 09ea8c430..6e417d394 100644
--- a/management/server/idp/google_workspace.go
+++ b/management/server/idp/google_workspace.go
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"fmt"
"net/http"
- "time"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2/google"
@@ -49,9 +48,10 @@ func NewGoogleWorkspaceManager(ctx context.Context, config GoogleWorkspaceClient
httpTransport.MaxIdleConns = 5
httpClient := &http.Client{
- Timeout: 10 * time.Second,
+ Timeout: idpTimeout(),
Transport: httpTransport,
}
+
helper := JsonParser{}
if config.CustomerID == "" {
diff --git a/management/server/idp/jumpcloud.go b/management/server/idp/jumpcloud.go
index 6345e424a..8c4a9d089 100644
--- a/management/server/idp/jumpcloud.go
+++ b/management/server/idp/jumpcloud.go
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"strings"
- "time"
v1 "github.com/TheJumpCloud/jcapi-go/v1"
@@ -46,9 +45,10 @@ func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppM
httpTransport.MaxIdleConns = 5
httpClient := &http.Client{
- Timeout: 10 * time.Second,
+ Timeout: idpTimeout(),
Transport: httpTransport,
}
+
helper := JsonParser{}
if config.APIToken == "" {
diff --git a/management/server/idp/keycloak.go b/management/server/idp/keycloak.go
index c611317ab..b640f7520 100644
--- a/management/server/idp/keycloak.go
+++ b/management/server/idp/keycloak.go
@@ -63,9 +63,10 @@ func NewKeycloakManager(config KeycloakClientConfig, appMetrics telemetry.AppMet
httpTransport.MaxIdleConns = 5
httpClient := &http.Client{
- Timeout: 10 * time.Second,
+ Timeout: idpTimeout(),
Transport: httpTransport,
}
+
helper := JsonParser{}
if config.ClientID == "" {
diff --git a/management/server/idp/okta.go b/management/server/idp/okta.go
index b9cd006be..07f0d8008 100644
--- a/management/server/idp/okta.go
+++ b/management/server/idp/okta.go
@@ -6,7 +6,6 @@ import (
"net/http"
"net/url"
"strings"
- "time"
"github.com/okta/okta-sdk-golang/v2/okta"
"github.com/okta/okta-sdk-golang/v2/okta/query"
@@ -45,7 +44,7 @@ func NewOktaManager(config OktaClientConfig, appMetrics telemetry.AppMetrics) (*
httpTransport.MaxIdleConns = 5
httpClient := &http.Client{
- Timeout: 10 * time.Second,
+ Timeout: idpTimeout(),
Transport: httpTransport,
}
diff --git a/management/server/idp/pocketid.go b/management/server/idp/pocketid.go
index d8d764830..ee8e304ee 100644
--- a/management/server/idp/pocketid.go
+++ b/management/server/idp/pocketid.go
@@ -8,7 +8,6 @@ import (
"net/url"
"slices"
"strings"
- "time"
"github.com/netbirdio/netbird/management/server/telemetry"
)
@@ -88,9 +87,10 @@ func NewPocketIdManager(config PocketIdClientConfig, appMetrics telemetry.AppMet
httpTransport.MaxIdleConns = 5
httpClient := &http.Client{
- Timeout: 10 * time.Second,
+ Timeout: idpTimeout(),
Transport: httpTransport,
}
+
helper := JsonParser{}
if config.ManagementEndpoint == "" {
diff --git a/management/server/idp/util.go b/management/server/idp/util.go
index df1497114..4310d1388 100644
--- a/management/server/idp/util.go
+++ b/management/server/idp/util.go
@@ -4,7 +4,9 @@ import (
"encoding/json"
"math/rand"
"net/url"
+ "os"
"strings"
+ "time"
)
var (
@@ -69,3 +71,24 @@ func baseURL(rawURL string) string {
return parsedURL.Scheme + "://" + parsedURL.Host
}
+
+const (
+ // Provides the env variable name for use with idpTimeout function
+ idpTimeoutEnv = "NB_IDP_TIMEOUT"
+ // Sets the defaultTimeout to 10s.
+ defaultTimeout = 10 * time.Second
+)
+
+// idpTimeout returns a timeout value for the IDP
+func idpTimeout() time.Duration {
+ timeoutStr, ok := os.LookupEnv(idpTimeoutEnv)
+ if !ok || timeoutStr == "" {
+ return defaultTimeout
+ }
+
+ timeout, err := time.ParseDuration(timeoutStr)
+ if err != nil {
+ return defaultTimeout
+ }
+ return timeout
+}
diff --git a/management/server/idp/zitadel.go b/management/server/idp/zitadel.go
index 8db3c4796..ea0fd0aa7 100644
--- a/management/server/idp/zitadel.go
+++ b/management/server/idp/zitadel.go
@@ -164,9 +164,10 @@ func NewZitadelManager(config ZitadelClientConfig, appMetrics telemetry.AppMetri
httpTransport.MaxIdleConns = 5
httpClient := &http.Client{
- Timeout: 10 * time.Second,
+ Timeout: idpTimeout(),
Transport: httpTransport,
}
+
helper := JsonParser{}
hasPAT := config.PAT != ""
diff --git a/management/server/job/channel.go b/management/server/job/channel.go
new file mode 100644
index 000000000..c4dc98a68
--- /dev/null
+++ b/management/server/job/channel.go
@@ -0,0 +1,59 @@
+package job
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+)
+
+// todo consider the channel buffer size when we allow to run multiple jobs
+const jobChannelBuffer = 1
+
+var (
+ ErrJobChannelClosed = errors.New("job channel closed")
+)
+
+type Channel struct {
+ events chan *Event
+ once sync.Once
+}
+
+func NewChannel() *Channel {
+ jc := &Channel{
+ events: make(chan *Event, jobChannelBuffer),
+ }
+
+ return jc
+}
+
+func (jc *Channel) AddEvent(ctx context.Context, responseWait time.Duration, event *Event) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ // todo: timeout is handled in the wrong place. If the peer does not respond with the job response, the server does not clean it up from the pending jobs and cannot apply a new job
+ case <-time.After(responseWait):
+ return fmt.Errorf("failed to add the event to the channel")
+ case jc.events <- event:
+ }
+ return nil
+}
+
+func (jc *Channel) Close() {
+ jc.once.Do(func() {
+ close(jc.events)
+ })
+}
+
+func (jc *Channel) Event(ctx context.Context) (*Event, error) {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case job, open := <-jc.events:
+ if !open {
+ return nil, ErrJobChannelClosed
+ }
+ return job, nil
+ }
+}
diff --git a/management/server/job/manager.go b/management/server/job/manager.go
new file mode 100644
index 000000000..0b183ac39
--- /dev/null
+++ b/management/server/job/manager.go
@@ -0,0 +1,182 @@
+package job
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+type Event struct {
+ PeerID string
+ Request *proto.JobRequest
+ Response *proto.JobResponse
+}
+
+type Manager struct {
+ mu *sync.RWMutex
+ jobChannels map[string]*Channel // per-peer job streams
+ pending map[string]*Event // jobID → event
+ responseWait time.Duration
+ metrics telemetry.AppMetrics
+ Store store.Store
+ peersManager peers.Manager
+}
+
+func NewJobManager(metrics telemetry.AppMetrics, store store.Store, peersManager peers.Manager) *Manager {
+
+ return &Manager{
+ jobChannels: make(map[string]*Channel),
+ pending: make(map[string]*Event),
+ responseWait: 5 * time.Minute,
+ metrics: metrics,
+ mu: &sync.RWMutex{},
+ Store: store,
+ peersManager: peersManager,
+ }
+}
+
+// CreateJobChannel creates or replaces a channel for a peer
+func (jm *Manager) CreateJobChannel(ctx context.Context, accountID, peerID string) *Channel {
+ // all pending jobs stored in db for this peer should be failed
+ if err := jm.Store.MarkAllPendingJobsAsFailed(ctx, accountID, peerID, "Pending job cleanup: marked as failed automatically due to being stuck too long"); err != nil {
+ log.WithContext(ctx).Error(err.Error())
+ }
+
+ jm.mu.Lock()
+ defer jm.mu.Unlock()
+
+ if ch, ok := jm.jobChannels[peerID]; ok {
+ ch.Close()
+ delete(jm.jobChannels, peerID)
+ }
+
+ ch := NewChannel()
+ jm.jobChannels[peerID] = ch
+ return ch
+}
+
+// SendJob sends a job to a peer and tracks it as pending
+func (jm *Manager) SendJob(ctx context.Context, accountID, peerID string, req *proto.JobRequest) error {
+ jm.mu.RLock()
+ ch, ok := jm.jobChannels[peerID]
+ jm.mu.RUnlock()
+ if !ok {
+ return fmt.Errorf("peer %s has no channel", peerID)
+ }
+
+ event := &Event{
+ PeerID: peerID,
+ Request: req,
+ }
+
+ jm.mu.Lock()
+ jm.pending[string(req.ID)] = event
+ jm.mu.Unlock()
+
+ if err := ch.AddEvent(ctx, jm.responseWait, event); err != nil {
+ jm.cleanup(ctx, accountID, string(req.ID), err.Error())
+ return err
+ }
+
+ return nil
+}
+
+// HandleResponse marks a job as finished and moves it to completed
+func (jm *Manager) HandleResponse(ctx context.Context, resp *proto.JobResponse, peerKey string) error {
+ jm.mu.Lock()
+ defer jm.mu.Unlock()
+
+ // todo: validate job ID and would be nice to use uuid text marshal instead of string
+ jobID := string(resp.ID)
+
+ // todo: in this map has jobs for all peers in any account. Consider to validate the jobID association for the peer
+ event, ok := jm.pending[jobID]
+ if !ok {
+ return fmt.Errorf("job %s not found", jobID)
+ }
+ var job types.Job
+ // todo: ApplyResponse should be static. Any member value is unusable in this way
+ if err := job.ApplyResponse(resp); err != nil {
+ return fmt.Errorf("invalid job response: %v", err)
+ }
+
+ peerID, err := jm.peersManager.GetPeerID(ctx, peerKey)
+ if err != nil {
+ return fmt.Errorf("failed to get peer ID: %v", err)
+ }
+ if peerID != event.PeerID {
+ return fmt.Errorf("peer ID mismatch: %s != %s", peerID, event.PeerID)
+ }
+
+ // update or create the store for job response
+ err = jm.Store.CompletePeerJob(ctx, &job)
+ if err != nil {
+ return fmt.Errorf("failed to complete job %s: %v", jobID, err)
+ }
+
+ delete(jm.pending, jobID)
+ return nil
+}
+
+// CloseChannel closes a peer’s channel and cleans up its jobs
+func (jm *Manager) CloseChannel(ctx context.Context, accountID, peerID string) {
+ jm.mu.Lock()
+ defer jm.mu.Unlock()
+
+ if ch, ok := jm.jobChannels[peerID]; ok {
+ ch.Close()
+ delete(jm.jobChannels, peerID)
+ }
+
+ for jobID, ev := range jm.pending {
+ if ev.PeerID == peerID {
+ // if the client disconnect and there is pending job then mark it as failed
+ if err := jm.Store.MarkPendingJobsAsFailed(ctx, accountID, peerID, jobID, "Time out peer disconnected"); err != nil {
+ log.WithContext(ctx).Errorf("failed to mark pending jobs as failed: %v", err)
+ }
+ delete(jm.pending, jobID)
+ }
+ }
+}
+
+// cleanup removes a pending job safely
+func (jm *Manager) cleanup(ctx context.Context, accountID, jobID string, reason string) {
+ jm.mu.Lock()
+ defer jm.mu.Unlock()
+
+ if ev, ok := jm.pending[jobID]; ok {
+ if err := jm.Store.MarkPendingJobsAsFailed(ctx, accountID, ev.PeerID, jobID, reason); err != nil {
+ log.WithContext(ctx).Errorf("failed to mark pending jobs as failed: %v", err)
+ }
+ delete(jm.pending, jobID)
+ }
+}
+
+func (jm *Manager) IsPeerConnected(peerID string) bool {
+ jm.mu.RLock()
+ defer jm.mu.RUnlock()
+
+ _, ok := jm.jobChannels[peerID]
+ return ok
+}
+
+func (jm *Manager) IsPeerHasPendingJobs(peerID string) bool {
+ jm.mu.RLock()
+ defer jm.mu.RUnlock()
+
+ for _, ev := range jm.pending {
+ if ev.PeerID == peerID {
+ return true
+ }
+ }
+ return false
+}
diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go
index cc302400f..090c99877 100644
--- a/management/server/management_proto_test.go
+++ b/management/server/management_proto_test.go
@@ -31,6 +31,7 @@ import (
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/settings"
@@ -361,13 +362,15 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
AnyTimes()
permissionsManager := permissions.NewManager(store)
groupsManager := groups.NewManagerMock()
+ peersManager := peers.NewManager(store, permissionsManager)
+ jobManager := job.NewJobManager(nil, store, peersManager)
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, store)
ephemeralMgr := manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager))
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config)
- accountManager, err := BuildManager(ctx, nil, store, networkMapController, nil, "",
+ accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "",
eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
if err != nil {
@@ -381,7 +384,7 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
return nil, nil, "", cleanup, err
}
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil)
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil)
if err != nil {
return nil, nil, "", cleanup, err
}
diff --git a/management/server/management_test.go b/management/server/management_test.go
index ace372509..0864baadf 100644
--- a/management/server/management_test.go
+++ b/management/server/management_test.go
@@ -30,6 +30,7 @@ import (
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "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/management/server/store"
@@ -202,6 +203,8 @@ func startServer(
AnyTimes()
permissionsManager := permissions.NewManager(str)
+ peersManager := peers.NewManager(str, permissionsManager)
+ jobManager := job.NewJobManager(nil, str, peersManager)
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
@@ -213,6 +216,7 @@ func startServer(
nil,
str,
networkMapController,
+ jobManager,
nil,
"",
eventStore,
@@ -237,6 +241,7 @@ func startServer(
config,
accountManager,
settingsMockManager,
+ jobManager,
secretsManager,
nil,
nil,
diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go
index 422829eba..75e971498 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -74,6 +74,7 @@ type MockAccountManager struct {
SaveOrAddUsersFunc func(ctx context.Context, accountID, initiatorUserID string, update []*types.User, addIfNotExists bool) ([]*types.UserInfo, error)
DeleteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error
DeleteRegularUsersFunc func(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error
+ UpdateUserPasswordFunc func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error
CreatePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error)
DeletePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) error
GetPATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) (*types.PersonalAccessToken, error)
@@ -135,6 +136,29 @@ type MockAccountManager struct {
CreateIdentityProviderFunc func(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
UpdateIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
DeleteIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string) error
+ CreatePeerJobFunc func(ctx context.Context, accountID, peerID, userID string, job *types.Job) error
+ GetAllPeerJobsFunc func(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error)
+ GetPeerJobByIDFunc func(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error)
+}
+
+func (am *MockAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error {
+ if am.CreatePeerJobFunc != nil {
+ return am.CreatePeerJobFunc(ctx, accountID, peerID, userID, job)
+ }
+ return status.Errorf(codes.Unimplemented, "method CreatePeerJob is not implemented")
+}
+
+func (am *MockAccountManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) {
+ if am.GetAllPeerJobsFunc != nil {
+ return am.GetAllPeerJobsFunc(ctx, accountID, userID, peerID)
+ }
+ return nil, status.Errorf(codes.Unimplemented, "method GetAllPeerJobs is not implemented")
+}
+func (am *MockAccountManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) {
+ if am.GetPeerJobByIDFunc != nil {
+ return am.GetPeerJobByIDFunc(ctx, accountID, userID, peerID, jobID)
+ }
+ return nil, status.Errorf(codes.Unimplemented, "method GetPeerJobByID is not implemented")
}
func (am *MockAccountManager) CreateGroup(ctx context.Context, accountID, userID string, group *types.Group) error {
@@ -612,6 +636,14 @@ func (am *MockAccountManager) DeleteRegularUsers(ctx context.Context, accountID,
return status.Errorf(codes.Unimplemented, "method DeleteRegularUsers is not implemented")
}
+// UpdateUserPassword mocks UpdateUserPassword of the AccountManager interface
+func (am *MockAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
+ if am.UpdateUserPasswordFunc != nil {
+ return am.UpdateUserPasswordFunc(ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword)
+ }
+ return status.Errorf(codes.Unimplemented, "method UpdateUserPassword is not implemented")
+}
+
func (am *MockAccountManager) InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error {
if am.InviteUserFunc != nil {
return am.InviteUserFunc(ctx, accountID, initiatorUserID, targetUserID)
diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go
index 955c6b0ef..0d781e0d4 100644
--- a/management/server/nameserver_test.go
+++ b/management/server/nameserver_test.go
@@ -18,6 +18,7 @@ import (
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/settings"
@@ -790,13 +791,14 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) {
AnyTimes()
permissionsManager := permissions.NewManager(store)
+ peersManager := peers.NewManager(store, permissionsManager)
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, store)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
- return BuildManager(context.Background(), nil, store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
}
func createNSStore(t *testing.T) (store.Store, error) {
diff --git a/management/server/peer.go b/management/server/peer.go
index 977bd52af..d6eb2aecd 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -31,6 +31,8 @@ import (
"github.com/netbirdio/netbird/shared/management/status"
)
+const remoteJobsMinVer = "0.64.0"
+
// GetPeers returns a list of peers under the given account filtering out peers that do not belong to a user if
// the current user is not an admin.
func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) {
@@ -324,6 +326,134 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
return peer, nil
}
+func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error {
+ allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Create)
+ if err != nil {
+ return status.NewPermissionValidationError(err)
+ }
+ if !allowed {
+ return status.NewPermissionDeniedError()
+ }
+
+ p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
+ if err != nil {
+ return err
+ }
+
+ if p.AccountID != accountID {
+ return status.NewPeerNotPartOfAccountError()
+ }
+
+ meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
+ if !strings.Contains(p.Meta.WtVersion, "dev") && (!meetMinVer || err != nil) {
+ return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer)
+ }
+
+ if !am.jobManager.IsPeerConnected(peerID) {
+ return status.Errorf(status.BadRequest, "peer not connected")
+ }
+
+ // check if already has pending jobs
+ // todo: The job checks here are not protected. The user can run this function from multiple threads,
+ // and each thread can think there is no job yet. This means entries in the pending job map will be overwritten,
+ // and only one will be kept, but potentially another one will overwrite it in the queue.
+ if am.jobManager.IsPeerHasPendingJobs(peerID) {
+ return status.Errorf(status.BadRequest, "peer already has pending job")
+ }
+
+ jobStream, err := job.ToStreamJobRequest()
+ if err != nil {
+ return status.Errorf(status.BadRequest, "invalid job request %v", err)
+ }
+
+ // try sending job first
+ if err := am.jobManager.SendJob(ctx, accountID, peerID, jobStream); err != nil {
+ return status.Errorf(status.Internal, "failed to send job: %v", err)
+ }
+
+ var peer *nbpeer.Peer
+ var eventsToStore func()
+
+ // persist job in DB only if send succeeded
+ err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ peer, err = transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID)
+ if err != nil {
+ return err
+ }
+ if err := transaction.CreatePeerJob(ctx, job); err != nil {
+ return err
+ }
+
+ jobMeta := map[string]any{
+ "for_peer_name": peer.Name,
+ "job_type": job.Workload.Type,
+ }
+
+ eventsToStore = func() {
+ am.StoreEvent(ctx, userID, peer.ID, accountID, activity.JobCreatedByUser, jobMeta)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ eventsToStore()
+ return nil
+}
+
+func (am *DefaultAccountManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) {
+ // todo: Create permissions for job
+ allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !allowed {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ peerAccountID, err := am.Store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID)
+ if err != nil {
+ return nil, err
+ }
+
+ if peerAccountID != accountID {
+ return nil, status.NewPeerNotPartOfAccountError()
+ }
+
+ accountJobs, err := am.Store.GetPeerJobs(ctx, accountID, peerID)
+ if err != nil {
+ return nil, err
+ }
+
+ return accountJobs, nil
+}
+
+func (am *DefaultAccountManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) {
+ allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
+ if err != nil {
+ return nil, status.NewPermissionValidationError(err)
+ }
+ if !allowed {
+ return nil, status.NewPermissionDeniedError()
+ }
+
+ peerAccountID, err := am.Store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID)
+ if err != nil {
+ return nil, err
+ }
+
+ if peerAccountID != accountID {
+ return nil, status.NewPeerNotPartOfAccountError()
+ }
+
+ job, err := am.Store.GetPeerJobByID(ctx, accountID, jobID)
+ if err != nil {
+ return nil, err
+ }
+
+ return job, nil
+}
+
// DeletePeer removes peer from the account by its IP
func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peerID, userID string) error {
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Delete)
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index 0160ff586..3846a3e85 100644
--- a/management/server/peer_test.go
+++ b/management/server/peer_test.go
@@ -34,6 +34,7 @@ import (
"github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "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/management/status"
@@ -1289,13 +1290,14 @@ func Test_RegisterPeerByUser(t *testing.T) {
t.Cleanup(ctrl.Finish)
settingsMockManager := settings.NewMockManager(ctrl)
permissionsManager := permissions.NewManager(s)
+ peersManager := peers.NewManager(s, permissionsManager)
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, s)
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
- am, err := BuildManager(context.Background(), nil, s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
assert.NoError(t, err)
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
@@ -1374,13 +1376,14 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
Return(&types.ExtraSettings{}, nil).
AnyTimes()
permissionsManager := permissions.NewManager(s)
+ peersManager := peers.NewManager(s, permissionsManager)
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, s)
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
- am, err := BuildManager(context.Background(), nil, s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
assert.NoError(t, err)
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
@@ -1527,13 +1530,14 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
settingsMockManager := settings.NewMockManager(ctrl)
permissionsManager := permissions.NewManager(s)
+ peersManager := peers.NewManager(s, permissionsManager)
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, s)
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
- am, err := BuildManager(context.Background(), nil, s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
assert.NoError(t, err)
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
@@ -1607,13 +1611,14 @@ func Test_LoginPeer(t *testing.T) {
Return(&types.ExtraSettings{}, nil).
AnyTimes()
permissionsManager := permissions.NewManager(s)
+ peersManager := peers.NewManager(s, permissionsManager)
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, s)
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
- am, err := BuildManager(context.Background(), nil, s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
assert.NoError(t, err)
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
diff --git a/management/server/permissions/modules/module.go b/management/server/permissions/modules/module.go
index 0ae10d521..f19675d27 100644
--- a/management/server/permissions/modules/module.go
+++ b/management/server/permissions/modules/module.go
@@ -3,35 +3,37 @@ package modules
type Module string
const (
- Networks Module = "networks"
- Peers Module = "peers"
- Groups Module = "groups"
- Settings Module = "settings"
- Accounts Module = "accounts"
- Dns Module = "dns"
- Nameservers Module = "nameservers"
- Events Module = "events"
- Policies Module = "policies"
- Routes Module = "routes"
- Users Module = "users"
- SetupKeys Module = "setup_keys"
- Pats Module = "pats"
+ Networks Module = "networks"
+ Peers Module = "peers"
+ RemoteJobs Module = "remote_jobs"
+ Groups Module = "groups"
+ Settings Module = "settings"
+ Accounts Module = "accounts"
+ Dns Module = "dns"
+ Nameservers Module = "nameservers"
+ Events Module = "events"
+ Policies Module = "policies"
+ Routes Module = "routes"
+ Users Module = "users"
+ SetupKeys Module = "setup_keys"
+ Pats Module = "pats"
IdentityProviders Module = "identity_providers"
)
var All = map[Module]struct{}{
- Networks: {},
- Peers: {},
- Groups: {},
- Settings: {},
- Accounts: {},
- Dns: {},
- Nameservers: {},
- Events: {},
- Policies: {},
- Routes: {},
- Users: {},
- SetupKeys: {},
- Pats: {},
+ Networks: {},
+ Peers: {},
+ RemoteJobs: {},
+ Groups: {},
+ Settings: {},
+ Accounts: {},
+ Dns: {},
+ Nameservers: {},
+ Events: {},
+ Policies: {},
+ Routes: {},
+ Users: {},
+ SetupKeys: {},
+ Pats: {},
IdentityProviders: {},
}
diff --git a/management/server/route_test.go b/management/server/route_test.go
index 6dc8c4cf4..d4882eff8 100644
--- a/management/server/route_test.go
+++ b/management/server/route_test.go
@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
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"
@@ -1289,13 +1290,14 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel.
Return(&types.ExtraSettings{}, nil)
permissionsManager := permissions.NewManager(store)
+ peersManager := peers.NewManager(store, permissionsManager)
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, store)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
- am, err := BuildManager(context.Background(), nil, store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
+ am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
if err != nil {
return nil, nil, err
}
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index f407a35e6..0eb687dbb 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -27,6 +27,8 @@ import (
"gorm.io/gorm/logger"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
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"
@@ -41,14 +43,15 @@ import (
)
const (
- storeSqliteFileName = "store.db"
- idQueryCondition = "id = ?"
- keyQueryCondition = "key = ?"
- mysqlKeyQueryCondition = "`key` = ?"
- accountAndIDQueryCondition = "account_id = ? and id = ?"
- accountAndIDsQueryCondition = "account_id = ? AND id IN ?"
- accountIDCondition = "account_id = ?"
- peerNotFoundFMT = "peer %s not found"
+ storeSqliteFileName = "store.db"
+ idQueryCondition = "id = ?"
+ keyQueryCondition = "key = ?"
+ mysqlKeyQueryCondition = "`key` = ?"
+ accountAndIDQueryCondition = "account_id = ? and id = ?"
+ accountAndPeerIDQueryCondition = "account_id = ? and peer_id = ?"
+ accountAndIDsQueryCondition = "account_id = ? AND id IN ?"
+ accountIDCondition = "account_id = ?"
+ peerNotFoundFMT = "peer %s not found"
pgMaxConnections = 30
pgMinConnections = 1
@@ -123,6 +126,7 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met
&types.Account{}, &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{},
&installation{}, &types.ExtraSettings{}, &posture.Checks{}, &nbpeer.NetworkAddress{},
&networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, &types.AccountOnboarding{},
+ &types.Job{}, &zones.Zone{}, &records.Record{},
)
if err != nil {
return nil, fmt.Errorf("auto migratePreAuto: %w", err)
@@ -141,6 +145,97 @@ func GetKeyQueryCondition(s *SqlStore) string {
return keyQueryCondition
}
+// SaveJob persists a job in DB
+func (s *SqlStore) CreatePeerJob(ctx context.Context, job *types.Job) error {
+ result := s.db.Create(job)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to create job in store: %s", result.Error)
+ return status.Errorf(status.Internal, "failed to create job in store")
+ }
+ return nil
+}
+
+func (s *SqlStore) CompletePeerJob(ctx context.Context, job *types.Job) error {
+ result := s.db.
+ Model(&types.Job{}).
+ Where(idQueryCondition, job.ID).
+ Updates(job)
+
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to update job in store: %s", result.Error)
+ return status.Errorf(status.Internal, "failed to update job in store")
+ }
+ return nil
+}
+
+// job was pending for too long and has been cancelled
+func (s *SqlStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error {
+ now := time.Now().UTC()
+ result := s.db.
+ Model(&types.Job{}).
+ Where(accountAndPeerIDQueryCondition+" AND id = ?"+" AND status = ?", accountID, peerID, jobID, types.JobStatusPending).
+ Updates(types.Job{
+ Status: types.JobStatusFailed,
+ FailedReason: reason,
+ CompletedAt: &now,
+ })
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error)
+ return status.Errorf(status.Internal, "failed to mark pending job as Failed in store")
+ }
+ return nil
+}
+
+// job was pending for too long and has been cancelled
+func (s *SqlStore) MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error {
+ now := time.Now().UTC()
+ result := s.db.
+ Model(&types.Job{}).
+ Where(accountAndPeerIDQueryCondition+" AND status = ?", accountID, peerID, types.JobStatusPending).
+ Updates(types.Job{
+ Status: types.JobStatusFailed,
+ FailedReason: reason,
+ CompletedAt: &now,
+ })
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error)
+ return status.Errorf(status.Internal, "failed to mark pending job as Failed in store")
+ }
+ return nil
+}
+
+// GetJobByID fetches job by ID
+func (s *SqlStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types.Job, error) {
+ var job types.Job
+ err := s.db.
+ Where(accountAndIDQueryCondition, accountID, jobID).
+ First(&job).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, status.Errorf(status.NotFound, "job %s not found", jobID)
+ }
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to fetch job from store: %s", err)
+ return nil, err
+ }
+ return &job, nil
+}
+
+// get all jobs
+func (s *SqlStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types.Job, error) {
+ var jobs []*types.Job
+ err := s.db.
+ Where(accountAndPeerIDQueryCondition, accountID, peerID).
+ Order("created_at DESC").
+ Find(&jobs).Error
+
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to fetch jobs from store: %s", err)
+ return nil, err
+ }
+
+ return jobs, nil
+}
+
// AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock
func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) {
log.WithContext(ctx).Tracef("acquiring global lock")
@@ -4179,3 +4274,204 @@ func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingS
return userID, nil
}
+
+func (s *SqlStore) CreateZone(ctx context.Context, zone *zones.Zone) error {
+ result := s.db.Create(zone)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to create zone to store: %v", result.Error)
+ return status.Errorf(status.Internal, "failed to create zone to store")
+ }
+
+ return nil
+}
+
+func (s *SqlStore) UpdateZone(ctx context.Context, zone *zones.Zone) error {
+ result := s.db.Select("*").Save(zone)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to update zone to store: %v", result.Error)
+ return status.Errorf(status.Internal, "failed to update zone to store")
+ }
+
+ return nil
+}
+
+func (s *SqlStore) DeleteZone(ctx context.Context, accountID, zoneID string) error {
+ result := s.db.Delete(&zones.Zone{}, accountAndIDQueryCondition, accountID, zoneID)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to delete zone from store: %v", result.Error)
+ return status.Errorf(status.Internal, "failed to delete zone from store")
+ }
+
+ if result.RowsAffected == 0 {
+ return status.NewZoneNotFoundError(zoneID)
+ }
+
+ return nil
+}
+
+func (s *SqlStore) GetZoneByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) (*zones.Zone, error) {
+ tx := s.db
+ if lockStrength != LockingStrengthNone {
+ tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
+ }
+
+ var zone *zones.Zone
+ result := tx.Preload("Records").Take(&zone, accountAndIDQueryCondition, accountID, zoneID)
+ if result.Error != nil {
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ return nil, status.NewZoneNotFoundError(zoneID)
+ }
+
+ log.WithContext(ctx).Errorf("failed to get zone from store: %v", result.Error)
+ return nil, status.Errorf(status.Internal, "failed to get zone from store")
+ }
+
+ return zone, nil
+}
+
+func (s *SqlStore) GetZoneByDomain(ctx context.Context, accountID, domain string) (*zones.Zone, error) {
+ var zone *zones.Zone
+ result := s.db.Where("account_id = ? AND domain = ?", accountID, domain).First(&zone)
+ if result.Error != nil {
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ return nil, status.NewZoneNotFoundError(domain)
+ }
+
+ log.WithContext(ctx).Errorf("failed to get zone by domain from store: %v", result.Error)
+ return nil, status.Errorf(status.Internal, "failed to get zone by domain from store")
+ }
+
+ return zone, nil
+}
+
+func (s *SqlStore) GetAccountZones(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*zones.Zone, error) {
+ tx := s.db
+ if lockStrength != LockingStrengthNone {
+ tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
+ }
+
+ var zones []*zones.Zone
+ result := tx.Preload("Records").Find(&zones, accountIDCondition, accountID)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to get zones from the store: %s", result.Error)
+ return nil, status.Errorf(status.Internal, "failed to get zones from store")
+ }
+
+ return zones, nil
+}
+
+func (s *SqlStore) CreateDNSRecord(ctx context.Context, record *records.Record) error {
+ result := s.db.Create(record)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to create dns record to store: %v", result.Error)
+ return status.Errorf(status.Internal, "failed to create dns record to store")
+ }
+
+ return nil
+}
+
+func (s *SqlStore) UpdateDNSRecord(ctx context.Context, record *records.Record) error {
+ result := s.db.Select("*").Save(record)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to update dns record to store: %v", result.Error)
+ return status.Errorf(status.Internal, "failed to update dns record to store")
+ }
+
+ return nil
+}
+
+func (s *SqlStore) DeleteDNSRecord(ctx context.Context, accountID, zoneID, recordID string) error {
+ result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to delete dns record from store: %v", result.Error)
+ return status.Errorf(status.Internal, "failed to delete dns record from store")
+ }
+
+ if result.RowsAffected == 0 {
+ return status.NewDNSRecordNotFoundError(recordID)
+ }
+
+ return nil
+}
+
+func (s *SqlStore) GetDNSRecordByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, recordID string) (*records.Record, error) {
+ tx := s.db
+ if lockStrength != LockingStrengthNone {
+ tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
+ }
+
+ var record *records.Record
+ result := tx.Where("account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID).Take(&record)
+ if result.Error != nil {
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ return nil, status.NewDNSRecordNotFoundError(recordID)
+ }
+
+ log.WithContext(ctx).Errorf("failed to get dns record from store: %v", result.Error)
+ return nil, status.Errorf(status.Internal, "failed to get dns record from store")
+ }
+
+ return record, nil
+}
+
+func (s *SqlStore) GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error) {
+ tx := s.db
+ if lockStrength != LockingStrengthNone {
+ tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
+ }
+
+ var recordsList []*records.Record
+ result := tx.Where("account_id = ? AND zone_id = ?", accountID, zoneID).Find(&recordsList)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to get zone dns records from the store: %s", result.Error)
+ return nil, status.Errorf(status.Internal, "failed to get zone dns records from store")
+ }
+
+ return recordsList, nil
+}
+
+func (s *SqlStore) GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error) {
+ tx := s.db
+ if lockStrength != LockingStrengthNone {
+ tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
+ }
+
+ var recordsList []*records.Record
+ result := tx.Where("account_id = ? AND zone_id = ? AND name = ?", accountID, zoneID, name).Find(&recordsList)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to get zone dns records by name from the store: %s", result.Error)
+ return nil, status.Errorf(status.Internal, "failed to get zone dns records by name from store")
+ }
+
+ return recordsList, nil
+}
+
+func (s *SqlStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error {
+ result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ?", accountID, zoneID)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to delete zone dns records from store: %v", result.Error)
+ return status.Errorf(status.Internal, "failed to delete zone dns records from store")
+ }
+
+ return nil
+}
+
+func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error) {
+ tx := s.db
+ if lockStrength != LockingStrengthNone {
+ tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
+ }
+
+ var peerID string
+ result := tx.Model(&nbpeer.Peer{}).
+ Select("id").
+ Where(GetKeyQueryCondition(s), key).
+ Limit(1).
+ Scan(&peerID)
+ if result.Error != nil {
+ log.WithContext(ctx).Errorf("failed to get peer ID by key: %s", result.Error)
+ return "", status.Errorf(status.Internal, "failed to get peer ID by key")
+ }
+
+ return peerID, nil
+}
diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go
index 952432252..7cf42c4e8 100644
--- a/management/server/store/sql_store_test.go
+++ b/management/server/store/sql_store_test.go
@@ -22,6 +22,8 @@ import (
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
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"
@@ -4025,3 +4027,476 @@ func TestSqlStore_ExecuteInTransaction_Timeout(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "transaction has already been committed or rolled back", "expected transaction rolled back error, got: %v", err)
}
+
+func TestSqlStore_CreateZone(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ savedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID)
+ require.NoError(t, err)
+ require.NotNil(t, savedZone)
+ assert.Equal(t, zone.ID, savedZone.ID)
+ assert.Equal(t, zone.Name, savedZone.Name)
+ assert.Equal(t, zone.Domain, savedZone.Domain)
+ assert.Equal(t, zone.Enabled, savedZone.Enabled)
+ assert.Equal(t, zone.EnableSearchDomain, savedZone.EnableSearchDomain)
+ assert.Equal(t, zone.DistributionGroups, savedZone.DistributionGroups)
+}
+
+func TestSqlStore_GetZoneByID(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ accountID string
+ zoneID string
+ expectError bool
+ }{
+ {
+ name: "retrieve existing zone",
+ accountID: accountID,
+ zoneID: zone.ID,
+ expectError: false,
+ },
+ {
+ name: "retrieve non-existing zone",
+ accountID: accountID,
+ zoneID: "non-existing",
+ expectError: true,
+ },
+ {
+ name: "retrieve with empty zone ID",
+ accountID: accountID,
+ zoneID: "",
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ savedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, tt.accountID, tt.zoneID)
+ if tt.expectError {
+ require.Error(t, err)
+ sErr, ok := status.FromError(err)
+ require.True(t, ok)
+ require.Equal(t, sErr.Type(), status.NotFound)
+ require.Nil(t, savedZone)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, savedZone)
+ assert.Equal(t, tt.zoneID, savedZone.ID)
+ }
+ })
+ }
+}
+
+func TestSqlStore_GetAccountZones(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone1 := zones.NewZone(accountID, "Zone 1", "example1.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone1)
+ require.NoError(t, err)
+
+ zone2 := zones.NewZone(accountID, "Zone 2", "example2.com", true, true, []string{"group1", "group2"})
+ err = store.CreateZone(context.Background(), zone2)
+ require.NoError(t, err)
+
+ allZones, err := store.GetAccountZones(context.Background(), LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ require.NotNil(t, allZones)
+ assert.GreaterOrEqual(t, len(allZones), 2)
+
+ zoneIDs := make(map[string]bool)
+ for _, z := range allZones {
+ zoneIDs[z.ID] = true
+ }
+ assert.True(t, zoneIDs[zone1.ID])
+ assert.True(t, zoneIDs[zone2.ID])
+}
+
+func TestSqlStore_GetZoneByDomain(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+ otherAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3c"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ accountID string
+ domain string
+ expectError bool
+ errorType status.Type
+ }{
+ {
+ name: "retrieve existing zone by domain",
+ accountID: accountID,
+ domain: "example.com",
+ expectError: false,
+ },
+ {
+ name: "retrieve non-existing zone domain",
+ accountID: accountID,
+ domain: "non-existing.com",
+ expectError: true,
+ errorType: status.NotFound,
+ },
+ {
+ name: "retrieve with empty domain",
+ accountID: accountID,
+ domain: "",
+ expectError: true,
+ errorType: status.NotFound,
+ },
+ {
+ name: "retrieve with different account ID",
+ accountID: otherAccountID,
+ domain: "example.com",
+ expectError: true,
+ errorType: status.NotFound,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ savedZone, err := store.GetZoneByDomain(context.Background(), tt.accountID, tt.domain)
+ if tt.expectError {
+ require.Error(t, err)
+ sErr, ok := status.FromError(err)
+ require.True(t, ok)
+ require.Equal(t, tt.errorType, sErr.Type())
+ require.Nil(t, savedZone)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, savedZone)
+ assert.Equal(t, tt.domain, savedZone.Domain)
+ assert.Equal(t, zone.ID, savedZone.ID)
+ assert.Equal(t, zone.Name, savedZone.Name)
+ }
+ })
+ }
+}
+
+func TestSqlStore_UpdateZone(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ zone.Name = "Updated Zone"
+ zone.Domain = "updated.com"
+ zone.Enabled = false
+ zone.EnableSearchDomain = true
+ zone.DistributionGroups = []string{"group2", "group3"}
+
+ err = store.UpdateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ updatedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID)
+ require.NoError(t, err)
+ require.NotNil(t, updatedZone)
+ assert.Equal(t, "Updated Zone", updatedZone.Name)
+ assert.Equal(t, "updated.com", updatedZone.Domain)
+ assert.False(t, updatedZone.Enabled)
+ assert.True(t, updatedZone.EnableSearchDomain)
+ assert.Equal(t, []string{"group2", "group3"}, updatedZone.DistributionGroups)
+}
+
+func TestSqlStore_DeleteZone(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ err = store.DeleteZone(context.Background(), accountID, zone.ID)
+ require.NoError(t, err)
+
+ deletedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID)
+ require.Error(t, err)
+ require.Nil(t, deletedZone)
+ sErr, ok := status.FromError(err)
+ require.True(t, ok)
+ require.Equal(t, sErr.Type(), status.NotFound)
+}
+
+func TestSqlStore_CreateDNSRecord(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
+
+ err = store.CreateDNSRecord(context.Background(), record)
+ require.NoError(t, err)
+
+ savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID)
+ require.NoError(t, err)
+ require.NotNil(t, savedRecord)
+ assert.Equal(t, record.ID, savedRecord.ID)
+ assert.Equal(t, record.Name, savedRecord.Name)
+ assert.Equal(t, record.Type, savedRecord.Type)
+ assert.Equal(t, record.Content, savedRecord.Content)
+ assert.Equal(t, record.TTL, savedRecord.TTL)
+ assert.Equal(t, zone.ID, savedRecord.ZoneID)
+}
+
+func TestSqlStore_GetDNSRecordByID(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err = store.CreateDNSRecord(context.Background(), record)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ accountID string
+ zoneID string
+ recordID string
+ expectError bool
+ }{
+ {
+ name: "retrieve existing record",
+ accountID: accountID,
+ zoneID: zone.ID,
+ recordID: record.ID,
+ expectError: false,
+ },
+ {
+ name: "retrieve non-existing record",
+ accountID: accountID,
+ zoneID: zone.ID,
+ recordID: "non-existing",
+ expectError: true,
+ },
+ {
+ name: "retrieve with empty record ID",
+ accountID: accountID,
+ zoneID: zone.ID,
+ recordID: "",
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, tt.accountID, tt.zoneID, tt.recordID)
+ if tt.expectError {
+ require.Error(t, err)
+ sErr, ok := status.FromError(err)
+ require.True(t, ok)
+ require.Equal(t, sErr.Type(), status.NotFound)
+ require.Nil(t, savedRecord)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, savedRecord)
+ assert.Equal(t, tt.recordID, savedRecord.ID)
+ }
+ })
+ }
+}
+
+func TestSqlStore_GetZoneDNSRecords(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ recordA := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err = store.CreateDNSRecord(context.Background(), recordA)
+ require.NoError(t, err)
+
+ recordAAAA := records.NewRecord(accountID, zone.ID, "ipv6.example.com", records.RecordTypeAAAA, "2001:db8::1", 300)
+ err = store.CreateDNSRecord(context.Background(), recordAAAA)
+ require.NoError(t, err)
+
+ recordCNAME := records.NewRecord(accountID, zone.ID, "alias.example.com", records.RecordTypeCNAME, "www.example.com", 300)
+ err = store.CreateDNSRecord(context.Background(), recordCNAME)
+ require.NoError(t, err)
+
+ allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID)
+ require.NoError(t, err)
+ require.NotNil(t, allRecords)
+ assert.Equal(t, 3, len(allRecords))
+
+ recordIDs := make(map[string]bool)
+ for _, r := range allRecords {
+ recordIDs[r.ID] = true
+ }
+ assert.True(t, recordIDs[recordA.ID])
+ assert.True(t, recordIDs[recordAAAA.ID])
+ assert.True(t, recordIDs[recordCNAME.ID])
+}
+
+func TestSqlStore_GetZoneDNSRecordsByName(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err = store.CreateDNSRecord(context.Background(), record1)
+ require.NoError(t, err)
+
+ record2 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeAAAA, "2001:db8::1", 300)
+ err = store.CreateDNSRecord(context.Background(), record2)
+ require.NoError(t, err)
+
+ record3 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600)
+ err = store.CreateDNSRecord(context.Background(), record3)
+ require.NoError(t, err)
+
+ recordsByName, err := store.GetZoneDNSRecordsByName(context.Background(), LockingStrengthNone, accountID, zone.ID, "www.example.com")
+ require.NoError(t, err)
+ require.NotNil(t, recordsByName)
+ assert.Equal(t, 2, len(recordsByName))
+
+ for _, r := range recordsByName {
+ assert.Equal(t, "www.example.com", r.Name)
+ }
+}
+
+func TestSqlStore_UpdateDNSRecord(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err = store.CreateDNSRecord(context.Background(), record)
+ require.NoError(t, err)
+
+ record.Name = "api.example.com"
+ record.Content = "192.168.1.100"
+ record.TTL = 600
+
+ err = store.UpdateDNSRecord(context.Background(), record)
+ require.NoError(t, err)
+
+ updatedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID)
+ require.NoError(t, err)
+ require.NotNil(t, updatedRecord)
+ assert.Equal(t, "api.example.com", updatedRecord.Name)
+ assert.Equal(t, "192.168.1.100", updatedRecord.Content)
+ assert.Equal(t, 600, updatedRecord.TTL)
+}
+
+func TestSqlStore_DeleteDNSRecord(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err = store.CreateDNSRecord(context.Background(), record)
+ require.NoError(t, err)
+
+ err = store.DeleteDNSRecord(context.Background(), accountID, zone.ID, record.ID)
+ require.NoError(t, err)
+
+ deletedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID)
+ require.Error(t, err)
+ require.Nil(t, deletedRecord)
+ sErr, ok := status.FromError(err)
+ require.True(t, ok)
+ require.Equal(t, sErr.Type(), status.NotFound)
+}
+
+func TestSqlStore_DeleteZoneDNSRecords(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
+ err = store.CreateZone(context.Background(), zone)
+ require.NoError(t, err)
+
+ record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
+ err = store.CreateDNSRecord(context.Background(), record1)
+ require.NoError(t, err)
+
+ record2 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600)
+ err = store.CreateDNSRecord(context.Background(), record2)
+ require.NoError(t, err)
+
+ allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID)
+ require.NoError(t, err)
+ assert.Equal(t, 2, len(allRecords))
+
+ err = store.DeleteZoneDNSRecords(context.Background(), accountID, zone.ID)
+ require.NoError(t, err)
+
+ remainingRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID)
+ require.NoError(t, err)
+ assert.Equal(t, 0, len(remainingRecords))
+}
diff --git a/management/server/store/store.go b/management/server/store/store.go
index 55d11c36a..02c746592 100644
--- a/management/server/store/store.go
+++ b/management/server/store/store.go
@@ -23,6 +23,8 @@ import (
"gorm.io/gorm"
"github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/management/server/testutil"
"github.com/netbirdio/netbird/management/server/types"
@@ -209,6 +211,28 @@ type Store interface {
// SetFieldEncrypt sets the field encryptor for encrypting sensitive user data.
SetFieldEncrypt(enc *crypt.FieldEncrypt)
GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error)
+
+ CreateZone(ctx context.Context, zone *zones.Zone) error
+ UpdateZone(ctx context.Context, zone *zones.Zone) error
+ DeleteZone(ctx context.Context, accountID, zoneID string) error
+ GetZoneByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) (*zones.Zone, error)
+ GetZoneByDomain(ctx context.Context, accountID, domain string) (*zones.Zone, error)
+ GetAccountZones(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*zones.Zone, error)
+
+ CreateDNSRecord(ctx context.Context, record *records.Record) error
+ UpdateDNSRecord(ctx context.Context, record *records.Record) error
+ DeleteDNSRecord(ctx context.Context, accountID, zoneID, recordID string) error
+ GetDNSRecordByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, recordID string) (*records.Record, error)
+ GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error)
+ GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error)
+ DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error
+ CreatePeerJob(ctx context.Context, job *types.Job) error
+ CompletePeerJob(ctx context.Context, job *types.Job) error
+ GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types.Job, error)
+ GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types.Job, error)
+ MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error
+ MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error
+ GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error)
}
const (
diff --git a/management/server/types/account.go b/management/server/types/account.go
index 06170a132..a2b5140d4 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -18,6 +18,8 @@ import (
"github.com/netbirdio/netbird/client/ssh/auth"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
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"
@@ -150,17 +152,16 @@ func (o AccountOnboarding) IsEqual(onboarding AccountOnboarding) bool {
// GetRoutesToSync returns the enabled routes for the peer ID and the routes
// from the ACL peers that have distribution groups associated with the peer ID.
// Please mind, that the returned route.Route objects will contain Peer.Key instead of Peer.ID.
-func (a *Account) GetRoutesToSync(ctx context.Context, peerID string, aclPeers []*nbpeer.Peer) []*route.Route {
+func (a *Account) GetRoutesToSync(ctx context.Context, peerID string, aclPeers []*nbpeer.Peer, peerGroups LookupMap) []*route.Route {
routes, peerDisabledRoutes := a.getRoutingPeerRoutes(ctx, peerID)
peerRoutesMembership := make(LookupMap)
for _, r := range append(routes, peerDisabledRoutes...) {
peerRoutesMembership[string(r.GetHAUniqueID())] = struct{}{}
}
- groupListMap := a.GetPeerGroups(peerID)
for _, peer := range aclPeers {
activeRoutes, _ := a.getRoutingPeerRoutes(ctx, peer.ID)
- groupFilteredRoutes := a.filterRoutesByGroups(activeRoutes, groupListMap)
+ groupFilteredRoutes := a.filterRoutesByGroups(activeRoutes, peerGroups)
filteredRoutes := a.filterRoutesFromPeersOfSameHAGroup(groupFilteredRoutes, peerRoutesMembership)
routes = append(routes, filteredRoutes...)
}
@@ -274,6 +275,7 @@ func (a *Account) GetPeerNetworkMap(
ctx context.Context,
peerID string,
peersCustomZone nbdns.CustomZone,
+ accountZones []*zones.Zone,
validatedPeersMap map[string]struct{},
resourcePolicies map[string][]*Policy,
routers map[string]map[string]*routerTypes.NetworkRouter,
@@ -294,6 +296,8 @@ func (a *Account) GetPeerNetworkMap(
}
}
+ peerGroups := a.GetPeerGroups(peerID)
+
aclPeers, firewallRules, authorizedUsers, enableSSH := a.GetPeerConnectionResources(ctx, peer, validatedPeersMap, groupIDToUserIDs)
// exclude expired peers
var peersToConnect []*nbpeer.Peer
@@ -307,7 +311,7 @@ func (a *Account) GetPeerNetworkMap(
peersToConnect = append(peersToConnect, p)
}
- routesUpdate := a.GetRoutesToSync(ctx, peerID, peersToConnect)
+ routesUpdate := a.GetRoutesToSync(ctx, peerID, peersToConnect, peerGroups)
routesFirewallRules := a.GetPeerRoutesFirewallRules(ctx, peerID, validatedPeersMap)
isRouter, networkResourcesRoutes, sourcePeers := a.GetNetworkResourcesRoutesToSync(ctx, peerID, resourcePolicies, routers)
var networkResourcesFirewallRules []*RouteFirewallRule
@@ -323,6 +327,7 @@ func (a *Account) GetPeerNetworkMap(
if dnsManagementStatus {
var zones []nbdns.CustomZone
+
if peersCustomZone.Domain != "" {
records := filterZoneRecordsForPeers(peer, peersCustomZone, peersToConnectIncludingRouters, expiredPeers)
zones = append(zones, nbdns.CustomZone{
@@ -330,6 +335,10 @@ func (a *Account) GetPeerNetworkMap(
Records: records,
})
}
+
+ filteredAccountZones := filterPeerAppliedZones(ctx, accountZones, peerGroups)
+ zones = append(zones, filteredAccountZones...)
+
dnsUpdate.CustomZones = zones
dnsUpdate.NameServerGroups = getPeerNSGroups(a, peerID)
}
@@ -1881,3 +1890,66 @@ func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, p
return filteredRecords
}
+
+// filterPeerAppliedZones filters account zones based on the peer's group membership
+func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone {
+ var customZones []nbdns.CustomZone
+
+ if len(peerGroups) == 0 {
+ return customZones
+ }
+
+ for _, zone := range accountZones {
+ if !zone.Enabled || len(zone.Records) == 0 {
+ continue
+ }
+
+ hasAccess := false
+ for _, distGroupID := range zone.DistributionGroups {
+ if _, found := peerGroups[distGroupID]; found {
+ hasAccess = true
+ break
+ }
+ }
+
+ if !hasAccess {
+ continue
+ }
+
+ simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records))
+ for _, record := range zone.Records {
+ var recordType int
+ rData := record.Content
+
+ switch record.Type {
+ case records.RecordTypeA:
+ recordType = int(dns.TypeA)
+ case records.RecordTypeAAAA:
+ recordType = int(dns.TypeAAAA)
+ case records.RecordTypeCNAME:
+ recordType = int(dns.TypeCNAME)
+ rData = dns.Fqdn(record.Content)
+ default:
+ log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID)
+ continue
+ }
+
+ simpleRecords = append(simpleRecords, nbdns.SimpleRecord{
+ Name: dns.Fqdn(record.Name),
+ Type: recordType,
+ Class: nbdns.DefaultClass,
+ TTL: record.TTL,
+ RData: rData,
+ })
+ }
+
+ customZones = append(customZones, nbdns.CustomZone{
+ Domain: dns.Fqdn(zone.Domain),
+ Records: simpleRecords,
+ SearchDomainDisabled: !zone.EnableSearchDomain,
+ NonAuthoritative: true,
+ })
+ }
+
+ return customZones
+}
diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go
index 2c9f2428d..af2896216 100644
--- a/management/server/types/account_test.go
+++ b/management/server/types/account_test.go
@@ -13,6 +13,8 @@ import (
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
+ "github.com/netbirdio/netbird/management/internals/modules/zones/records"
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"
@@ -1425,3 +1427,515 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
})
}
}
+
+func Test_filterPeerAppliedZones(t *testing.T) {
+ ctx := context.Background()
+
+ tests := []struct {
+ name string
+ accountZones []*zones.Zone
+ peerGroups LookupMap
+ expected []nbdns.CustomZone
+ }{
+ {
+ name: "empty peer groups returns empty custom zones",
+ accountZones: []*zones.Zone{},
+ peerGroups: LookupMap{},
+ expected: []nbdns.CustomZone{},
+ },
+ {
+ name: "peer has access to zone with A record",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "example.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "www.example.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}},
+ expected: []nbdns.CustomZone{
+ {
+ Domain: "example.com.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "www.example.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "192.168.1.1",
+ },
+ },
+ SearchDomainDisabled: true,
+ },
+ },
+ },
+ {
+ name: "peer has access to zone with search domain enabled",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "internal.local",
+ Enabled: true,
+ EnableSearchDomain: true,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "api.internal.local",
+ Type: records.RecordTypeA,
+ Content: "10.0.0.1",
+ TTL: 600,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}},
+ expected: []nbdns.CustomZone{
+ {
+ Domain: "internal.local.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "api.internal.local.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 600,
+ RData: "10.0.0.1",
+ },
+ },
+ SearchDomainDisabled: false,
+ },
+ },
+ },
+ {
+ name: "peer has no access to zone",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "private.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group2"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "secret.private.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}},
+ expected: []nbdns.CustomZone{},
+ },
+ {
+ name: "disabled zone is filtered out",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "disabled.com",
+ Enabled: false,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "www.disabled.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}},
+ expected: []nbdns.CustomZone{},
+ },
+ {
+ name: "zone with no records is filtered out",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "empty.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{},
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}},
+ expected: []nbdns.CustomZone{},
+ },
+ {
+ name: "peer has access via multiple groups",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "multi.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1", "group2", "group3"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "www.multi.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group2": struct{}{}},
+ expected: []nbdns.CustomZone{
+ {
+ Domain: "multi.com.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "www.multi.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "192.168.1.1",
+ },
+ },
+ SearchDomainDisabled: true,
+ },
+ },
+ },
+ {
+ name: "multiple zones with mixed access",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "allowed.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "www.allowed.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ },
+ },
+ },
+ {
+ ID: "zone2",
+ Domain: "denied.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group2"},
+ Records: []*records.Record{
+ {
+ ID: "record2",
+ Name: "www.denied.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.2",
+ TTL: 300,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}},
+ expected: []nbdns.CustomZone{
+ {
+ Domain: "allowed.com.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "www.allowed.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "192.168.1.1",
+ },
+ },
+ SearchDomainDisabled: true,
+ },
+ },
+ },
+ {
+ name: "zone with multiple record types",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "mixed.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "www.mixed.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ },
+ {
+ ID: "record2",
+ Name: "ipv6.mixed.com",
+ Type: records.RecordTypeAAAA,
+ Content: "2001:db8::1",
+ TTL: 600,
+ },
+ {
+ ID: "record3",
+ Name: "alias.mixed.com",
+ Type: records.RecordTypeCNAME,
+ Content: "www.mixed.com",
+ TTL: 900,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}},
+ expected: []nbdns.CustomZone{
+ {
+ Domain: "mixed.com.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "www.mixed.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "192.168.1.1",
+ },
+ {
+ Name: "ipv6.mixed.com.",
+ Type: int(dns.TypeAAAA),
+ Class: nbdns.DefaultClass,
+ TTL: 600,
+ RData: "2001:db8::1",
+ },
+ {
+ Name: "alias.mixed.com.",
+ Type: int(dns.TypeCNAME),
+ Class: nbdns.DefaultClass,
+ TTL: 900,
+ RData: "www.mixed.com.",
+ },
+ },
+ SearchDomainDisabled: true,
+ },
+ },
+ },
+ {
+ name: "multiple zones both accessible",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "first.com",
+ Enabled: true,
+ EnableSearchDomain: true,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "www.first.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ },
+ },
+ },
+ {
+ ID: "zone2",
+ Domain: "second.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{
+ {
+ ID: "record2",
+ Name: "www.second.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.2",
+ TTL: 600,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}},
+ expected: []nbdns.CustomZone{
+ {
+ Domain: "first.com.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "www.first.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "192.168.1.1",
+ },
+ },
+ SearchDomainDisabled: false,
+ },
+ {
+ Domain: "second.com.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "www.second.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 600,
+ RData: "192.168.1.2",
+ },
+ },
+ SearchDomainDisabled: true,
+ },
+ },
+ },
+ {
+ name: "zone with multiple records of same type",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "multi-a.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "www.multi-a.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ },
+ {
+ ID: "record2",
+ Name: "www.multi-a.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.2",
+ TTL: 300,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}},
+ expected: []nbdns.CustomZone{
+ {
+ Domain: "multi-a.com.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "www.multi-a.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "192.168.1.1",
+ },
+ {
+ Name: "www.multi-a.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "192.168.1.2",
+ },
+ },
+ SearchDomainDisabled: true,
+ },
+ },
+ },
+ {
+ name: "peer in multiple groups accessing different zones",
+ accountZones: []*zones.Zone{
+ {
+ ID: "zone1",
+ Domain: "zone1.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ Records: []*records.Record{
+ {
+ ID: "record1",
+ Name: "www.zone1.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.1",
+ TTL: 300,
+ },
+ },
+ },
+ {
+ ID: "zone2",
+ Domain: "zone2.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group2"},
+ Records: []*records.Record{
+ {
+ ID: "record2",
+ Name: "www.zone2.com",
+ Type: records.RecordTypeA,
+ Content: "192.168.1.2",
+ TTL: 300,
+ },
+ },
+ },
+ },
+ peerGroups: LookupMap{"group1": struct{}{}, "group2": struct{}{}},
+ expected: []nbdns.CustomZone{
+ {
+ Domain: "zone1.com.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "www.zone1.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "192.168.1.1",
+ },
+ },
+ SearchDomainDisabled: true,
+ },
+ {
+ Domain: "zone2.com.",
+ Records: []nbdns.SimpleRecord{
+ {
+ Name: "www.zone2.com.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "192.168.1.2",
+ },
+ },
+ SearchDomainDisabled: true,
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := filterPeerAppliedZones(ctx, tt.accountZones, tt.peerGroups)
+ require.Equal(t, len(tt.expected), len(result), "number of custom zones should match")
+
+ for i, expectedZone := range tt.expected {
+ assert.Equal(t, expectedZone.Domain, result[i].Domain, "domain should match")
+ assert.Equal(t, expectedZone.SearchDomainDisabled, result[i].SearchDomainDisabled, "search domain disabled flag should match")
+ assert.Equal(t, len(expectedZone.Records), len(result[i].Records), "number of records should match")
+
+ for j, expectedRecord := range expectedZone.Records {
+ assert.Equal(t, expectedRecord.Name, result[i].Records[j].Name, "record name should match")
+ assert.Equal(t, expectedRecord.Type, result[i].Records[j].Type, "record type should match")
+ assert.Equal(t, expectedRecord.Class, result[i].Records[j].Class, "record class should match")
+ assert.Equal(t, expectedRecord.TTL, result[i].Records[j].TTL, "record TTL should match")
+ assert.Equal(t, expectedRecord.RData, result[i].Records[j].RData, "record RData should match")
+ }
+ }
+ })
+ }
+}
diff --git a/management/server/types/identity_provider.go b/management/server/types/identity_provider.go
index e809590de..c4498e4d4 100644
--- a/management/server/types/identity_provider.go
+++ b/management/server/types/identity_provider.go
@@ -7,12 +7,14 @@ import (
// Identity provider validation errors
var (
- ErrIdentityProviderNameRequired = errors.New("identity provider name is required")
- ErrIdentityProviderTypeRequired = errors.New("identity provider type is required")
- ErrIdentityProviderTypeUnsupported = errors.New("unsupported identity provider type")
- ErrIdentityProviderIssuerRequired = errors.New("identity provider issuer is required")
- ErrIdentityProviderIssuerInvalid = errors.New("identity provider issuer must be a valid URL")
- ErrIdentityProviderClientIDRequired = errors.New("identity provider client ID is required")
+ ErrIdentityProviderNameRequired = errors.New("identity provider name is required")
+ ErrIdentityProviderTypeRequired = errors.New("identity provider type is required")
+ ErrIdentityProviderTypeUnsupported = errors.New("unsupported identity provider type")
+ ErrIdentityProviderIssuerRequired = errors.New("identity provider issuer is required")
+ ErrIdentityProviderIssuerInvalid = errors.New("identity provider issuer must be a valid URL")
+ ErrIdentityProviderIssuerUnreachable = errors.New("identity provider issuer is unreachable")
+ ErrIdentityProviderIssuerMismatch = errors.New("identity provider issuer does not match the issuer returned by the provider")
+ ErrIdentityProviderClientIDRequired = errors.New("identity provider client ID is required")
)
// IdentityProviderType is the type of identity provider
diff --git a/management/server/types/job.go b/management/server/types/job.go
new file mode 100644
index 000000000..bad8f00ba
--- /dev/null
+++ b/management/server/types/job.go
@@ -0,0 +1,228 @@
+package types
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/netbirdio/netbird/shared/management/http/api"
+ "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/shared/management/status"
+)
+
+type JobStatus string
+
+const (
+ JobStatusPending JobStatus = "pending"
+ JobStatusSucceeded JobStatus = "succeeded"
+ JobStatusFailed JobStatus = "failed"
+)
+
+type JobType string
+
+const (
+ JobTypeBundle JobType = "bundle"
+)
+
+const (
+ // MaxJobReasonLength is the maximum length allowed for job failure reasons
+ MaxJobReasonLength = 4096
+)
+
+type Job struct {
+ // ID is the primary identifier
+ ID string `gorm:"primaryKey"`
+
+ // CreatedAt when job was created (UTC)
+ CreatedAt time.Time `gorm:"autoCreateTime"`
+
+ // CompletedAt when job finished, null if still running
+ CompletedAt *time.Time
+
+ // TriggeredBy user that triggered this job
+ TriggeredBy string `gorm:"index"`
+
+ PeerID string `gorm:"index"`
+
+ AccountID string `gorm:"index"`
+
+ // Status of the job: pending, succeeded, failed
+ Status JobStatus `gorm:"index;type:varchar(50)"`
+
+ // FailedReason describes why the job failed (if failed)
+ FailedReason string
+
+ Workload Workload `gorm:"embedded;embeddedPrefix:workload_"`
+}
+
+type Workload struct {
+ Type JobType `gorm:"column:workload_type;index;type:varchar(50)"`
+ Parameters json.RawMessage `gorm:"type:json"`
+ Result json.RawMessage `gorm:"type:json"`
+}
+
+// NewJob creates a new job with default fields and validation
+func NewJob(triggeredBy, accountID, peerID string, req *api.JobRequest) (*Job, error) {
+ if req == nil {
+ return nil, status.Errorf(status.BadRequest, "job request cannot be nil")
+ }
+
+ // Determine job type
+ jobTypeStr, err := req.Workload.Discriminator()
+ if err != nil {
+ return nil, status.Errorf(status.BadRequest, "could not determine job type: %v", err)
+ }
+ jobType := JobType(jobTypeStr)
+
+ if jobType == "" {
+ return nil, status.Errorf(status.BadRequest, "job type is required")
+ }
+
+ var workload Workload
+
+ switch jobType {
+ case JobTypeBundle:
+ if err := validateAndBuildBundleParams(req.Workload, &workload); err != nil {
+ return nil, status.Errorf(status.BadRequest, "%v", err)
+ }
+ default:
+ return nil, status.Errorf(status.BadRequest, "unsupported job type: %s", jobType)
+ }
+
+ return &Job{
+ ID: uuid.New().String(),
+ TriggeredBy: triggeredBy,
+ PeerID: peerID,
+ AccountID: accountID,
+ Status: JobStatusPending,
+ CreatedAt: time.Now().UTC(),
+ Workload: workload,
+ }, nil
+}
+
+func (j *Job) BuildWorkloadResponse() (*api.WorkloadResponse, error) {
+ var wl api.WorkloadResponse
+
+ switch j.Workload.Type {
+ case JobTypeBundle:
+ if err := j.buildBundleResponse(&wl); err != nil {
+ return nil, status.Errorf(status.Internal, "failed to process job: %v", err.Error())
+ }
+ return &wl, nil
+
+ default:
+ return nil, status.Errorf(status.InvalidArgument, "unknown job type: %v", j.Workload.Type)
+ }
+}
+
+func (j *Job) buildBundleResponse(wl *api.WorkloadResponse) error {
+ var p api.BundleParameters
+ if err := json.Unmarshal(j.Workload.Parameters, &p); err != nil {
+ return fmt.Errorf("invalid parameters for bundle job: %w", err)
+ }
+ var r api.BundleResult
+ if err := json.Unmarshal(j.Workload.Result, &r); err != nil {
+ return fmt.Errorf("invalid result for bundle job: %w", err)
+ }
+
+ if err := wl.FromBundleWorkloadResponse(api.BundleWorkloadResponse{
+ Type: api.WorkloadTypeBundle,
+ Parameters: p,
+ Result: r,
+ }); err != nil {
+ return fmt.Errorf("unknown job parameters: %v", err)
+ }
+ return nil
+}
+
+func validateAndBuildBundleParams(req api.WorkloadRequest, workload *Workload) error {
+ bundle, err := req.AsBundleWorkloadRequest()
+ if err != nil {
+ return fmt.Errorf("invalid parameters for bundle job")
+ }
+ // validate bundle_for_time <= 5 minutes if BundleFor is enabled
+ if bundle.Parameters.BundleFor && (bundle.Parameters.BundleForTime < 1 || bundle.Parameters.BundleForTime > 5) {
+ return fmt.Errorf("bundle_for_time must be between 1 and 5, got %d", bundle.Parameters.BundleForTime)
+ }
+ // validate log-file-count ≥ 1 and ≤ 1000
+ if bundle.Parameters.LogFileCount < 1 || bundle.Parameters.LogFileCount > 1000 {
+ return fmt.Errorf("log-file-count must be between 1 and 1000, got %d", bundle.Parameters.LogFileCount)
+ }
+
+ workload.Parameters, err = json.Marshal(bundle.Parameters)
+ if err != nil {
+ return fmt.Errorf("failed to marshal workload parameters: %w", err)
+ }
+ workload.Result = []byte("{}")
+ workload.Type = JobType(api.WorkloadTypeBundle)
+
+ return nil
+}
+
+// ApplyResponse validates and maps a proto.JobResponse into the Job fields.
+func (j *Job) ApplyResponse(resp *proto.JobResponse) error {
+ if resp == nil {
+ return nil
+ }
+
+ j.ID = string(resp.ID)
+ now := time.Now().UTC()
+ j.CompletedAt = &now
+ switch resp.Status {
+ case proto.JobStatus_succeeded:
+ j.Status = JobStatusSucceeded
+ case proto.JobStatus_failed:
+ j.Status = JobStatusFailed
+ if len(resp.Reason) > 0 {
+ reason := string(resp.Reason)
+ if len(resp.Reason) > MaxJobReasonLength {
+ reason = string(resp.Reason[:MaxJobReasonLength]) + "... (truncated)"
+ }
+ j.FailedReason = fmt.Sprintf("Client error: '%s'", reason)
+ }
+ return nil
+ default:
+ return fmt.Errorf("unexpected job status: %v", resp.Status)
+ }
+
+ // Handle workload results (oneof)
+ var err error
+ switch r := resp.WorkloadResults.(type) {
+ case *proto.JobResponse_Bundle:
+ if j.Workload.Result, err = json.Marshal(r.Bundle); err != nil {
+ return fmt.Errorf("failed to marshal workload results: %w", err)
+ }
+ default:
+ return fmt.Errorf("unsupported workload response type: %T", r)
+ }
+ return nil
+}
+
+func (j *Job) ToStreamJobRequest() (*proto.JobRequest, error) {
+ switch j.Workload.Type {
+ case JobTypeBundle:
+ return j.buildStreamBundleResponse()
+ default:
+ return nil, status.Errorf(status.InvalidArgument, "unknown job type: %v", j.Workload.Type)
+ }
+}
+
+func (j *Job) buildStreamBundleResponse() (*proto.JobRequest, error) {
+ var p api.BundleParameters
+ if err := json.Unmarshal(j.Workload.Parameters, &p); err != nil {
+ return nil, fmt.Errorf("invalid parameters for bundle job: %w", err)
+ }
+ return &proto.JobRequest{
+ ID: []byte(j.ID),
+ WorkloadParameters: &proto.JobRequest_Bundle{
+ Bundle: &proto.BundleParameters{
+ BundleFor: p.BundleFor,
+ BundleForTime: int64(p.BundleForTime),
+ LogFileCount: int32(p.LogFileCount),
+ Anonymize: p.Anonymize,
+ },
+ },
+ }, nil
+}
diff --git a/management/server/types/networkmap.go b/management/server/types/networkmap.go
index ff81e5dc1..68c988a93 100644
--- a/management/server/types/networkmap.go
+++ b/management/server/types/networkmap.go
@@ -4,6 +4,7 @@ import (
"context"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/telemetry"
)
@@ -25,11 +26,12 @@ func (a *Account) GetPeerNetworkMapExp(
ctx context.Context,
peerID string,
peersCustomZone nbdns.CustomZone,
+ accountZones []*zones.Zone,
validatedPeers map[string]struct{},
metrics *telemetry.AccountManagerMetrics,
) *NetworkMap {
a.initNetworkMapBuilder(validatedPeers)
- return a.NetworkMapCache.GetPeerNetworkMap(ctx, peerID, peersCustomZone, validatedPeers, metrics)
+ return a.NetworkMapCache.GetPeerNetworkMap(ctx, peerID, peersCustomZone, accountZones, validatedPeers, metrics)
}
func (a *Account) OnPeerAddedUpdNetworkMapCache(peerId string) error {
diff --git a/management/server/types/networkmap_golden_test.go b/management/server/types/networkmap_golden_test.go
index 19ccbd688..ef6c51779 100644
--- a/management/server/types/networkmap_golden_test.go
+++ b/management/server/types/networkmap_golden_test.go
@@ -70,13 +70,13 @@ func TestGetPeerNetworkMap_Golden(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
- legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
+ legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
- newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
+ newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
@@ -115,7 +115,7 @@ func BenchmarkGetPeerNetworkMap(b *testing.B) {
b.Run("old builder", func(b *testing.B) {
for range b.N {
for _, peerID := range peerIDs {
- _ = account.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
+ _ = account.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
}
}
})
@@ -124,7 +124,7 @@ func BenchmarkGetPeerNetworkMap(b *testing.B) {
for range b.N {
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
for _, peerID := range peerIDs {
- _ = builder.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, validatedPeersMap, nil)
+ _ = builder.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
}
}
})
@@ -177,7 +177,7 @@ func TestGetPeerNetworkMap_Golden_WithNewPeer(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
- legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
+ legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
@@ -185,7 +185,7 @@ func TestGetPeerNetworkMap_Golden_WithNewPeer(t *testing.T) {
err = builder.OnPeerAddedIncremental(account, newPeerID)
require.NoError(t, err, "error adding peer to cache")
- newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
+ newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
@@ -240,7 +240,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerAdded(b *testing.B) {
b.Run("old builder after add", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, testingPeerID := range peerIDs {
- _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
+ _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
}
}
})
@@ -250,7 +250,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerAdded(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = builder.OnPeerAddedIncremental(account, newPeerID)
for _, testingPeerID := range peerIDs {
- _ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
+ _ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
}
}
})
@@ -317,7 +317,7 @@ func TestGetPeerNetworkMap_Golden_WithNewRoutingPeer(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
- legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
+ legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
@@ -325,7 +325,7 @@ func TestGetPeerNetworkMap_Golden_WithNewRoutingPeer(t *testing.T) {
err = builder.OnPeerAddedIncremental(account, newRouterID)
require.NoError(t, err, "error adding router to cache")
- newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
+ newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
@@ -402,7 +402,7 @@ func BenchmarkGetPeerNetworkMap_AfterRouterPeerAdded(b *testing.B) {
b.Run("old builder after add", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, testingPeerID := range peerIDs {
- _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
+ _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
}
}
})
@@ -412,7 +412,7 @@ func BenchmarkGetPeerNetworkMap_AfterRouterPeerAdded(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = builder.OnPeerAddedIncremental(account, newRouterID)
for _, testingPeerID := range peerIDs {
- _ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
+ _ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
}
}
})
@@ -458,7 +458,7 @@ func TestGetPeerNetworkMap_Golden_WithDeletedPeer(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
- legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
+ legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
@@ -466,7 +466,7 @@ func TestGetPeerNetworkMap_Golden_WithDeletedPeer(t *testing.T) {
err = builder.OnPeerDeleted(account, deletedPeerID)
require.NoError(t, err, "error deleting peer from cache")
- newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
+ newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
@@ -537,7 +537,7 @@ func TestGetPeerNetworkMap_Golden_WithDeletedRouterPeer(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
- legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
+ legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
@@ -545,7 +545,7 @@ func TestGetPeerNetworkMap_Golden_WithDeletedRouterPeer(t *testing.T) {
err = builder.OnPeerDeleted(account, deletedRouterID)
require.NoError(t, err, "error deleting routing peer from cache")
- newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
+ newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
@@ -597,7 +597,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerDeleted(b *testing.B) {
b.Run("old builder after delete", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, testingPeerID := range peerIDs {
- _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
+ _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
}
}
})
@@ -607,7 +607,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerDeleted(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = builder.OnPeerDeleted(account, deletedPeerID)
for _, testingPeerID := range peerIDs {
- _ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
+ _ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
}
}
})
@@ -944,7 +944,7 @@ func TestGetPeerNetworkMap_Golden_New_WithOnPeerAddedRouter_Batched(t *testing.T
time.Sleep(100 * time.Millisecond)
- networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
+ networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil)
normalizeAndSortNetworkMap(networkMap)
diff --git a/management/server/types/networkmapbuilder.go b/management/server/types/networkmapbuilder.go
index 0acd3a026..6448b8403 100644
--- a/management/server/types/networkmapbuilder.go
+++ b/management/server/types/networkmapbuilder.go
@@ -14,6 +14,7 @@ import (
"github.com/netbirdio/netbird/client/ssh/auth"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/zones"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
@@ -1033,7 +1034,7 @@ func (b *NetworkMapBuilder) updateAccountLocked(account *Account) *Account {
}
func (b *NetworkMapBuilder) GetPeerNetworkMap(
- ctx context.Context, peerID string, peersCustomZone nbdns.CustomZone,
+ ctx context.Context, peerID string, peersCustomZone nbdns.CustomZone, accountZones []*zones.Zone,
validatedPeers map[string]struct{}, metrics *telemetry.AccountManagerMetrics,
) *NetworkMap {
start := time.Now()
@@ -1057,7 +1058,7 @@ func (b *NetworkMapBuilder) GetPeerNetworkMap(
return &NetworkMap{Network: account.Network.Copy()}
}
- nm := b.assembleNetworkMap(account, peer, aclView, routesView, dnsConfig, sshView, peersCustomZone, validatedPeers)
+ nm := b.assembleNetworkMap(ctx, account, peer, aclView, routesView, dnsConfig, sshView, peersCustomZone, accountZones, validatedPeers)
if metrics != nil {
objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
@@ -1074,8 +1075,8 @@ func (b *NetworkMapBuilder) GetPeerNetworkMap(
}
func (b *NetworkMapBuilder) assembleNetworkMap(
- account *Account, peer *nbpeer.Peer, aclView *PeerACLView, routesView *PeerRoutesView,
- dnsConfig *nbdns.Config, sshView *PeerSSHView, customZone nbdns.CustomZone, validatedPeers map[string]struct{},
+ ctx context.Context, account *Account, peer *nbpeer.Peer, aclView *PeerACLView, routesView *PeerRoutesView,
+ dnsConfig *nbdns.Config, sshView *PeerSSHView, peersCustomZone nbdns.CustomZone, accountZones []*zones.Zone, validatedPeers map[string]struct{},
) *NetworkMap {
var peersToConnect []*nbpeer.Peer
@@ -1125,13 +1126,26 @@ func (b *NetworkMapBuilder) assembleNetworkMap(
}
finalDNSConfig := *dnsConfig
- if finalDNSConfig.ServiceEnable && customZone.Domain != "" {
+ if finalDNSConfig.ServiceEnable {
var zones []nbdns.CustomZone
- records := filterZoneRecordsForPeers(peer, customZone, peersToConnect, expiredPeers)
- zones = append(zones, nbdns.CustomZone{
- Domain: customZone.Domain,
- Records: records,
- })
+
+ peerGroupsSlice := b.cache.peerToGroups[peer.ID]
+ peerGroups := make(LookupMap, len(peerGroupsSlice))
+ for _, groupID := range peerGroupsSlice {
+ peerGroups[groupID] = struct{}{}
+ }
+
+ if peersCustomZone.Domain != "" {
+ records := filterZoneRecordsForPeers(peer, peersCustomZone, peersToConnect, expiredPeers)
+ zones = append(zones, nbdns.CustomZone{
+ Domain: peersCustomZone.Domain,
+ Records: records,
+ })
+ }
+
+ filteredAccountZones := filterPeerAppliedZones(ctx, accountZones, peerGroups)
+ zones = append(zones, filteredAccountZones...)
+
finalDNSConfig.CustomZones = zones
}
diff --git a/management/server/user.go b/management/server/user.go
index 4f9007b61..1f38b749f 100644
--- a/management/server/user.go
+++ b/management/server/user.go
@@ -249,6 +249,37 @@ func (am *DefaultAccountManager) ListUsers(ctx context.Context, accountID string
return am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
}
+// UpdateUserPassword updates the password for a user in the embedded IdP.
+// This is only available when the embedded IdP is enabled.
+// Users can only change their own password.
+func (am *DefaultAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
+ if !IsEmbeddedIdp(am.idpManager) {
+ return status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider")
+ }
+
+ if oldPassword == "" {
+ return status.Errorf(status.InvalidArgument, "old password is required")
+ }
+
+ if newPassword == "" {
+ return status.Errorf(status.InvalidArgument, "new password is required")
+ }
+
+ embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager)
+ if !ok {
+ return status.Errorf(status.Internal, "failed to get embedded IdP manager")
+ }
+
+ err := embeddedIdp.UpdateUserPassword(ctx, currentUserID, targetUserID, oldPassword, newPassword)
+ if err != nil {
+ return status.Errorf(status.InvalidArgument, "failed to update password: %v", err)
+ }
+
+ am.StoreEvent(ctx, currentUserID, targetUserID, accountID, activity.UserPasswordChanged, nil)
+
+ return nil
+}
+
func (am *DefaultAccountManager) deleteServiceUser(ctx context.Context, accountID string, initiatorUserID string, targetUser *types.User) error {
if err := am.Store.DeleteUser(ctx, accountID, targetUser.Id); err != nil {
return err
@@ -806,7 +837,20 @@ func (am *DefaultAccountManager) getUserInfo(ctx context.Context, user *types.Us
}
return user.ToUserInfo(userData)
}
- return user.ToUserInfo(nil)
+
+ userInfo, err := user.ToUserInfo(nil)
+ if err != nil {
+ return nil, err
+ }
+
+ // For embedded IDP users, extract the IdPID (connector ID) from the encoded user ID
+ if IsEmbeddedIdp(am.idpManager) && !user.IsServiceUser {
+ if _, connectorID, decodeErr := dex.DecodeDexUserID(user.Id); decodeErr == nil && connectorID != "" {
+ userInfo.IdPID = connectorID
+ }
+ }
+
+ return userInfo, nil
}
// validateUserUpdate validates the update operation for a user.
@@ -911,10 +955,12 @@ func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accoun
accountUsers := []*types.User{}
switch {
case allowed:
+ start := time.Now()
accountUsers, err = am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
+ log.WithContext(ctx).Tracef("Got %d users from account %s after %s", len(accountUsers), accountID, time.Since(start))
case user != nil && user.AccountID == accountID:
accountUsers = append(accountUsers, user)
default:
@@ -933,23 +979,40 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a
if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
users := make(map[string]userLoggedInOnce, len(accountUsers))
usersFromIntegration := make([]*idp.UserData, 0)
+ filtered := make(map[string]*idp.UserData, len(accountUsers))
+ log.WithContext(ctx).Tracef("Querying users from IDP for account %s", accountID)
+ start := time.Now()
+
+ integrationKeys := make(map[string]struct{})
for _, user := range accountUsers {
if user.Issued == types.UserIssuedIntegration {
- key := user.IntegrationReference.CacheKey(accountID, user.Id)
- info, err := am.externalCacheManager.Get(am.ctx, key)
- if err != nil {
- log.WithContext(ctx).Infof("Get ExternalCache for key: %s, error: %s", key, err)
- users[user.Id] = true
- continue
- }
- usersFromIntegration = append(usersFromIntegration, info)
+ integrationKeys[user.IntegrationReference.CacheKey(accountID)] = struct{}{}
continue
}
if !user.IsServiceUser {
users[user.Id] = userLoggedInOnce(!user.GetLastLogin().IsZero())
}
}
+
+ for key := range integrationKeys {
+ usersData, err := am.externalCacheManager.GetUsers(am.ctx, key)
+ if err != nil {
+ log.WithContext(ctx).Debugf("GetUsers from ExternalCache for key: %s, error: %s", key, err)
+ continue
+ }
+ for _, ud := range usersData {
+ filtered[ud.ID] = ud
+ }
+ }
+
+ for _, ud := range filtered {
+ usersFromIntegration = append(usersFromIntegration, ud)
+ }
+
+ log.WithContext(ctx).Tracef("Got user info from external cache after %s", time.Since(start))
+ start = time.Now()
queriedUsers, err = am.lookupCache(ctx, users, accountID)
+ log.WithContext(ctx).Tracef("Got user info from cache for %d users after %s", len(queriedUsers), time.Since(start))
if err != nil {
return nil, err
}
diff --git a/management/server/user_test.go b/management/server/user_test.go
index 6d356a8b1..2dd1cea2e 100644
--- a/management/server/user_test.go
+++ b/management/server/user_test.go
@@ -1086,8 +1086,12 @@ func TestDefaultAccountManager_ExternalCache(t *testing.T) {
assert.NoError(t, err)
cacheManager := am.GetExternalCacheManager()
- cacheKey := externalUser.IntegrationReference.CacheKey(mockAccountID, externalUser.Id)
- err = cacheManager.Set(context.Background(), cacheKey, &idp.UserData{ID: externalUser.Id, Name: "Test User", Email: "user@example.com"}, time.Minute)
+ tud := &idp.UserData{ID: externalUser.Id, Name: "Test User", Email: "user@example.com"}
+ cacheKeyUser := externalUser.IntegrationReference.CacheKey(mockAccountID, externalUser.Id)
+ err = cacheManager.Set(context.Background(), cacheKeyUser, tud, time.Minute)
+ assert.NoError(t, err)
+ cacheKeyAccount := externalUser.IntegrationReference.CacheKey(mockAccountID)
+ err = cacheManager.SetUsers(context.Background(), cacheKeyAccount, []*idp.UserData{tud}, time.Minute)
assert.NoError(t, err)
infos, err := am.GetUsersFromAccount(context.Background(), mockAccountID, mockUserID)
diff --git a/management/server/util/util.go b/management/server/util/util.go
index 617484274..eea6a72b0 100644
--- a/management/server/util/util.go
+++ b/management/server/util/util.go
@@ -1,5 +1,9 @@
package util
+import "regexp"
+
+var domainRegex = regexp.MustCompile(`^(\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$`)
+
// Difference returns the elements in `a` that aren't in `b`.
func Difference(a, b []string) []string {
mb := make(map[string]struct{}, len(b))
@@ -50,3 +54,10 @@ func contains[T comparableObject[T]](slice []T, element T) bool {
}
return false
}
+
+func IsValidDomain(domain string) bool {
+ if domain == "" {
+ return false
+ }
+ return domainRegex.MatchString(domain)
+}
diff --git a/relay/cmd/root.go b/relay/cmd/root.go
index e7dadcfdf..20c565c3d 100644
--- a/relay/cmd/root.go
+++ b/relay/cmd/root.go
@@ -6,6 +6,7 @@ import (
"crypto/tls"
"errors"
"fmt"
+ "net"
"net/http"
"os"
"os/signal"
@@ -22,6 +23,7 @@ import (
"github.com/netbirdio/netbird/relay/server"
"github.com/netbirdio/netbird/shared/relay/auth"
"github.com/netbirdio/netbird/signal/metrics"
+ "github.com/netbirdio/netbird/stun"
"github.com/netbirdio/netbird/util"
)
@@ -43,6 +45,10 @@ type Config struct {
LogLevel string
LogFile string
HealthcheckListenAddress string
+ // STUN server configuration
+ EnableSTUN bool
+ STUNPorts []int
+ STUNLogLevel string
}
func (c Config) Validate() error {
@@ -52,6 +58,25 @@ func (c Config) Validate() error {
if c.AuthSecret == "" {
return fmt.Errorf("auth secret is required")
}
+
+ // Validate STUN configuration
+ if c.EnableSTUN {
+ if len(c.STUNPorts) == 0 {
+ return fmt.Errorf("--stun-ports is required when --enable-stun is set")
+ }
+
+ seen := make(map[int]bool)
+ for _, port := range c.STUNPorts {
+ if port <= 0 || port > 65535 {
+ return fmt.Errorf("invalid STUN port %d: must be between 1 and 65535", port)
+ }
+ if seen[port] {
+ return fmt.Errorf("duplicate STUN port %d", port)
+ }
+ seen[port] = true
+ }
+ }
+
return nil
}
@@ -91,6 +116,9 @@ func init() {
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level")
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file")
rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server")
+ rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server")
+ rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)")
+ rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)")
setFlagsFromEnvVars(rootCmd)
}
@@ -119,21 +147,14 @@ func execute(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to initialize log: %s", err)
}
+ // Resource creation phase (fail fast before starting any goroutines)
+
metricsServer, err := metrics.NewServer(cobraConfig.MetricsPort, "")
if err != nil {
log.Debugf("setup metrics: %v", err)
return fmt.Errorf("setup metrics: %v", err)
}
- wg.Add(1)
- go func() {
- defer wg.Done()
- log.Infof("running metrics server: %s%s", metricsServer.Addr, metricsServer.Endpoint)
- if err := metricsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
- log.Fatalf("Failed to start metrics server: %v", err)
- }
- }()
-
srvListenerCfg := server.ListenerConfig{
Address: cobraConfig.ListenAddress,
}
@@ -145,6 +166,12 @@ func execute(cmd *cobra.Command, args []string) error {
}
srvListenerCfg.TLSConfig = tlsConfig
+ // Create STUN listeners early to fail fast
+ stunListeners, err := createSTUNListeners()
+ if err != nil {
+ return err
+ }
+
hashedSecret := sha256.Sum256([]byte(cobraConfig.AuthSecret))
authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour)
@@ -155,60 +182,145 @@ func execute(cmd *cobra.Command, args []string) error {
TLSSupport: tlsSupport,
}
- srv, err := server.NewServer(cfg)
+ srv, err := createRelayServer(cfg)
if err != nil {
- log.Debugf("failed to create relay server: %v", err)
- return fmt.Errorf("failed to create relay server: %v", err)
+ cleanupSTUNListeners(stunListeners)
+ return err
}
+
+ hCfg := healthcheck.Config{
+ ListenAddress: cobraConfig.HealthcheckListenAddress,
+ ServiceChecker: srv,
+ }
+ httpHealthcheck, err := createHealthCheck(hCfg)
+ if err != nil {
+ cleanupSTUNListeners(stunListeners)
+ return err
+ }
+
+ var stunServer *stun.Server
+ if len(stunListeners) > 0 {
+ stunServer = stun.NewServer(stunListeners, cobraConfig.STUNLogLevel)
+ }
+
+ // Start all servers (only after all resources are successfully created)
+ startServers(&wg, metricsServer, srv, srvListenerCfg, httpHealthcheck, stunServer)
+
+ waitForExitSignal()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ err = shutdownServers(ctx, metricsServer, srv, httpHealthcheck, stunServer)
+ wg.Wait()
+ return err
+}
+
+func startServers(wg *sync.WaitGroup, metricsServer *metrics.Metrics, srv *server.Server, srvListenerCfg server.ListenerConfig, httpHealthcheck *healthcheck.Server, stunServer *stun.Server) {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ log.Infof("running metrics server: %s%s", metricsServer.Addr, metricsServer.Endpoint)
+ if err := metricsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
+ log.Fatalf("failed to start metrics server: %v", err)
+ }
+ }()
+
instanceURL := srv.InstanceURL()
log.Infof("server will be available on: %s", instanceURL.String())
wg.Add(1)
go func() {
defer wg.Done()
if err := srv.Listen(srvListenerCfg); err != nil {
- log.Fatalf("failed to bind server: %s", err)
+ log.Fatalf("failed to bind relay server: %s", err)
}
}()
- hCfg := healthcheck.Config{
- ListenAddress: cobraConfig.HealthcheckListenAddress,
- ServiceChecker: srv,
- }
- httpHealthcheck, err := healthcheck.NewServer(hCfg)
- if err != nil {
- log.Debugf("failed to create healthcheck server: %v", err)
- return fmt.Errorf("failed to create healthcheck server: %v", err)
- }
wg.Add(1)
go func() {
defer wg.Done()
if err := httpHealthcheck.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
- log.Fatalf("Failed to start healthcheck server: %v", err)
+ log.Fatalf("failed to start healthcheck server: %v", err)
}
}()
- // it will block until exit signal
- waitForExitSignal()
+ if stunServer != nil {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if err := stunServer.Listen(); err != nil {
+ if errors.Is(err, stun.ErrServerClosed) {
+ return
+ }
+ log.Errorf("STUN server error: %v", err)
+ }
+ }()
+ }
+}
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
+func shutdownServers(ctx context.Context, metricsServer *metrics.Metrics, srv *server.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server) error {
+ var errs error
- var shutDownErrors error
if err := httpHealthcheck.Shutdown(ctx); err != nil {
- shutDownErrors = multierror.Append(shutDownErrors, fmt.Errorf("failed to close healthcheck server: %v", err))
+ errs = multierror.Append(errs, fmt.Errorf("failed to close healthcheck server: %w", err))
+ }
+
+ if stunServer != nil {
+ if err := stunServer.Shutdown(); err != nil {
+ errs = multierror.Append(errs, fmt.Errorf("failed to close STUN server: %w", err))
+ }
}
if err := srv.Shutdown(ctx); err != nil {
- shutDownErrors = multierror.Append(shutDownErrors, fmt.Errorf("failed to close server: %s", err))
+ errs = multierror.Append(errs, fmt.Errorf("failed to close relay server: %w", err))
}
log.Infof("shutting down metrics server")
if err := metricsServer.Shutdown(ctx); err != nil {
- shutDownErrors = multierror.Append(shutDownErrors, fmt.Errorf("failed to close metrics server: %v", err))
+ errs = multierror.Append(errs, fmt.Errorf("failed to close metrics server: %w", err))
}
- wg.Wait()
- return shutDownErrors
+ return errs
+}
+
+func createHealthCheck(hCfg healthcheck.Config) (*healthcheck.Server, error) {
+ httpHealthcheck, err := healthcheck.NewServer(hCfg)
+ if err != nil {
+ log.Debugf("failed to create healthcheck server: %v", err)
+ return nil, fmt.Errorf("failed to create healthcheck server: %v", err)
+ }
+ return httpHealthcheck, nil
+}
+
+func createRelayServer(cfg server.Config) (*server.Server, error) {
+ srv, err := server.NewServer(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create relay server: %v", err)
+ }
+ return srv, nil
+}
+
+func cleanupSTUNListeners(stunListeners []*net.UDPConn) {
+ for _, l := range stunListeners {
+ _ = l.Close()
+ }
+}
+
+func createSTUNListeners() ([]*net.UDPConn, error) {
+ var stunListeners []*net.UDPConn
+ if cobraConfig.EnableSTUN {
+ for _, port := range cobraConfig.STUNPorts {
+ listener, err := net.ListenUDP("udp", &net.UDPAddr{Port: port})
+ if err != nil {
+ // Close already opened listeners on failure
+ cleanupSTUNListeners(stunListeners)
+ log.Debugf("failed to create STUN listener on port %d: %v", port, err)
+ return nil, fmt.Errorf("failed to create STUN listener on port %d: %v", port, err)
+ }
+ stunListeners = append(stunListeners, listener)
+ }
+ }
+ return stunListeners, nil
}
func handleTLSConfig(cfg *Config) (*tls.Config, bool, error) {
diff --git a/release_files/ui-post-install.sh b/release_files/ui-post-install.sh
index ff6c4ee9b..e2eb32cdc 100644
--- a/release_files/ui-post-install.sh
+++ b/release_files/ui-post-install.sh
@@ -8,6 +8,10 @@ pid="$(pgrep -x -f /usr/bin/netbird-ui || true)"
if [ -n "${pid}" ]
then
uid="$(cat /proc/"${pid}"/loginuid)"
+ # loginuid can be 4294967295 (-1) if not set, fall back to process uid
+ if [ "${uid}" = "4294967295" ] || [ "${uid}" = "-1" ]; then
+ uid="$(stat -c '%u' /proc/"${pid}")"
+ fi
username="$(id -nu "${uid}")"
# Only re-run if it was already running
pkill -x -f /usr/bin/netbird-ui >/dev/null 2>&1
diff --git a/shared/management/client/client.go b/shared/management/client/client.go
index 8a89010eb..5b10273b0 100644
--- a/shared/management/client/client.go
+++ b/shared/management/client/client.go
@@ -14,6 +14,7 @@ import (
type Client interface {
io.Closer
Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error
+ Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error
GetServerPublicKey() (*wgtypes.Key, error)
Register(serverKey wgtypes.Key, setupKey string, jwtToken string, sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
Login(serverKey wgtypes.Key, sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go
index 64f6831f2..a11f863a7 100644
--- a/shared/management/client/client_test.go
+++ b/shared/management/client/client_test.go
@@ -18,12 +18,13 @@ import (
"google.golang.org/grpc/status"
"github.com/netbirdio/management-integrations/integrations"
+ ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
"github.com/netbirdio/netbird/management/internals/modules/peers"
- "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server/job"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/encryption"
@@ -92,6 +93,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
peersManger := peers.NewManager(store, permissionsManagerMock)
settingsManagerMock := settings.NewMockManager(ctrl)
+ jobManager := job.NewJobManager(nil, store, peersManger)
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManger, settingsManagerMock, eventStore)
@@ -117,8 +119,8 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
ctx := context.Background()
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
- networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManger), config)
- accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
+ networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManger), config)
+ accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
if err != nil {
t.Fatal(err)
}
@@ -129,7 +131,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
if err != nil {
t.Fatal(err)
}
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, secretsManager, nil, nil, mgmt.MockIntegratedValidator{}, networkMapController, nil)
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, mgmt.MockIntegratedValidator{}, networkMapController, nil)
if err != nil {
t.Fatal(err)
}
diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go
index a030f1371..f2a8a6600 100644
--- a/shared/management/client/grpc.go
+++ b/shared/management/client/grpc.go
@@ -12,6 +12,7 @@ import (
gstatus "google.golang.org/grpc/status"
"github.com/cenkalti/backoff/v4"
+ "github.com/google/uuid"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/grpc"
@@ -118,8 +119,26 @@ func (c *GrpcClient) ready() bool {
// Sync wraps the real client's Sync endpoint call and takes care of retries and encryption/decryption of messages
// Blocking request. The result will be sent via msgHandler callback function
func (c *GrpcClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error {
- backOff := defaultBackoff(ctx)
+ return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error {
+ return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler)
+ })
+}
+// Job wraps the real client's Job endpoint call and takes care of retries and encryption/decryption of messages
+// Blocking request. The result will be sent via msgHandler callback function
+func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error {
+ return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error {
+ return c.handleJobStream(ctx, serverPubKey, msgHandler)
+ })
+}
+
+// withMgmtStream runs a streaming operation against the ManagementService
+// It takes care of retries, connection readiness, and fetching server public key.
+func (c *GrpcClient) withMgmtStream(
+ ctx context.Context,
+ handler func(ctx context.Context, serverPubKey wgtypes.Key) error,
+) error {
+ backOff := defaultBackoff(ctx)
operation := func() error {
log.Debugf("management connection state %v", c.conn.GetState())
connState := c.conn.GetState()
@@ -137,7 +156,7 @@ func (c *GrpcClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler
return err
}
- return c.handleStream(ctx, *serverPubKey, sysInfo, msgHandler, backOff)
+ return handler(ctx, *serverPubKey)
}
err := backoff.Retry(operation, backOff)
@@ -148,12 +167,153 @@ func (c *GrpcClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler
return err
}
-func (c *GrpcClient) handleStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info,
- msgHandler func(msg *proto.SyncResponse) error, backOff backoff.BackOff) error {
+func (c *GrpcClient) handleJobStream(
+ ctx context.Context,
+ serverPubKey wgtypes.Key,
+ msgHandler func(msg *proto.JobRequest) *proto.JobResponse,
+) error {
ctx, cancelStream := context.WithCancel(ctx)
defer cancelStream()
- stream, err := c.connectToStream(ctx, serverPubKey, sysInfo)
+ stream, err := c.realClient.Job(ctx)
+ if err != nil {
+ log.Errorf("failed to open job stream: %v", err)
+ return err
+ }
+
+ // Handshake with the server
+ if err := c.sendHandshake(ctx, stream, serverPubKey); err != nil {
+ return err
+ }
+
+ log.Debug("job stream handshake sent successfully")
+
+ // Main loop: receive, process, respond
+ for {
+ jobReq, err := c.receiveJobRequest(ctx, stream, serverPubKey)
+ if err != nil {
+ if s, ok := gstatus.FromError(err); ok {
+ switch s.Code() {
+ case codes.PermissionDenied:
+ c.notifyDisconnected(err)
+ return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer
+ case codes.Canceled:
+ log.Debugf("management connection context has been canceled, this usually indicates shutdown")
+ return err
+ case codes.Unimplemented:
+ log.Warn("Job feature is not supported by the current management server version. " +
+ "Please update the management service to use this feature.")
+ return nil
+ default:
+ c.notifyDisconnected(err)
+ log.Warnf("disconnected from the Management service but will retry silently. Reason: %v", err)
+ return err
+ }
+ } else {
+ // non-gRPC error
+ c.notifyDisconnected(err)
+ log.Warnf("disconnected from the Management service but will retry silently. Reason: %v", err)
+ return err
+ }
+ }
+
+ if jobReq == nil || len(jobReq.ID) == 0 {
+ log.Debug("received unknown or empty job request, skipping")
+ continue
+ }
+
+ log.Infof("received a new job from the management server (ID: %s)", jobReq.ID)
+ jobResp := c.processJobRequest(ctx, jobReq, msgHandler)
+ if err := c.sendJobResponse(ctx, stream, serverPubKey, jobResp); err != nil {
+ return err
+ }
+ }
+}
+
+// sendHandshake sends the initial handshake message
+func (c *GrpcClient) sendHandshake(ctx context.Context, stream proto.ManagementService_JobClient, serverPubKey wgtypes.Key) error {
+ handshakeReq := &proto.JobRequest{
+ ID: []byte(uuid.New().String()),
+ }
+ encHello, err := encryption.EncryptMessage(serverPubKey, c.key, handshakeReq)
+ if err != nil {
+ log.Errorf("failed to encrypt handshake message: %v", err)
+ return err
+ }
+ return stream.Send(&proto.EncryptedMessage{
+ WgPubKey: c.key.PublicKey().String(),
+ Body: encHello,
+ })
+}
+
+// receiveJobRequest waits for and decrypts a job request
+func (c *GrpcClient) receiveJobRequest(
+ ctx context.Context,
+ stream proto.ManagementService_JobClient,
+ serverPubKey wgtypes.Key,
+) (*proto.JobRequest, error) {
+ encryptedMsg, err := stream.Recv()
+ if err != nil {
+ return nil, err
+ }
+
+ jobReq := &proto.JobRequest{}
+ if err := encryption.DecryptMessage(serverPubKey, c.key, encryptedMsg.Body, jobReq); err != nil {
+ log.Warnf("failed to decrypt job request: %v", err)
+ return nil, err
+ }
+
+ return jobReq, nil
+}
+
+// processJobRequest executes the handler and ensures a valid response
+func (c *GrpcClient) processJobRequest(
+ ctx context.Context,
+ jobReq *proto.JobRequest,
+ msgHandler func(msg *proto.JobRequest) *proto.JobResponse,
+) *proto.JobResponse {
+ jobResp := msgHandler(jobReq)
+ if jobResp == nil {
+ jobResp = &proto.JobResponse{
+ ID: jobReq.ID,
+ Status: proto.JobStatus_failed,
+ Reason: []byte("handler returned nil response"),
+ }
+ log.Warnf("job handler returned nil for job %s", string(jobReq.ID))
+ }
+ return jobResp
+}
+
+// sendJobResponse encrypts and sends a job response
+func (c *GrpcClient) sendJobResponse(
+ ctx context.Context,
+ stream proto.ManagementService_JobClient,
+ serverPubKey wgtypes.Key,
+ resp *proto.JobResponse,
+) error {
+ encResp, err := encryption.EncryptMessage(serverPubKey, c.key, resp)
+ if err != nil {
+ log.Errorf("failed to encrypt job response for job %s: %v", string(resp.ID), err)
+ return err
+ }
+
+ if err := stream.Send(&proto.EncryptedMessage{
+ WgPubKey: c.key.PublicKey().String(),
+ Body: encResp,
+ }); err != nil {
+ log.Errorf("failed to send job response for job %s: %v", string(resp.ID), err)
+ return err
+ }
+
+ log.Infof("job response sent for job %s (status: %s)", string(resp.ID), resp.Status.String())
+ return nil
+}
+
+func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error {
+ ctx, cancelStream := context.WithCancel(ctx)
+ defer cancelStream()
+
+ stream, err := c.connectToSyncStream(ctx, serverPubKey, sysInfo)
if err != nil {
log.Debugf("failed to open Management Service stream: %s", err)
if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied {
@@ -166,20 +326,22 @@ func (c *GrpcClient) handleStream(ctx context.Context, serverPubKey wgtypes.Key,
c.notifyConnected()
// blocking until error
- err = c.receiveEvents(stream, serverPubKey, msgHandler)
- // we need this reset because after a successful connection and a consequent error, backoff lib doesn't
- // reset times and next try will start with a long delay
- backOff.Reset()
+ err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler)
if err != nil {
c.notifyDisconnected(err)
- s, _ := gstatus.FromError(err)
- switch s.Code() {
- case codes.PermissionDenied:
- return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer
- case codes.Canceled:
- log.Debugf("management connection context has been canceled, this usually indicates shutdown")
- return nil
- default:
+ if s, ok := gstatus.FromError(err); ok {
+ switch s.Code() {
+ case codes.PermissionDenied:
+ return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer
+ case codes.Canceled:
+ log.Debugf("management connection context has been canceled, this usually indicates shutdown")
+ return nil
+ default:
+ log.Warnf("disconnected from the Management service but will retry silently. Reason: %v", err)
+ return err
+ }
+ } else {
+ // non-gRPC error
log.Warnf("disconnected from the Management service but will retry silently. Reason: %v", err)
return err
}
@@ -198,7 +360,7 @@ func (c *GrpcClient) GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, err
ctx, cancelStream := context.WithCancel(c.ctx)
defer cancelStream()
- stream, err := c.connectToStream(ctx, *serverPubKey, sysInfo)
+ stream, err := c.connectToSyncStream(ctx, *serverPubKey, sysInfo)
if err != nil {
log.Debugf("failed to open Management Service stream: %s", err)
return nil, err
@@ -231,7 +393,7 @@ func (c *GrpcClient) GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, err
return decryptedResp.GetNetworkMap(), nil
}
-func (c *GrpcClient) connectToStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info) (proto.ManagementService_SyncClient, error) {
+func (c *GrpcClient) connectToSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info) (proto.ManagementService_SyncClient, error) {
req := &proto.SyncRequest{Meta: infoToMetaData(sysInfo)}
myPrivateKey := c.key
@@ -250,7 +412,7 @@ func (c *GrpcClient) connectToStream(ctx context.Context, serverPubKey wgtypes.K
return sync, nil
}
-func (c *GrpcClient) receiveEvents(stream proto.ManagementService_SyncClient, serverPubKey wgtypes.Key, msgHandler func(msg *proto.SyncResponse) error) error {
+func (c *GrpcClient) receiveUpdatesEvents(stream proto.ManagementService_SyncClient, serverPubKey wgtypes.Key, msgHandler func(msg *proto.SyncResponse) error) error {
for {
update, err := stream.Recv()
if err == io.EOF {
diff --git a/shared/management/client/mock.go b/shared/management/client/mock.go
index 29006c9c3..ac96f7b36 100644
--- a/shared/management/client/mock.go
+++ b/shared/management/client/mock.go
@@ -20,6 +20,7 @@ type MockClient struct {
GetPKCEAuthorizationFlowFunc func(serverKey wgtypes.Key) (*proto.PKCEAuthorizationFlow, error)
SyncMetaFunc func(sysInfo *system.Info) error
LogoutFunc func() error
+ JobFunc func(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error
}
func (m *MockClient) IsHealthy() bool {
@@ -40,6 +41,13 @@ func (m *MockClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler
return m.SyncFunc(ctx, sysInfo, msgHandler)
}
+func (m *MockClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error {
+ if m.JobFunc == nil {
+ return nil
+ }
+ return m.JobFunc(ctx, msgHandler)
+}
+
func (m *MockClient) GetServerPublicKey() (*wgtypes.Key, error) {
if m.GetServerPublicKeyFunc == nil {
return nil, nil
diff --git a/shared/management/client/rest/client.go b/shared/management/client/rest/client.go
index 77c960435..ad8328093 100644
--- a/shared/management/client/rest/client.go
+++ b/shared/management/client/rest/client.go
@@ -59,9 +59,13 @@ type Client struct {
Routes *RoutesAPI
// DNS NetBird DNS APIs
- // see more: https://docs.netbird.io/api/resources/routes
+ // see more: https://docs.netbird.io/api/resources/dns
DNS *DNSAPI
+ // DNSZones NetBird DNS Zones APIs
+ // see more: https://docs.netbird.io/api/resources/dns-zones
+ DNSZones *DNSZonesAPI
+
// GeoLocation NetBird Geo Location APIs
// see more: https://docs.netbird.io/api/resources/geo-locations
GeoLocation *GeoLocationAPI
@@ -113,6 +117,7 @@ func (c *Client) initialize() {
c.Networks = &NetworksAPI{c}
c.Routes = &RoutesAPI{c}
c.DNS = &DNSAPI{c}
+ c.DNSZones = &DNSZonesAPI{c}
c.GeoLocation = &GeoLocationAPI{c}
c.Events = &EventsAPI{c}
}
diff --git a/shared/management/client/rest/dns_zones.go b/shared/management/client/rest/dns_zones.go
new file mode 100644
index 000000000..6ee7d336e
--- /dev/null
+++ b/shared/management/client/rest/dns_zones.go
@@ -0,0 +1,170 @@
+package rest
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+
+ "github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+// DNSZonesAPI APIs for DNS Zones Management, do not use directly
+type DNSZonesAPI struct {
+ c *Client
+}
+
+// ListZones list all DNS zones
+// See more: https://docs.netbird.io/api/resources/dns-zones#list-all-dns-zones
+func (a *DNSZonesAPI) ListZones(ctx context.Context) ([]api.Zone, error) {
+ resp, err := a.c.NewRequest(ctx, "GET", "/api/dns/zones", nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[[]api.Zone](resp)
+ return ret, err
+}
+
+// GetZone get DNS zone info
+// See more: https://docs.netbird.io/api/resources/dns-zones#retrieve-a-dns-zone
+func (a *DNSZonesAPI) GetZone(ctx context.Context, zoneID string) (*api.Zone, error) {
+ resp, err := a.c.NewRequest(ctx, "GET", "/api/dns/zones/"+zoneID, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[api.Zone](resp)
+ return &ret, err
+}
+
+// CreateZone create new DNS zone
+// See more: https://docs.netbird.io/api/resources/dns-zones#create-a-dns-zone
+func (a *DNSZonesAPI) CreateZone(ctx context.Context, request api.PostApiDnsZonesJSONRequestBody) (*api.Zone, error) {
+ requestBytes, err := json.Marshal(request)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := a.c.NewRequest(ctx, "POST", "/api/dns/zones", bytes.NewReader(requestBytes), nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[api.Zone](resp)
+ return &ret, err
+}
+
+// UpdateZone update DNS zone info
+// See more: https://docs.netbird.io/api/resources/dns-zones#update-a-dns-zone
+func (a *DNSZonesAPI) UpdateZone(ctx context.Context, zoneID string, request api.PutApiDnsZonesZoneIdJSONRequestBody) (*api.Zone, error) {
+ requestBytes, err := json.Marshal(request)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := a.c.NewRequest(ctx, "PUT", "/api/dns/zones/"+zoneID, bytes.NewReader(requestBytes), nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[api.Zone](resp)
+ return &ret, err
+}
+
+// DeleteZone delete DNS zone
+// See more: https://docs.netbird.io/api/resources/dns-zones#delete-a-dns-zone
+func (a *DNSZonesAPI) DeleteZone(ctx context.Context, zoneID string) error {
+ resp, err := a.c.NewRequest(ctx, "DELETE", "/api/dns/zones/"+zoneID, nil, nil)
+ if err != nil {
+ return err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+
+ return nil
+}
+
+// ListRecords list all DNS records in a zone
+// See more: https://docs.netbird.io/api/resources/dns-zones#list-all-dns-records
+func (a *DNSZonesAPI) ListRecords(ctx context.Context, zoneID string) ([]api.DNSRecord, error) {
+ resp, err := a.c.NewRequest(ctx, "GET", "/api/dns/zones/"+zoneID+"/records", nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[[]api.DNSRecord](resp)
+ return ret, err
+}
+
+// GetRecord get DNS record info
+// See more: https://docs.netbird.io/api/resources/dns-zones#retrieve-a-dns-record
+func (a *DNSZonesAPI) GetRecord(ctx context.Context, zoneID, recordID string) (*api.DNSRecord, error) {
+ resp, err := a.c.NewRequest(ctx, "GET", "/api/dns/zones/"+zoneID+"/records/"+recordID, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[api.DNSRecord](resp)
+ return &ret, err
+}
+
+// CreateRecord create new DNS record in a zone
+// See more: https://docs.netbird.io/api/resources/dns-zones#create-a-dns-record
+func (a *DNSZonesAPI) CreateRecord(ctx context.Context, zoneID string, request api.PostApiDnsZonesZoneIdRecordsJSONRequestBody) (*api.DNSRecord, error) {
+ requestBytes, err := json.Marshal(request)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := a.c.NewRequest(ctx, "POST", "/api/dns/zones/"+zoneID+"/records", bytes.NewReader(requestBytes), nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[api.DNSRecord](resp)
+ return &ret, err
+}
+
+// UpdateRecord update DNS record info
+// See more: https://docs.netbird.io/api/resources/dns-zones#update-a-dns-record
+func (a *DNSZonesAPI) UpdateRecord(ctx context.Context, zoneID, recordID string, request api.PutApiDnsZonesZoneIdRecordsRecordIdJSONRequestBody) (*api.DNSRecord, error) {
+ requestBytes, err := json.Marshal(request)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := a.c.NewRequest(ctx, "PUT", "/api/dns/zones/"+zoneID+"/records/"+recordID, bytes.NewReader(requestBytes), nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[api.DNSRecord](resp)
+ return &ret, err
+}
+
+// DeleteRecord delete DNS record
+// See more: https://docs.netbird.io/api/resources/dns-zones#delete-a-dns-record
+func (a *DNSZonesAPI) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
+ resp, err := a.c.NewRequest(ctx, "DELETE", "/api/dns/zones/"+zoneID+"/records/"+recordID, nil, nil)
+ if err != nil {
+ return err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+
+ return nil
+}
diff --git a/shared/management/client/rest/dns_zones_test.go b/shared/management/client/rest/dns_zones_test.go
new file mode 100644
index 000000000..c04a3ea57
--- /dev/null
+++ b/shared/management/client/rest/dns_zones_test.go
@@ -0,0 +1,460 @@
+//go:build integration
+// +build integration
+
+package rest_test
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+
+ "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 (
+ testZone = api.Zone{
+ Id: "zone123",
+ Name: "test-zone",
+ Domain: "example.com",
+ Enabled: true,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ }
+
+ testDNSRecord = api.DNSRecord{
+ Id: "record123",
+ Name: "www",
+ Content: "192.168.1.1",
+ Type: api.DNSRecordTypeA,
+ Ttl: 300,
+ }
+)
+
+func TestDNSZone_List_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "GET", r.Method)
+ retBytes, _ := json.Marshal([]api.Zone{testZone})
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.ListZones(context.Background())
+ require.NoError(t, err)
+ assert.Len(t, ret, 1)
+ assert.Equal(t, testZone, ret[0])
+ })
+}
+
+func TestDNSZone_List_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones", 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.DNSZones.ListZones(context.Background())
+ assert.Error(t, err)
+ assert.Equal(t, "No", err.Error())
+ assert.Empty(t, ret)
+ })
+}
+
+func TestDNSZone_Get_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "GET", r.Method)
+ retBytes, _ := json.Marshal(testZone)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.GetZone(context.Background(), "zone123")
+ require.NoError(t, err)
+ assert.Equal(t, testZone, *ret)
+ })
+}
+
+func TestDNSZone_Get_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123", 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.DNSZones.GetZone(context.Background(), "zone123")
+ assert.Error(t, err)
+ assert.Equal(t, "Not found", err.Error())
+ assert.Empty(t, ret)
+ })
+}
+
+func TestDNSZone_Create_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones", 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.PostApiDnsZonesJSONRequestBody
+ err = json.Unmarshal(reqBytes, &req)
+ require.NoError(t, err)
+ assert.Equal(t, "test-zone", req.Name)
+ assert.Equal(t, "example.com", req.Domain)
+ retBytes, _ := json.Marshal(testZone)
+ _, err = w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ enabled := true
+ ret, err := c.DNSZones.CreateZone(context.Background(), api.PostApiDnsZonesJSONRequestBody{
+ Name: "test-zone",
+ Domain: "example.com",
+ Enabled: &enabled,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, testZone, *ret)
+ })
+}
+
+func TestDNSZone_Create_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Invalid request", Code: 400})
+ w.WriteHeader(400)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.CreateZone(context.Background(), api.PostApiDnsZonesJSONRequestBody{
+ Name: "test-zone",
+ Domain: "example.com",
+ })
+ assert.Error(t, err)
+ assert.Equal(t, "Invalid request", err.Error())
+ assert.Nil(t, ret)
+ })
+}
+
+func TestDNSZone_Update_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123", 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.PutApiDnsZonesZoneIdJSONRequestBody
+ err = json.Unmarshal(reqBytes, &req)
+ require.NoError(t, err)
+ assert.Equal(t, "updated-zone", req.Name)
+ retBytes, _ := json.Marshal(testZone)
+ _, err = w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ enabled := true
+ ret, err := c.DNSZones.UpdateZone(context.Background(), "zone123", api.PutApiDnsZonesZoneIdJSONRequestBody{
+ Name: "updated-zone",
+ Domain: "example.com",
+ Enabled: &enabled,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"group1"},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, testZone, *ret)
+ })
+}
+
+func TestDNSZone_Update_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Invalid request", Code: 400})
+ w.WriteHeader(400)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.UpdateZone(context.Background(), "zone123", api.PutApiDnsZonesZoneIdJSONRequestBody{
+ Name: "updated-zone",
+ Domain: "example.com",
+ })
+ assert.Error(t, err)
+ assert.Equal(t, "Invalid request", err.Error())
+ assert.Nil(t, ret)
+ })
+}
+
+func TestDNSZone_Delete_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "DELETE", r.Method)
+ w.WriteHeader(200)
+ })
+ err := c.DNSZones.DeleteZone(context.Background(), "zone123")
+ require.NoError(t, err)
+ })
+}
+
+func TestDNSZone_Delete_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123", 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.DNSZones.DeleteZone(context.Background(), "zone123")
+ assert.Error(t, err)
+ assert.Equal(t, "Not found", err.Error())
+ })
+}
+
+func TestDNSRecord_List_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "GET", r.Method)
+ retBytes, _ := json.Marshal([]api.DNSRecord{testDNSRecord})
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.ListRecords(context.Background(), "zone123")
+ require.NoError(t, err)
+ assert.Len(t, ret, 1)
+ assert.Equal(t, testDNSRecord, ret[0])
+ })
+}
+
+func TestDNSRecord_List_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Zone not found", Code: 404})
+ w.WriteHeader(404)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.ListRecords(context.Background(), "zone123")
+ assert.Error(t, err)
+ assert.Equal(t, "Zone not found", err.Error())
+ assert.Empty(t, ret)
+ })
+}
+
+func TestDNSRecord_Get_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records/record123", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "GET", r.Method)
+ retBytes, _ := json.Marshal(testDNSRecord)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.GetRecord(context.Background(), "zone123", "record123")
+ require.NoError(t, err)
+ assert.Equal(t, testDNSRecord, *ret)
+ })
+}
+
+func TestDNSRecord_Get_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records/record123", 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.DNSZones.GetRecord(context.Background(), "zone123", "record123")
+ assert.Error(t, err)
+ assert.Equal(t, "Not found", err.Error())
+ assert.Empty(t, ret)
+ })
+}
+
+func TestDNSRecord_Create_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records", 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.PostApiDnsZonesZoneIdRecordsJSONRequestBody
+ err = json.Unmarshal(reqBytes, &req)
+ require.NoError(t, err)
+ assert.Equal(t, "www", req.Name)
+ assert.Equal(t, "192.168.1.1", req.Content)
+ assert.Equal(t, api.DNSRecordTypeA, req.Type)
+ retBytes, _ := json.Marshal(testDNSRecord)
+ _, err = w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.CreateRecord(context.Background(), "zone123", api.PostApiDnsZonesZoneIdRecordsJSONRequestBody{
+ Name: "www",
+ Content: "192.168.1.1",
+ Type: api.DNSRecordTypeA,
+ Ttl: 300,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, testDNSRecord, *ret)
+ })
+}
+
+func TestDNSRecord_Create_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Invalid record", Code: 400})
+ w.WriteHeader(400)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.CreateRecord(context.Background(), "zone123", api.PostApiDnsZonesZoneIdRecordsJSONRequestBody{
+ Name: "www",
+ Content: "192.168.1.1",
+ Type: api.DNSRecordTypeA,
+ Ttl: 300,
+ })
+ assert.Error(t, err)
+ assert.Equal(t, "Invalid record", err.Error())
+ assert.Nil(t, ret)
+ })
+}
+
+func TestDNSRecord_Update_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records/record123", 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.PutApiDnsZonesZoneIdRecordsRecordIdJSONRequestBody
+ err = json.Unmarshal(reqBytes, &req)
+ require.NoError(t, err)
+ assert.Equal(t, "api", req.Name)
+ assert.Equal(t, "192.168.1.2", req.Content)
+ retBytes, _ := json.Marshal(testDNSRecord)
+ _, err = w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.UpdateRecord(context.Background(), "zone123", "record123", api.PutApiDnsZonesZoneIdRecordsRecordIdJSONRequestBody{
+ Name: "api",
+ Content: "192.168.1.2",
+ Type: api.DNSRecordTypeA,
+ Ttl: 300,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, testDNSRecord, *ret)
+ })
+}
+
+func TestDNSRecord_Update_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records/record123", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Invalid record", Code: 400})
+ w.WriteHeader(400)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.DNSZones.UpdateRecord(context.Background(), "zone123", "record123", api.PutApiDnsZonesZoneIdRecordsRecordIdJSONRequestBody{
+ Name: "api",
+ Content: "192.168.1.2",
+ Type: api.DNSRecordTypeA,
+ Ttl: 300,
+ })
+ assert.Error(t, err)
+ assert.Equal(t, "Invalid record", err.Error())
+ assert.Nil(t, ret)
+ })
+}
+
+func TestDNSRecord_Delete_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records/record123", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "DELETE", r.Method)
+ w.WriteHeader(200)
+ })
+ err := c.DNSZones.DeleteRecord(context.Background(), "zone123", "record123")
+ require.NoError(t, err)
+ })
+}
+
+func TestDNSRecord_Delete_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/dns/zones/zone123/records/record123", 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.DNSZones.DeleteRecord(context.Background(), "zone123", "record123")
+ assert.Error(t, err)
+ assert.Equal(t, "Not found", err.Error())
+ })
+}
+
+func TestDNSZones_Integration(t *testing.T) {
+ enabled := true
+ zoneReq := api.ZoneRequest{
+ Name: "test-zone",
+ Domain: "test.example.com",
+ Enabled: &enabled,
+ EnableSearchDomain: false,
+ DistributionGroups: []string{"cs1tnh0hhcjnqoiuebeg"},
+ }
+
+ recordReq := api.DNSRecordRequest{
+ Name: "api.test.example.com",
+ Content: "192.168.1.100",
+ Type: api.DNSRecordTypeA,
+ Ttl: 300,
+ }
+
+ withBlackBoxServer(t, func(c *rest.Client) {
+ zone, err := c.DNSZones.CreateZone(context.Background(), zoneReq)
+ require.NoError(t, err)
+ assert.Equal(t, "test-zone", zone.Name)
+ assert.Equal(t, "test.example.com", zone.Domain)
+
+ zones, err := c.DNSZones.ListZones(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, *zone, zones[0])
+
+ getZone, err := c.DNSZones.GetZone(context.Background(), zone.Id)
+ require.NoError(t, err)
+ assert.Equal(t, *zone, *getZone)
+
+ zoneReq.Name = "updated-zone"
+ updatedZone, err := c.DNSZones.UpdateZone(context.Background(), zone.Id, zoneReq)
+ require.NoError(t, err)
+ assert.Equal(t, "updated-zone", updatedZone.Name)
+
+ record, err := c.DNSZones.CreateRecord(context.Background(), zone.Id, recordReq)
+ require.NoError(t, err)
+ assert.Equal(t, "api.test.example.com", record.Name)
+ assert.Equal(t, "192.168.1.100", record.Content)
+
+ records, err := c.DNSZones.ListRecords(context.Background(), zone.Id)
+ require.NoError(t, err)
+ assert.Equal(t, *record, records[0])
+
+ getRecord, err := c.DNSZones.GetRecord(context.Background(), zone.Id, record.Id)
+ require.NoError(t, err)
+ assert.Equal(t, *record, *getRecord)
+
+ recordReq.Name = "www.test.example.com"
+ updatedRecord, err := c.DNSZones.UpdateRecord(context.Background(), zone.Id, record.Id, recordReq)
+ require.NoError(t, err)
+ assert.Equal(t, "www.test.example.com", updatedRecord.Name)
+
+ err = c.DNSZones.DeleteRecord(context.Background(), zone.Id, record.Id)
+ require.NoError(t, err)
+
+ records, err = c.DNSZones.ListRecords(context.Background(), zone.Id)
+ require.NoError(t, err)
+ assert.Len(t, records, 0)
+
+ err = c.DNSZones.DeleteZone(context.Background(), zone.Id)
+ require.NoError(t, err)
+
+ zones, err = c.DNSZones.ListZones(context.Background())
+ require.NoError(t, err)
+ assert.Len(t, zones, 0)
+ })
+}
diff --git a/shared/management/http/api/generate.sh b/shared/management/http/api/generate.sh
index 2f24fd903..3770ea90f 100755
--- a/shared/management/http/api/generate.sh
+++ b/shared/management/http/api/generate.sh
@@ -11,6 +11,6 @@ fi
old_pwd=$(pwd)
script_path=$(dirname $(realpath "$0"))
cd "$script_path"
-go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@4a1477f6a8ba6ca8115cc23bb2fb67f0b9fca18e
+go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
oapi-codegen --config cfg.yaml openapi.yml
-cd "$old_pwd"
\ No newline at end of file
+cd "$old_pwd"
diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml
index 64086e7ec..f1ff98b16 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -25,6 +25,8 @@ tags:
description: Interact with and view information about routes.
- name: DNS
description: Interact with and view information about DNS configuration.
+ - name: DNS Zones
+ description: Interact with and view information about custom DNS zones.
- name: Events
description: View information about the account and network events.
- name: Accounts
@@ -36,8 +38,142 @@ tags:
description: Interact with and view information about identity providers.
- name: Instance
description: Instance setup and status endpoints for initial configuration.
+ - name: Jobs
+ description: Interact with and view information about remote jobs.
+ x-experimental: true
+
components:
schemas:
+ PasswordChangeRequest:
+ type: object
+ properties:
+ old_password:
+ description: The current password
+ type: string
+ example: "currentPassword123"
+ new_password:
+ description: The new password to set
+ type: string
+ example: "newSecurePassword456"
+ required:
+ - old_password
+ - new_password
+ WorkloadType:
+ type: string
+ description: |
+ Identifies the type of workload the job will execute.
+ Currently only `"bundle"` is supported.
+ enum:
+ - bundle
+ example: "bundle"
+ BundleParameters:
+ type: object
+ description: These parameters control what gets included in the bundle and how it is processed.
+ properties:
+ bundle_for:
+ type: boolean
+ description: Whether to generate a bundle for the given timeframe.
+ example: true
+ bundle_for_time:
+ type: integer
+ minimum: 1
+ maximum: 5
+ description: Time period in minutes for which to generate the bundle.
+ example: 2
+ log_file_count:
+ type: integer
+ minimum: 1
+ maximum: 1000
+ description: Maximum number of log files to include in the bundle.
+ example: 100
+ anonymize:
+ type: boolean
+ description: Whether sensitive data should be anonymized in the bundle.
+ example: false
+ required:
+ - bundle_for
+ - bundle_for_time
+ - log_file_count
+ - anonymize
+ BundleResult:
+ type: object
+ properties:
+ upload_key:
+ type: string
+ example: "upload_key_123"
+ nullable: true
+ BundleWorkloadRequest:
+ type: object
+ properties:
+ type:
+ $ref: '#/components/schemas/WorkloadType'
+ parameters:
+ $ref: '#/components/schemas/BundleParameters'
+ required:
+ - type
+ - parameters
+ BundleWorkloadResponse:
+ type: object
+ properties:
+ type:
+ $ref: '#/components/schemas/WorkloadType'
+ parameters:
+ $ref: '#/components/schemas/BundleParameters'
+ result:
+ $ref: '#/components/schemas/BundleResult'
+ required:
+ - type
+ - parameters
+ - result
+ WorkloadRequest:
+ oneOf:
+ - $ref: '#/components/schemas/BundleWorkloadRequest'
+ discriminator:
+ propertyName: type
+ mapping:
+ bundle: '#/components/schemas/BundleWorkloadRequest'
+ WorkloadResponse:
+ oneOf:
+ - $ref: '#/components/schemas/BundleWorkloadResponse'
+ discriminator:
+ propertyName: type
+ mapping:
+ bundle: '#/components/schemas/BundleWorkloadResponse'
+ JobRequest:
+ type: object
+ properties:
+ workload:
+ $ref: '#/components/schemas/WorkloadRequest'
+ required:
+ - workload
+ JobResponse:
+ type: object
+ properties:
+ id:
+ type: string
+ created_at:
+ type: string
+ format: date-time
+ completed_at:
+ type: string
+ format: date-time
+ nullable: true
+ triggered_by:
+ type: string
+ status:
+ type: string
+ enum: [pending, succeeded, failed]
+ failed_reason:
+ type: string
+ nullable: true
+ workload:
+ $ref: '#/components/schemas/WorkloadResponse'
+ required:
+ - id
+ - created_at
+ - status
+ - triggered_by
+ - workload
Account:
type: object
properties:
@@ -1779,6 +1915,100 @@ components:
example: ch8i4ug6lnn4g9hqv7m0
required:
- disabled_management_groups
+ ZoneRequest:
+ type: object
+ properties:
+ name:
+ description: Zone name identifier
+ type: string
+ maxLength: 255
+ minLength: 1
+ example: Office Zone
+ domain:
+ description: Zone domain (FQDN)
+ type: string
+ example: example.com
+ enabled:
+ description: Zone status
+ type: boolean
+ default: true
+ enable_search_domain:
+ description: Enable this zone as a search domain
+ type: boolean
+ example: false
+ distribution_groups:
+ description: Group IDs that defines groups of peers that will resolve this zone
+ type: array
+ items:
+ type: string
+ example: ch8i4ug6lnn4g9hqv7m0
+ required:
+ - name
+ - domain
+ - enable_search_domain
+ - distribution_groups
+ Zone:
+ allOf:
+ - type: object
+ properties:
+ id:
+ description: Zone ID
+ type: string
+ example: ch8i4ug6lnn4g9hqv7m0
+ records:
+ description: DNS records associated with this zone
+ type: array
+ items:
+ $ref: '#/components/schemas/DNSRecord'
+ required:
+ - id
+ - enabled
+ - records
+ - $ref: '#/components/schemas/ZoneRequest'
+ DNSRecordType:
+ type: string
+ description: DNS record type
+ enum:
+ - A
+ - AAAA
+ - CNAME
+ example: A
+ DNSRecordRequest:
+ type: object
+ properties:
+ name:
+ description: FQDN for the DNS record. Must be a subdomain within or match the zone's domain.
+ type: string
+ example: www.example.com
+ type:
+ $ref: '#/components/schemas/DNSRecordType'
+ content:
+ description: DNS record content (IP address for A/AAAA, domain for CNAME)
+ type: string
+ maxLength: 255
+ minLength: 1
+ example: 192.168.1.1
+ ttl:
+ description: Time to live in seconds
+ type: integer
+ minimum: 0
+ example: 300
+ required:
+ - name
+ - type
+ - content
+ - ttl
+ DNSRecord:
+ allOf:
+ - type: object
+ properties:
+ id:
+ description: DNS record ID
+ type: string
+ example: ch8i4ug6lnn4g9hqv7m0
+ required:
+ - id
+ - $ref: '#/components/schemas/DNSRecordRequest'
Event:
type: object
properties:
@@ -1798,18 +2028,51 @@ components:
activity_code:
description: The string code of the activity that occurred during the event
type: string
- enum: [ "user.peer.delete", "user.join", "user.invite", "user.peer.add", "user.group.add", "user.group.delete",
- "user.role.update", "user.block", "user.unblock", "user.peer.login",
- "setupkey.peer.add", "setupkey.add", "setupkey.update", "setupkey.revoke", "setupkey.overuse",
- "setupkey.group.delete", "setupkey.group.add",
- "rule.add", "rule.delete", "rule.update",
- "policy.add", "policy.delete", "policy.update",
- "group.add", "group.update", "dns.setting.disabled.management.group.add", "dns.setting.disabled.management.group.delete",
- "account.create", "account.setting.peer.login.expiration.update", "account.setting.peer.login.expiration.disable", "account.setting.peer.login.expiration.enable",
- "route.add", "route.delete", "route.update",
- "nameserver.group.add", "nameserver.group.delete", "nameserver.group.update",
- "peer.ssh.disable", "peer.ssh.enable", "peer.rename", "peer.login.expiration.disable", "peer.login.expiration.enable", "peer.login.expire",
- "service.user.create", "personal.access.token.create", "service.user.delete", "personal.access.token.delete" ]
+ enum: [
+ "peer.user.add", "peer.setupkey.add", "user.join", "user.invite", "account.create", "account.delete",
+ "user.peer.delete", "rule.add", "rule.update", "rule.delete",
+ "policy.add", "policy.update", "policy.delete",
+ "setupkey.add", "setupkey.update", "setupkey.revoke", "setupkey.overuse", "setupkey.delete",
+ "group.add", "group.update", "group.delete",
+ "peer.group.add", "peer.group.delete",
+ "user.group.add", "user.group.delete", "user.role.update",
+ "setupkey.group.add", "setupkey.group.delete",
+ "dns.setting.disabled.management.group.add", "dns.setting.disabled.management.group.delete",
+ "route.add", "route.delete", "route.update",
+ "peer.ssh.enable", "peer.ssh.disable", "peer.rename",
+ "peer.login.expiration.enable", "peer.login.expiration.disable",
+ "nameserver.group.add", "nameserver.group.delete", "nameserver.group.update",
+ "account.setting.peer.login.expiration.update", "account.setting.peer.login.expiration.enable", "account.setting.peer.login.expiration.disable",
+ "personal.access.token.create", "personal.access.token.delete",
+ "service.user.create", "service.user.delete",
+ "user.block", "user.unblock", "user.delete",
+ "user.peer.login", "peer.login.expire",
+ "dashboard.login",
+ "integration.create", "integration.update", "integration.delete",
+ "account.setting.peer.approval.enable", "account.setting.peer.approval.disable",
+ "peer.approve", "peer.approval.revoke",
+ "transferred.owner.role",
+ "posture.check.create", "posture.check.update", "posture.check.delete",
+ "peer.inactivity.expiration.enable", "peer.inactivity.expiration.disable",
+ "account.peer.inactivity.expiration.enable", "account.peer.inactivity.expiration.disable", "account.peer.inactivity.expiration.update",
+ "account.setting.group.propagation.enable", "account.setting.group.propagation.disable",
+ "account.setting.routing.peer.dns.resolution.enable", "account.setting.routing.peer.dns.resolution.disable",
+ "network.create", "network.update", "network.delete",
+ "network.resource.create", "network.resource.update", "network.resource.delete",
+ "network.router.create", "network.router.update", "network.router.delete",
+ "resource.group.add", "resource.group.delete",
+ "account.dns.domain.update",
+ "account.setting.lazy.connection.enable", "account.setting.lazy.connection.disable",
+ "account.network.range.update",
+ "peer.ip.update",
+ "user.approve", "user.reject", "user.create",
+ "account.settings.auto.version.update",
+ "identityprovider.create", "identityprovider.update", "identityprovider.delete",
+ "dns.zone.create", "dns.zone.update", "dns.zone.delete",
+ "dns.zone.record.create", "dns.zone.record.update", "dns.zone.record.delete",
+ "peer.job.create",
+ "user.password.change"
+ ]
example: route.add
initiator_id:
description: The ID of the initiator of the event. E.g., an ID of a user that triggered the event.
@@ -2458,6 +2721,110 @@ paths:
content: { }
'500':
"$ref": "#/components/responses/internal_error"
+ /api/peers/{peerId}/jobs:
+ get:
+ summary: List Jobs
+ description: Retrieve all jobs for a given peer
+ tags: [ Jobs ]
+ security:
+ - BearerAuth: []
+ - TokenAuth: []
+ parameters:
+ - in: path
+ name: peerId
+ description: The unique identifier of a peer
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: List of jobs
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/JobResponse'
+ '400':
+ $ref: '#/components/responses/bad_request'
+ '401':
+ $ref: '#/components/responses/requires_authentication'
+ '403':
+ $ref: '#/components/responses/forbidden'
+ '500':
+ $ref: '#/components/responses/internal_error'
+ post:
+ summary: Create Job
+ description: Create a new job for a given peer
+ tags: [ Jobs ]
+ security:
+ - BearerAuth: []
+ - TokenAuth: []
+ parameters:
+ - in: path
+ name: peerId
+ description: The unique identifier of a peer
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: Create job request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/JobRequest'
+ required: true
+ responses:
+ '201':
+ description: Job created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/JobResponse'
+ '400':
+ "$ref": "#/components/responses/bad_request"
+ '401':
+ "$ref": "#/components/responses/requires_authentication"
+ '403':
+ "$ref": "#/components/responses/forbidden"
+ '500':
+ "$ref": "#/components/responses/internal_error"
+ /api/peers/{peerId}/jobs/{jobId}:
+ get:
+ summary: Get Job
+ description: Retrieve details of a specific job
+ tags: [ Jobs ]
+ security:
+ - BearerAuth: []
+ - TokenAuth: []
+ parameters:
+ - in: path
+ name: peerId
+ required: true
+ description: The unique identifier of a peer
+ schema:
+ type: string
+ - in: path
+ name: jobId
+ required: true
+ description: The unique identifier of a job
+ schema:
+ type: string
+ responses:
+ '200':
+ description: A Job object
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/JobResponse'
+ '400':
+ "$ref": "#/components/responses/bad_request"
+ '401':
+ "$ref": "#/components/responses/requires_authentication"
+ '403':
+ "$ref": "#/components/responses/forbidden"
+ '500':
+ "$ref": "#/components/responses/internal_error"
/api/accounts:
get:
summary: List all Accounts
@@ -2885,6 +3252,43 @@ paths:
"$ref": "#/components/responses/forbidden"
'500':
"$ref": "#/components/responses/internal_error"
+ /api/users/{userId}/password:
+ put:
+ summary: Change user password
+ description: Change the password for a user. Only available when embedded IdP is enabled. Users can only change their own password.
+ tags: [ Users ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ parameters:
+ - in: path
+ name: userId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a user
+ requestBody:
+ description: Password change request
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PasswordChangeRequest'
+ responses:
+ '200':
+ description: Password changed successfully
+ content: {}
+ '400':
+ "$ref": "#/components/responses/bad_request"
+ '401':
+ "$ref": "#/components/responses/requires_authentication"
+ '403':
+ "$ref": "#/components/responses/forbidden"
+ '412':
+ description: Precondition failed - embedded IdP is not enabled
+ content: { }
+ '500':
+ "$ref": "#/components/responses/internal_error"
/api/users/current:
get:
summary: Retrieve current user
@@ -4733,6 +5137,347 @@ paths:
"$ref": "#/components/responses/forbidden"
'500':
"$ref": "#/components/responses/internal_error"
+ /api/dns/zones:
+ get:
+ summary: List all DNS Zones
+ description: Returns a list of all custom DNS zones
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ responses:
+ '200':
+ description: A JSON Array of DNS Zones
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Zone'
+ '401':
+ "$ref": "#/components/responses/requires_authentication"
+ '403':
+ "$ref": "#/components/responses/forbidden"
+ '500':
+ "$ref": "#/components/responses/internal_error"
+ post:
+ summary: Create a DNS Zone
+ description: Creates a new custom DNS zone
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ requestBody:
+ description: A DNS zone object
+ content:
+ 'application/json':
+ schema:
+ $ref: '#/components/schemas/ZoneRequest'
+ responses:
+ '200':
+ description: A JSON Object of the created DNS Zone
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Zone'
+ '400':
+ "$ref": "#/components/responses/bad_request"
+ '401':
+ "$ref": "#/components/responses/requires_authentication"
+ '403':
+ "$ref": "#/components/responses/forbidden"
+ '500':
+ "$ref": "#/components/responses/internal_error"
+ /api/dns/zones/{zoneId}:
+ get:
+ summary: Retrieve a DNS Zone
+ description: Returns information about a specific DNS zone
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ parameters:
+ - in: path
+ name: zoneId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a zone
+ example: chacbco6lnnbn6cg5s91
+ responses:
+ '200':
+ description: A JSON Object of a DNS Zone
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Zone'
+ '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:
+ summary: Update a DNS Zone
+ description: Updates a custom DNS zone
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ parameters:
+ - in: path
+ name: zoneId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a zone
+ example: chacbco6lnnbn6cg5s91
+ requestBody:
+ description: A DNS zone object
+ content:
+ 'application/json':
+ schema:
+ $ref: '#/components/schemas/ZoneRequest'
+ responses:
+ '200':
+ description: A JSON Object of the updated DNS Zone
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Zone'
+ '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:
+ summary: Delete a DNS Zone
+ description: Deletes a custom DNS zone
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ parameters:
+ - in: path
+ name: zoneId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a zone
+ example: chacbco6lnnbn6cg5s91
+ responses:
+ '200':
+ description: Zone deletion successful
+ '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/dns/zones/{zoneId}/records:
+ get:
+ summary: List all DNS Records
+ description: Returns a list of all DNS records in a zone
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ parameters:
+ - in: path
+ name: zoneId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a zone
+ example: chacbco6lnnbn6cg5s91
+ responses:
+ '200':
+ description: A JSON Array of DNS Records
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/DNSRecord'
+ '401':
+ "$ref": "#/components/responses/requires_authentication"
+ '403':
+ "$ref": "#/components/responses/forbidden"
+ '404':
+ "$ref": "#/components/responses/not_found"
+ '500':
+ "$ref": "#/components/responses/internal_error"
+ post:
+ summary: Create a DNS Record
+ description: Creates a new DNS record in a zone
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ parameters:
+ - in: path
+ name: zoneId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a zone
+ example: chacbco6lnnbn6cg5s91
+ requestBody:
+ description: A DNS record object
+ content:
+ 'application/json':
+ schema:
+ $ref: '#/components/schemas/DNSRecordRequest'
+ responses:
+ '200':
+ description: A JSON Object of the created DNS Record
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecord'
+ '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/dns/zones/{zoneId}/records/{recordId}:
+ get:
+ summary: Retrieve a DNS Record
+ description: Returns information about a specific DNS record
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ parameters:
+ - in: path
+ name: zoneId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a zone
+ example: chacbco6lnnbn6cg5s91
+ - in: path
+ name: recordId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a DNS record
+ example: chacbco6lnnbn6cg5s92
+ responses:
+ '200':
+ description: A JSON Object of a DNS Record
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecord'
+ '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:
+ summary: Update a DNS Record
+ description: Updates a DNS record in a zone
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ parameters:
+ - in: path
+ name: zoneId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a zone
+ example: chacbco6lnnbn6cg5s91
+ - in: path
+ name: recordId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a DNS record
+ example: chacbco6lnnbn6cg5s92
+ requestBody:
+ description: A DNS record object
+ content:
+ 'application/json':
+ schema:
+ $ref: '#/components/schemas/DNSRecordRequest'
+ responses:
+ '200':
+ description: A JSON Object of the updated DNS Record
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecord'
+ '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:
+ summary: Delete a DNS Record
+ description: Deletes a DNS record from a zone
+ tags: [ DNS Zones ]
+ security:
+ - BearerAuth: [ ]
+ - TokenAuth: [ ]
+ parameters:
+ - in: path
+ name: zoneId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a zone
+ example: chacbco6lnnbn6cg5s91
+ - in: path
+ name: recordId
+ required: true
+ schema:
+ type: string
+ description: The unique identifier of a DNS record
+ example: chacbco6lnnbn6cg5s92
+ responses:
+ '200':
+ description: Record deletion successful
+ '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/events/audit:
get:
summary: List all Audit Events
diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go
index ab5a65cb0..848023689 100644
--- a/shared/management/http/api/types.gen.go
+++ b/shared/management/http/api/types.gen.go
@@ -1,10 +1,14 @@
// Package api provides primitives to interact with the openapi HTTP API.
//
-// Code generated by github.com/deepmap/oapi-codegen version v1.11.1-0.20220912230023-4a1477f6a8ba DO NOT EDIT.
+// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT.
package api
import (
+ "encoding/json"
+ "errors"
"time"
+
+ "github.com/oapi-codegen/runtime"
)
const (
@@ -12,55 +16,120 @@ const (
TokenAuthScopes = "TokenAuth.Scopes"
)
+// Defines values for DNSRecordType.
+const (
+ DNSRecordTypeA DNSRecordType = "A"
+ DNSRecordTypeAAAA DNSRecordType = "AAAA"
+ DNSRecordTypeCNAME DNSRecordType = "CNAME"
+)
+
// Defines values for EventActivityCode.
const (
- EventActivityCodeAccountCreate EventActivityCode = "account.create"
- EventActivityCodeAccountSettingPeerLoginExpirationDisable EventActivityCode = "account.setting.peer.login.expiration.disable"
- EventActivityCodeAccountSettingPeerLoginExpirationEnable EventActivityCode = "account.setting.peer.login.expiration.enable"
- EventActivityCodeAccountSettingPeerLoginExpirationUpdate EventActivityCode = "account.setting.peer.login.expiration.update"
- EventActivityCodeDnsSettingDisabledManagementGroupAdd EventActivityCode = "dns.setting.disabled.management.group.add"
- EventActivityCodeDnsSettingDisabledManagementGroupDelete EventActivityCode = "dns.setting.disabled.management.group.delete"
- EventActivityCodeGroupAdd EventActivityCode = "group.add"
- EventActivityCodeGroupUpdate EventActivityCode = "group.update"
- EventActivityCodeNameserverGroupAdd EventActivityCode = "nameserver.group.add"
- EventActivityCodeNameserverGroupDelete EventActivityCode = "nameserver.group.delete"
- EventActivityCodeNameserverGroupUpdate EventActivityCode = "nameserver.group.update"
- EventActivityCodePeerLoginExpirationDisable EventActivityCode = "peer.login.expiration.disable"
- EventActivityCodePeerLoginExpirationEnable EventActivityCode = "peer.login.expiration.enable"
- EventActivityCodePeerLoginExpire EventActivityCode = "peer.login.expire"
- EventActivityCodePeerRename EventActivityCode = "peer.rename"
- EventActivityCodePeerSshDisable EventActivityCode = "peer.ssh.disable"
- EventActivityCodePeerSshEnable EventActivityCode = "peer.ssh.enable"
- EventActivityCodePersonalAccessTokenCreate EventActivityCode = "personal.access.token.create"
- EventActivityCodePersonalAccessTokenDelete EventActivityCode = "personal.access.token.delete"
- EventActivityCodePolicyAdd EventActivityCode = "policy.add"
- EventActivityCodePolicyDelete EventActivityCode = "policy.delete"
- EventActivityCodePolicyUpdate EventActivityCode = "policy.update"
- EventActivityCodeRouteAdd EventActivityCode = "route.add"
- EventActivityCodeRouteDelete EventActivityCode = "route.delete"
- EventActivityCodeRouteUpdate EventActivityCode = "route.update"
- EventActivityCodeRuleAdd EventActivityCode = "rule.add"
- EventActivityCodeRuleDelete EventActivityCode = "rule.delete"
- EventActivityCodeRuleUpdate EventActivityCode = "rule.update"
- EventActivityCodeServiceUserCreate EventActivityCode = "service.user.create"
- EventActivityCodeServiceUserDelete EventActivityCode = "service.user.delete"
- EventActivityCodeSetupkeyAdd EventActivityCode = "setupkey.add"
- EventActivityCodeSetupkeyGroupAdd EventActivityCode = "setupkey.group.add"
- EventActivityCodeSetupkeyGroupDelete EventActivityCode = "setupkey.group.delete"
- EventActivityCodeSetupkeyOveruse EventActivityCode = "setupkey.overuse"
- EventActivityCodeSetupkeyPeerAdd EventActivityCode = "setupkey.peer.add"
- EventActivityCodeSetupkeyRevoke EventActivityCode = "setupkey.revoke"
- EventActivityCodeSetupkeyUpdate EventActivityCode = "setupkey.update"
- EventActivityCodeUserBlock EventActivityCode = "user.block"
- EventActivityCodeUserGroupAdd EventActivityCode = "user.group.add"
- EventActivityCodeUserGroupDelete EventActivityCode = "user.group.delete"
- EventActivityCodeUserInvite EventActivityCode = "user.invite"
- EventActivityCodeUserJoin EventActivityCode = "user.join"
- EventActivityCodeUserPeerAdd EventActivityCode = "user.peer.add"
- EventActivityCodeUserPeerDelete EventActivityCode = "user.peer.delete"
- EventActivityCodeUserPeerLogin EventActivityCode = "user.peer.login"
- EventActivityCodeUserRoleUpdate EventActivityCode = "user.role.update"
- EventActivityCodeUserUnblock EventActivityCode = "user.unblock"
+ EventActivityCodeAccountCreate EventActivityCode = "account.create"
+ EventActivityCodeAccountDelete EventActivityCode = "account.delete"
+ EventActivityCodeAccountDnsDomainUpdate EventActivityCode = "account.dns.domain.update"
+ EventActivityCodeAccountNetworkRangeUpdate EventActivityCode = "account.network.range.update"
+ EventActivityCodeAccountPeerInactivityExpirationDisable EventActivityCode = "account.peer.inactivity.expiration.disable"
+ EventActivityCodeAccountPeerInactivityExpirationEnable EventActivityCode = "account.peer.inactivity.expiration.enable"
+ EventActivityCodeAccountPeerInactivityExpirationUpdate EventActivityCode = "account.peer.inactivity.expiration.update"
+ EventActivityCodeAccountSettingGroupPropagationDisable EventActivityCode = "account.setting.group.propagation.disable"
+ EventActivityCodeAccountSettingGroupPropagationEnable EventActivityCode = "account.setting.group.propagation.enable"
+ EventActivityCodeAccountSettingLazyConnectionDisable EventActivityCode = "account.setting.lazy.connection.disable"
+ EventActivityCodeAccountSettingLazyConnectionEnable EventActivityCode = "account.setting.lazy.connection.enable"
+ EventActivityCodeAccountSettingPeerApprovalDisable EventActivityCode = "account.setting.peer.approval.disable"
+ EventActivityCodeAccountSettingPeerApprovalEnable EventActivityCode = "account.setting.peer.approval.enable"
+ EventActivityCodeAccountSettingPeerLoginExpirationDisable EventActivityCode = "account.setting.peer.login.expiration.disable"
+ EventActivityCodeAccountSettingPeerLoginExpirationEnable EventActivityCode = "account.setting.peer.login.expiration.enable"
+ EventActivityCodeAccountSettingPeerLoginExpirationUpdate EventActivityCode = "account.setting.peer.login.expiration.update"
+ EventActivityCodeAccountSettingRoutingPeerDnsResolutionDisable EventActivityCode = "account.setting.routing.peer.dns.resolution.disable"
+ EventActivityCodeAccountSettingRoutingPeerDnsResolutionEnable EventActivityCode = "account.setting.routing.peer.dns.resolution.enable"
+ EventActivityCodeAccountSettingsAutoVersionUpdate EventActivityCode = "account.settings.auto.version.update"
+ EventActivityCodeDashboardLogin EventActivityCode = "dashboard.login"
+ EventActivityCodeDnsSettingDisabledManagementGroupAdd EventActivityCode = "dns.setting.disabled.management.group.add"
+ EventActivityCodeDnsSettingDisabledManagementGroupDelete EventActivityCode = "dns.setting.disabled.management.group.delete"
+ EventActivityCodeDnsZoneCreate EventActivityCode = "dns.zone.create"
+ EventActivityCodeDnsZoneDelete EventActivityCode = "dns.zone.delete"
+ EventActivityCodeDnsZoneRecordCreate EventActivityCode = "dns.zone.record.create"
+ EventActivityCodeDnsZoneRecordDelete EventActivityCode = "dns.zone.record.delete"
+ EventActivityCodeDnsZoneRecordUpdate EventActivityCode = "dns.zone.record.update"
+ EventActivityCodeDnsZoneUpdate EventActivityCode = "dns.zone.update"
+ EventActivityCodeGroupAdd EventActivityCode = "group.add"
+ EventActivityCodeGroupDelete EventActivityCode = "group.delete"
+ EventActivityCodeGroupUpdate EventActivityCode = "group.update"
+ EventActivityCodeIdentityproviderCreate EventActivityCode = "identityprovider.create"
+ EventActivityCodeIdentityproviderDelete EventActivityCode = "identityprovider.delete"
+ EventActivityCodeIdentityproviderUpdate EventActivityCode = "identityprovider.update"
+ EventActivityCodeIntegrationCreate EventActivityCode = "integration.create"
+ EventActivityCodeIntegrationDelete EventActivityCode = "integration.delete"
+ EventActivityCodeIntegrationUpdate EventActivityCode = "integration.update"
+ EventActivityCodeNameserverGroupAdd EventActivityCode = "nameserver.group.add"
+ EventActivityCodeNameserverGroupDelete EventActivityCode = "nameserver.group.delete"
+ EventActivityCodeNameserverGroupUpdate EventActivityCode = "nameserver.group.update"
+ EventActivityCodeNetworkCreate EventActivityCode = "network.create"
+ EventActivityCodeNetworkDelete EventActivityCode = "network.delete"
+ EventActivityCodeNetworkResourceCreate EventActivityCode = "network.resource.create"
+ EventActivityCodeNetworkResourceDelete EventActivityCode = "network.resource.delete"
+ EventActivityCodeNetworkResourceUpdate EventActivityCode = "network.resource.update"
+ EventActivityCodeNetworkRouterCreate EventActivityCode = "network.router.create"
+ EventActivityCodeNetworkRouterDelete EventActivityCode = "network.router.delete"
+ EventActivityCodeNetworkRouterUpdate EventActivityCode = "network.router.update"
+ EventActivityCodeNetworkUpdate EventActivityCode = "network.update"
+ EventActivityCodePeerApprovalRevoke EventActivityCode = "peer.approval.revoke"
+ EventActivityCodePeerApprove EventActivityCode = "peer.approve"
+ EventActivityCodePeerGroupAdd EventActivityCode = "peer.group.add"
+ EventActivityCodePeerGroupDelete EventActivityCode = "peer.group.delete"
+ EventActivityCodePeerInactivityExpirationDisable EventActivityCode = "peer.inactivity.expiration.disable"
+ EventActivityCodePeerInactivityExpirationEnable EventActivityCode = "peer.inactivity.expiration.enable"
+ EventActivityCodePeerIpUpdate EventActivityCode = "peer.ip.update"
+ EventActivityCodePeerJobCreate EventActivityCode = "peer.job.create"
+ EventActivityCodePeerLoginExpirationDisable EventActivityCode = "peer.login.expiration.disable"
+ EventActivityCodePeerLoginExpirationEnable EventActivityCode = "peer.login.expiration.enable"
+ EventActivityCodePeerLoginExpire EventActivityCode = "peer.login.expire"
+ EventActivityCodePeerRename EventActivityCode = "peer.rename"
+ EventActivityCodePeerSetupkeyAdd EventActivityCode = "peer.setupkey.add"
+ EventActivityCodePeerSshDisable EventActivityCode = "peer.ssh.disable"
+ EventActivityCodePeerSshEnable EventActivityCode = "peer.ssh.enable"
+ EventActivityCodePeerUserAdd EventActivityCode = "peer.user.add"
+ EventActivityCodePersonalAccessTokenCreate EventActivityCode = "personal.access.token.create"
+ EventActivityCodePersonalAccessTokenDelete EventActivityCode = "personal.access.token.delete"
+ EventActivityCodePolicyAdd EventActivityCode = "policy.add"
+ EventActivityCodePolicyDelete EventActivityCode = "policy.delete"
+ EventActivityCodePolicyUpdate EventActivityCode = "policy.update"
+ EventActivityCodePostureCheckCreate EventActivityCode = "posture.check.create"
+ EventActivityCodePostureCheckDelete EventActivityCode = "posture.check.delete"
+ EventActivityCodePostureCheckUpdate EventActivityCode = "posture.check.update"
+ EventActivityCodeResourceGroupAdd EventActivityCode = "resource.group.add"
+ EventActivityCodeResourceGroupDelete EventActivityCode = "resource.group.delete"
+ EventActivityCodeRouteAdd EventActivityCode = "route.add"
+ EventActivityCodeRouteDelete EventActivityCode = "route.delete"
+ EventActivityCodeRouteUpdate EventActivityCode = "route.update"
+ EventActivityCodeRuleAdd EventActivityCode = "rule.add"
+ EventActivityCodeRuleDelete EventActivityCode = "rule.delete"
+ EventActivityCodeRuleUpdate EventActivityCode = "rule.update"
+ EventActivityCodeServiceUserCreate EventActivityCode = "service.user.create"
+ EventActivityCodeServiceUserDelete EventActivityCode = "service.user.delete"
+ EventActivityCodeSetupkeyAdd EventActivityCode = "setupkey.add"
+ EventActivityCodeSetupkeyDelete EventActivityCode = "setupkey.delete"
+ EventActivityCodeSetupkeyGroupAdd EventActivityCode = "setupkey.group.add"
+ EventActivityCodeSetupkeyGroupDelete EventActivityCode = "setupkey.group.delete"
+ EventActivityCodeSetupkeyOveruse EventActivityCode = "setupkey.overuse"
+ EventActivityCodeSetupkeyRevoke EventActivityCode = "setupkey.revoke"
+ EventActivityCodeSetupkeyUpdate EventActivityCode = "setupkey.update"
+ EventActivityCodeTransferredOwnerRole EventActivityCode = "transferred.owner.role"
+ EventActivityCodeUserApprove EventActivityCode = "user.approve"
+ EventActivityCodeUserBlock EventActivityCode = "user.block"
+ EventActivityCodeUserCreate EventActivityCode = "user.create"
+ EventActivityCodeUserDelete EventActivityCode = "user.delete"
+ EventActivityCodeUserGroupAdd EventActivityCode = "user.group.add"
+ EventActivityCodeUserGroupDelete EventActivityCode = "user.group.delete"
+ EventActivityCodeUserInvite EventActivityCode = "user.invite"
+ EventActivityCodeUserJoin EventActivityCode = "user.join"
+ EventActivityCodeUserPasswordChange EventActivityCode = "user.password.change"
+ EventActivityCodeUserPeerDelete EventActivityCode = "user.peer.delete"
+ EventActivityCodeUserPeerLogin EventActivityCode = "user.peer.login"
+ EventActivityCodeUserReject EventActivityCode = "user.reject"
+ EventActivityCodeUserRoleUpdate EventActivityCode = "user.role.update"
+ EventActivityCodeUserUnblock EventActivityCode = "user.unblock"
)
// Defines values for GeoLocationCheckAction.
@@ -115,6 +184,13 @@ const (
IngressPortAllocationRequestPortRangeProtocolUdp IngressPortAllocationRequestPortRangeProtocol = "udp"
)
+// Defines values for JobResponseStatus.
+const (
+ JobResponseStatusFailed JobResponseStatus = "failed"
+ JobResponseStatusPending JobResponseStatus = "pending"
+ JobResponseStatusSucceeded JobResponseStatus = "succeeded"
+)
+
// Defines values for NameserverNsType.
const (
NameserverNsTypeUdp NameserverNsType = "udp"
@@ -193,6 +269,11 @@ const (
UserStatusInvited UserStatus = "invited"
)
+// Defines values for WorkloadType.
+const (
+ WorkloadTypeBundle WorkloadType = "bundle"
+)
+
// Defines values for GetApiEventsNetworkTrafficParamsType.
const (
GetApiEventsNetworkTrafficParamsTypeTYPEDROP GetApiEventsNetworkTrafficParamsType = "TYPE_DROP"
@@ -361,6 +442,47 @@ type AvailablePorts struct {
Udp int `json:"udp"`
}
+// BundleParameters These parameters control what gets included in the bundle and how it is processed.
+type BundleParameters struct {
+ // Anonymize Whether sensitive data should be anonymized in the bundle.
+ Anonymize bool `json:"anonymize"`
+
+ // BundleFor Whether to generate a bundle for the given timeframe.
+ BundleFor bool `json:"bundle_for"`
+
+ // BundleForTime Time period in minutes for which to generate the bundle.
+ BundleForTime int `json:"bundle_for_time"`
+
+ // LogFileCount Maximum number of log files to include in the bundle.
+ LogFileCount int `json:"log_file_count"`
+}
+
+// BundleResult defines model for BundleResult.
+type BundleResult struct {
+ UploadKey *string `json:"upload_key"`
+}
+
+// BundleWorkloadRequest defines model for BundleWorkloadRequest.
+type BundleWorkloadRequest struct {
+ // Parameters These parameters control what gets included in the bundle and how it is processed.
+ Parameters BundleParameters `json:"parameters"`
+
+ // Type Identifies the type of workload the job will execute.
+ // Currently only `"bundle"` is supported.
+ Type WorkloadType `json:"type"`
+}
+
+// BundleWorkloadResponse defines model for BundleWorkloadResponse.
+type BundleWorkloadResponse struct {
+ // Parameters These parameters control what gets included in the bundle and how it is processed.
+ Parameters BundleParameters `json:"parameters"`
+ Result BundleResult `json:"result"`
+
+ // Type Identifies the type of workload the job will execute.
+ // Currently only `"bundle"` is supported.
+ Type WorkloadType `json:"type"`
+}
+
// Checks List of objects that perform the actual checks
type Checks struct {
// GeoLocationCheck Posture check for geo location
@@ -427,6 +549,42 @@ type CreateSetupKeyRequest struct {
UsageLimit int `json:"usage_limit"`
}
+// DNSRecord defines model for DNSRecord.
+type DNSRecord struct {
+ // Content DNS record content (IP address for A/AAAA, domain for CNAME)
+ Content string `json:"content"`
+
+ // Id DNS record ID
+ Id string `json:"id"`
+
+ // Name FQDN for the DNS record. Must be a subdomain within or match the zone's domain.
+ Name string `json:"name"`
+
+ // Ttl Time to live in seconds
+ Ttl int `json:"ttl"`
+
+ // Type DNS record type
+ Type DNSRecordType `json:"type"`
+}
+
+// DNSRecordRequest defines model for DNSRecordRequest.
+type DNSRecordRequest struct {
+ // Content DNS record content (IP address for A/AAAA, domain for CNAME)
+ Content string `json:"content"`
+
+ // Name FQDN for the DNS record. Must be a subdomain within or match the zone's domain.
+ Name string `json:"name"`
+
+ // Ttl Time to live in seconds
+ Ttl int `json:"ttl"`
+
+ // Type DNS record type
+ Type DNSRecordType `json:"type"`
+}
+
+// DNSRecordType DNS record type
+type DNSRecordType string
+
// DNSSettings defines model for DNSSettings.
type DNSSettings struct {
// DisabledManagementGroups Groups whose DNS management is disabled
@@ -712,6 +870,25 @@ type InstanceStatus struct {
SetupRequired bool `json:"setup_required"`
}
+// JobRequest defines model for JobRequest.
+type JobRequest struct {
+ Workload WorkloadRequest `json:"workload"`
+}
+
+// JobResponse defines model for JobResponse.
+type JobResponse struct {
+ CompletedAt *time.Time `json:"completed_at"`
+ CreatedAt time.Time `json:"created_at"`
+ FailedReason *string `json:"failed_reason"`
+ Id string `json:"id"`
+ Status JobResponseStatus `json:"status"`
+ TriggeredBy string `json:"triggered_by"`
+ Workload WorkloadResponse `json:"workload"`
+}
+
+// JobResponseStatus defines model for JobResponse.Status.
+type JobResponseStatus string
+
// Location Describe geographical location information
type Location struct {
// CityName Commonly used English name of the city
@@ -1082,6 +1259,15 @@ type OSVersionCheck struct {
Windows *MinKernelVersionCheck `json:"windows,omitempty"`
}
+// PasswordChangeRequest defines model for PasswordChangeRequest.
+type PasswordChangeRequest struct {
+ // NewPassword The new password to set
+ NewPassword string `json:"new_password"`
+
+ // OldPassword The current password
+ OldPassword string `json:"old_password"`
+}
+
// Peer defines model for Peer.
type Peer struct {
// ApprovalRequired (Cloud only) Indicates whether peer needs approval
@@ -1999,6 +2185,62 @@ type UserRequest struct {
Role string `json:"role"`
}
+// WorkloadRequest defines model for WorkloadRequest.
+type WorkloadRequest struct {
+ union json.RawMessage
+}
+
+// WorkloadResponse defines model for WorkloadResponse.
+type WorkloadResponse struct {
+ union json.RawMessage
+}
+
+// WorkloadType Identifies the type of workload the job will execute.
+// Currently only `"bundle"` is supported.
+type WorkloadType string
+
+// Zone defines model for Zone.
+type Zone struct {
+ // DistributionGroups Group IDs that defines groups of peers that will resolve this zone
+ DistributionGroups []string `json:"distribution_groups"`
+
+ // Domain Zone domain (FQDN)
+ Domain string `json:"domain"`
+
+ // EnableSearchDomain Enable this zone as a search domain
+ EnableSearchDomain bool `json:"enable_search_domain"`
+
+ // Enabled Zone status
+ Enabled bool `json:"enabled"`
+
+ // Id Zone ID
+ Id string `json:"id"`
+
+ // Name Zone name identifier
+ Name string `json:"name"`
+
+ // Records DNS records associated with this zone
+ Records []DNSRecord `json:"records"`
+}
+
+// ZoneRequest defines model for ZoneRequest.
+type ZoneRequest struct {
+ // DistributionGroups Group IDs that defines groups of peers that will resolve this zone
+ DistributionGroups []string `json:"distribution_groups"`
+
+ // Domain Zone domain (FQDN)
+ Domain string `json:"domain"`
+
+ // EnableSearchDomain Enable this zone as a search domain
+ EnableSearchDomain bool `json:"enable_search_domain"`
+
+ // Enabled Zone status
+ Enabled *bool `json:"enabled,omitempty"`
+
+ // Name Zone name identifier
+ Name string `json:"name"`
+}
+
// GetApiEventsNetworkTrafficParams defines parameters for GetApiEventsNetworkTraffic.
type GetApiEventsNetworkTrafficParams struct {
// Page Page number
@@ -2083,6 +2325,18 @@ type PutApiDnsNameserversNsgroupIdJSONRequestBody = NameserverGroupRequest
// PutApiDnsSettingsJSONRequestBody defines body for PutApiDnsSettings for application/json ContentType.
type PutApiDnsSettingsJSONRequestBody = DNSSettings
+// PostApiDnsZonesJSONRequestBody defines body for PostApiDnsZones for application/json ContentType.
+type PostApiDnsZonesJSONRequestBody = ZoneRequest
+
+// PutApiDnsZonesZoneIdJSONRequestBody defines body for PutApiDnsZonesZoneId for application/json ContentType.
+type PutApiDnsZonesZoneIdJSONRequestBody = ZoneRequest
+
+// PostApiDnsZonesZoneIdRecordsJSONRequestBody defines body for PostApiDnsZonesZoneIdRecords for application/json ContentType.
+type PostApiDnsZonesZoneIdRecordsJSONRequestBody = DNSRecordRequest
+
+// PutApiDnsZonesZoneIdRecordsRecordIdJSONRequestBody defines body for PutApiDnsZonesZoneIdRecordsRecordId for application/json ContentType.
+type PutApiDnsZonesZoneIdRecordsRecordIdJSONRequestBody = DNSRecordRequest
+
// PostApiGroupsJSONRequestBody defines body for PostApiGroups for application/json ContentType.
type PostApiGroupsJSONRequestBody = GroupRequest
@@ -2128,6 +2382,9 @@ type PostApiPeersPeerIdIngressPortsJSONRequestBody = IngressPortAllocationReques
// PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody defines body for PutApiPeersPeerIdIngressPortsAllocationId for application/json ContentType.
type PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody = IngressPortAllocationRequest
+// PostApiPeersPeerIdJobsJSONRequestBody defines body for PostApiPeersPeerIdJobs for application/json ContentType.
+type PostApiPeersPeerIdJobsJSONRequestBody = JobRequest
+
// PostApiPeersPeerIdTemporaryAccessJSONRequestBody defines body for PostApiPeersPeerIdTemporaryAccess for application/json ContentType.
type PostApiPeersPeerIdTemporaryAccessJSONRequestBody = PeerTemporaryAccessRequest
@@ -2164,5 +2421,126 @@ type PostApiUsersJSONRequestBody = UserCreateRequest
// PutApiUsersUserIdJSONRequestBody defines body for PutApiUsersUserId for application/json ContentType.
type PutApiUsersUserIdJSONRequestBody = UserRequest
+// PutApiUsersUserIdPasswordJSONRequestBody defines body for PutApiUsersUserIdPassword for application/json ContentType.
+type PutApiUsersUserIdPasswordJSONRequestBody = PasswordChangeRequest
+
// PostApiUsersUserIdTokensJSONRequestBody defines body for PostApiUsersUserIdTokens for application/json ContentType.
type PostApiUsersUserIdTokensJSONRequestBody = PersonalAccessTokenRequest
+
+// AsBundleWorkloadRequest returns the union data inside the WorkloadRequest as a BundleWorkloadRequest
+func (t WorkloadRequest) AsBundleWorkloadRequest() (BundleWorkloadRequest, error) {
+ var body BundleWorkloadRequest
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromBundleWorkloadRequest overwrites any union data inside the WorkloadRequest as the provided BundleWorkloadRequest
+func (t *WorkloadRequest) FromBundleWorkloadRequest(v BundleWorkloadRequest) error {
+ v.Type = "bundle"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeBundleWorkloadRequest performs a merge with any union data inside the WorkloadRequest, using the provided BundleWorkloadRequest
+func (t *WorkloadRequest) MergeBundleWorkloadRequest(v BundleWorkloadRequest) error {
+ v.Type = "bundle"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+func (t WorkloadRequest) Discriminator() (string, error) {
+ var discriminator struct {
+ Discriminator string `json:"type"`
+ }
+ err := json.Unmarshal(t.union, &discriminator)
+ return discriminator.Discriminator, err
+}
+
+func (t WorkloadRequest) ValueByDiscriminator() (interface{}, error) {
+ discriminator, err := t.Discriminator()
+ if err != nil {
+ return nil, err
+ }
+ switch discriminator {
+ case "bundle":
+ return t.AsBundleWorkloadRequest()
+ default:
+ return nil, errors.New("unknown discriminator value: " + discriminator)
+ }
+}
+
+func (t WorkloadRequest) MarshalJSON() ([]byte, error) {
+ b, err := t.union.MarshalJSON()
+ return b, err
+}
+
+func (t *WorkloadRequest) UnmarshalJSON(b []byte) error {
+ err := t.union.UnmarshalJSON(b)
+ return err
+}
+
+// AsBundleWorkloadResponse returns the union data inside the WorkloadResponse as a BundleWorkloadResponse
+func (t WorkloadResponse) AsBundleWorkloadResponse() (BundleWorkloadResponse, error) {
+ var body BundleWorkloadResponse
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromBundleWorkloadResponse overwrites any union data inside the WorkloadResponse as the provided BundleWorkloadResponse
+func (t *WorkloadResponse) FromBundleWorkloadResponse(v BundleWorkloadResponse) error {
+ v.Type = "bundle"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeBundleWorkloadResponse performs a merge with any union data inside the WorkloadResponse, using the provided BundleWorkloadResponse
+func (t *WorkloadResponse) MergeBundleWorkloadResponse(v BundleWorkloadResponse) error {
+ v.Type = "bundle"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+func (t WorkloadResponse) Discriminator() (string, error) {
+ var discriminator struct {
+ Discriminator string `json:"type"`
+ }
+ err := json.Unmarshal(t.union, &discriminator)
+ return discriminator.Discriminator, err
+}
+
+func (t WorkloadResponse) ValueByDiscriminator() (interface{}, error) {
+ discriminator, err := t.Discriminator()
+ if err != nil {
+ return nil, err
+ }
+ switch discriminator {
+ case "bundle":
+ return t.AsBundleWorkloadResponse()
+ default:
+ return nil, errors.New("unknown discriminator value: " + discriminator)
+ }
+}
+
+func (t WorkloadResponse) MarshalJSON() ([]byte, error) {
+ b, err := t.union.MarshalJSON()
+ return b, err
+}
+
+func (t *WorkloadResponse) UnmarshalJSON(b []byte) error {
+ err := t.union.UnmarshalJSON(b)
+ return err
+}
diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go
index 2047c51ea..dfa9adaf6 100644
--- a/shared/management/proto/management.pb.go
+++ b/shared/management/proto/management.pb.go
@@ -22,6 +22,55 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
+type JobStatus int32
+
+const (
+ JobStatus_unknown_status JobStatus = 0 //placeholder
+ JobStatus_succeeded JobStatus = 1
+ JobStatus_failed JobStatus = 2
+)
+
+// Enum value maps for JobStatus.
+var (
+ JobStatus_name = map[int32]string{
+ 0: "unknown_status",
+ 1: "succeeded",
+ 2: "failed",
+ }
+ JobStatus_value = map[string]int32{
+ "unknown_status": 0,
+ "succeeded": 1,
+ "failed": 2,
+ }
+)
+
+func (x JobStatus) Enum() *JobStatus {
+ p := new(JobStatus)
+ *p = x
+ return p
+}
+
+func (x JobStatus) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (JobStatus) Descriptor() protoreflect.EnumDescriptor {
+ return file_management_proto_enumTypes[0].Descriptor()
+}
+
+func (JobStatus) Type() protoreflect.EnumType {
+ return &file_management_proto_enumTypes[0]
+}
+
+func (x JobStatus) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use JobStatus.Descriptor instead.
+func (JobStatus) EnumDescriptor() ([]byte, []int) {
+ return file_management_proto_rawDescGZIP(), []int{0}
+}
+
type RuleProtocol int32
const (
@@ -64,11 +113,11 @@ func (x RuleProtocol) String() string {
}
func (RuleProtocol) Descriptor() protoreflect.EnumDescriptor {
- return file_management_proto_enumTypes[0].Descriptor()
+ return file_management_proto_enumTypes[1].Descriptor()
}
func (RuleProtocol) Type() protoreflect.EnumType {
- return &file_management_proto_enumTypes[0]
+ return &file_management_proto_enumTypes[1]
}
func (x RuleProtocol) Number() protoreflect.EnumNumber {
@@ -77,7 +126,7 @@ func (x RuleProtocol) Number() protoreflect.EnumNumber {
// Deprecated: Use RuleProtocol.Descriptor instead.
func (RuleProtocol) EnumDescriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{0}
+ return file_management_proto_rawDescGZIP(), []int{1}
}
type RuleDirection int32
@@ -110,11 +159,11 @@ func (x RuleDirection) String() string {
}
func (RuleDirection) Descriptor() protoreflect.EnumDescriptor {
- return file_management_proto_enumTypes[1].Descriptor()
+ return file_management_proto_enumTypes[2].Descriptor()
}
func (RuleDirection) Type() protoreflect.EnumType {
- return &file_management_proto_enumTypes[1]
+ return &file_management_proto_enumTypes[2]
}
func (x RuleDirection) Number() protoreflect.EnumNumber {
@@ -123,7 +172,7 @@ func (x RuleDirection) Number() protoreflect.EnumNumber {
// Deprecated: Use RuleDirection.Descriptor instead.
func (RuleDirection) EnumDescriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{1}
+ return file_management_proto_rawDescGZIP(), []int{2}
}
type RuleAction int32
@@ -156,11 +205,11 @@ func (x RuleAction) String() string {
}
func (RuleAction) Descriptor() protoreflect.EnumDescriptor {
- return file_management_proto_enumTypes[2].Descriptor()
+ return file_management_proto_enumTypes[3].Descriptor()
}
func (RuleAction) Type() protoreflect.EnumType {
- return &file_management_proto_enumTypes[2]
+ return &file_management_proto_enumTypes[3]
}
func (x RuleAction) Number() protoreflect.EnumNumber {
@@ -169,7 +218,7 @@ func (x RuleAction) Number() protoreflect.EnumNumber {
// Deprecated: Use RuleAction.Descriptor instead.
func (RuleAction) EnumDescriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{2}
+ return file_management_proto_rawDescGZIP(), []int{3}
}
type HostConfig_Protocol int32
@@ -211,11 +260,11 @@ func (x HostConfig_Protocol) String() string {
}
func (HostConfig_Protocol) Descriptor() protoreflect.EnumDescriptor {
- return file_management_proto_enumTypes[3].Descriptor()
+ return file_management_proto_enumTypes[4].Descriptor()
}
func (HostConfig_Protocol) Type() protoreflect.EnumType {
- return &file_management_proto_enumTypes[3]
+ return &file_management_proto_enumTypes[4]
}
func (x HostConfig_Protocol) Number() protoreflect.EnumNumber {
@@ -224,7 +273,7 @@ func (x HostConfig_Protocol) Number() protoreflect.EnumNumber {
// Deprecated: Use HostConfig_Protocol.Descriptor instead.
func (HostConfig_Protocol) EnumDescriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{14, 0}
+ return file_management_proto_rawDescGZIP(), []int{18, 0}
}
type DeviceAuthorizationFlowProvider int32
@@ -254,11 +303,11 @@ func (x DeviceAuthorizationFlowProvider) String() string {
}
func (DeviceAuthorizationFlowProvider) Descriptor() protoreflect.EnumDescriptor {
- return file_management_proto_enumTypes[4].Descriptor()
+ return file_management_proto_enumTypes[5].Descriptor()
}
func (DeviceAuthorizationFlowProvider) Type() protoreflect.EnumType {
- return &file_management_proto_enumTypes[4]
+ return &file_management_proto_enumTypes[5]
}
func (x DeviceAuthorizationFlowProvider) Number() protoreflect.EnumNumber {
@@ -267,7 +316,7 @@ func (x DeviceAuthorizationFlowProvider) Number() protoreflect.EnumNumber {
// Deprecated: Use DeviceAuthorizationFlowProvider.Descriptor instead.
func (DeviceAuthorizationFlowProvider) EnumDescriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{27, 0}
+ return file_management_proto_rawDescGZIP(), []int{31, 0}
}
type EncryptedMessage struct {
@@ -336,6 +385,290 @@ func (x *EncryptedMessage) GetVersion() int32 {
return 0
}
+type JobRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ID []byte `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
+ // Types that are assignable to WorkloadParameters:
+ //
+ // *JobRequest_Bundle
+ WorkloadParameters isJobRequest_WorkloadParameters `protobuf_oneof:"workload_parameters"`
+}
+
+func (x *JobRequest) Reset() {
+ *x = JobRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_management_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *JobRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*JobRequest) ProtoMessage() {}
+
+func (x *JobRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_management_proto_msgTypes[1]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use JobRequest.ProtoReflect.Descriptor instead.
+func (*JobRequest) Descriptor() ([]byte, []int) {
+ return file_management_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *JobRequest) GetID() []byte {
+ if x != nil {
+ return x.ID
+ }
+ return nil
+}
+
+func (m *JobRequest) GetWorkloadParameters() isJobRequest_WorkloadParameters {
+ if m != nil {
+ return m.WorkloadParameters
+ }
+ return nil
+}
+
+func (x *JobRequest) GetBundle() *BundleParameters {
+ if x, ok := x.GetWorkloadParameters().(*JobRequest_Bundle); ok {
+ return x.Bundle
+ }
+ return nil
+}
+
+type isJobRequest_WorkloadParameters interface {
+ isJobRequest_WorkloadParameters()
+}
+
+type JobRequest_Bundle struct {
+ Bundle *BundleParameters `protobuf:"bytes,10,opt,name=bundle,proto3,oneof"` //OtherParameters other = 11;
+}
+
+func (*JobRequest_Bundle) isJobRequest_WorkloadParameters() {}
+
+type JobResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ID []byte `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
+ Status JobStatus `protobuf:"varint,2,opt,name=status,proto3,enum=management.JobStatus" json:"status,omitempty"`
+ Reason []byte `protobuf:"bytes,3,opt,name=Reason,proto3" json:"Reason,omitempty"`
+ // Types that are assignable to WorkloadResults:
+ //
+ // *JobResponse_Bundle
+ WorkloadResults isJobResponse_WorkloadResults `protobuf_oneof:"workload_results"`
+}
+
+func (x *JobResponse) Reset() {
+ *x = JobResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_management_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *JobResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*JobResponse) ProtoMessage() {}
+
+func (x *JobResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_management_proto_msgTypes[2]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use JobResponse.ProtoReflect.Descriptor instead.
+func (*JobResponse) Descriptor() ([]byte, []int) {
+ return file_management_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *JobResponse) GetID() []byte {
+ if x != nil {
+ return x.ID
+ }
+ return nil
+}
+
+func (x *JobResponse) GetStatus() JobStatus {
+ if x != nil {
+ return x.Status
+ }
+ return JobStatus_unknown_status
+}
+
+func (x *JobResponse) GetReason() []byte {
+ if x != nil {
+ return x.Reason
+ }
+ return nil
+}
+
+func (m *JobResponse) GetWorkloadResults() isJobResponse_WorkloadResults {
+ if m != nil {
+ return m.WorkloadResults
+ }
+ return nil
+}
+
+func (x *JobResponse) GetBundle() *BundleResult {
+ if x, ok := x.GetWorkloadResults().(*JobResponse_Bundle); ok {
+ return x.Bundle
+ }
+ return nil
+}
+
+type isJobResponse_WorkloadResults interface {
+ isJobResponse_WorkloadResults()
+}
+
+type JobResponse_Bundle struct {
+ Bundle *BundleResult `protobuf:"bytes,10,opt,name=bundle,proto3,oneof"` //OtherResult other = 11;
+}
+
+func (*JobResponse_Bundle) isJobResponse_WorkloadResults() {}
+
+type BundleParameters struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ BundleFor bool `protobuf:"varint,1,opt,name=bundle_for,json=bundleFor,proto3" json:"bundle_for,omitempty"`
+ BundleForTime int64 `protobuf:"varint,2,opt,name=bundle_for_time,json=bundleForTime,proto3" json:"bundle_for_time,omitempty"`
+ LogFileCount int32 `protobuf:"varint,3,opt,name=log_file_count,json=logFileCount,proto3" json:"log_file_count,omitempty"`
+ Anonymize bool `protobuf:"varint,4,opt,name=anonymize,proto3" json:"anonymize,omitempty"`
+}
+
+func (x *BundleParameters) Reset() {
+ *x = BundleParameters{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_management_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *BundleParameters) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BundleParameters) ProtoMessage() {}
+
+func (x *BundleParameters) ProtoReflect() protoreflect.Message {
+ mi := &file_management_proto_msgTypes[3]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use BundleParameters.ProtoReflect.Descriptor instead.
+func (*BundleParameters) Descriptor() ([]byte, []int) {
+ return file_management_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *BundleParameters) GetBundleFor() bool {
+ if x != nil {
+ return x.BundleFor
+ }
+ return false
+}
+
+func (x *BundleParameters) GetBundleForTime() int64 {
+ if x != nil {
+ return x.BundleForTime
+ }
+ return 0
+}
+
+func (x *BundleParameters) GetLogFileCount() int32 {
+ if x != nil {
+ return x.LogFileCount
+ }
+ return 0
+}
+
+func (x *BundleParameters) GetAnonymize() bool {
+ if x != nil {
+ return x.Anonymize
+ }
+ return false
+}
+
+type BundleResult struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ UploadKey string `protobuf:"bytes,1,opt,name=upload_key,json=uploadKey,proto3" json:"upload_key,omitempty"`
+}
+
+func (x *BundleResult) Reset() {
+ *x = BundleResult{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_management_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *BundleResult) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BundleResult) ProtoMessage() {}
+
+func (x *BundleResult) ProtoReflect() protoreflect.Message {
+ mi := &file_management_proto_msgTypes[4]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use BundleResult.ProtoReflect.Descriptor instead.
+func (*BundleResult) Descriptor() ([]byte, []int) {
+ return file_management_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *BundleResult) GetUploadKey() string {
+ if x != nil {
+ return x.UploadKey
+ }
+ return ""
+}
+
type SyncRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -348,7 +681,7 @@ type SyncRequest struct {
func (x *SyncRequest) Reset() {
*x = SyncRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[1]
+ mi := &file_management_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -361,7 +694,7 @@ func (x *SyncRequest) String() string {
func (*SyncRequest) ProtoMessage() {}
func (x *SyncRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[1]
+ mi := &file_management_proto_msgTypes[5]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -374,7 +707,7 @@ func (x *SyncRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SyncRequest.ProtoReflect.Descriptor instead.
func (*SyncRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{1}
+ return file_management_proto_rawDescGZIP(), []int{5}
}
func (x *SyncRequest) GetMeta() *PeerSystemMeta {
@@ -407,7 +740,7 @@ type SyncResponse struct {
func (x *SyncResponse) Reset() {
*x = SyncResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[2]
+ mi := &file_management_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -420,7 +753,7 @@ func (x *SyncResponse) String() string {
func (*SyncResponse) ProtoMessage() {}
func (x *SyncResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[2]
+ mi := &file_management_proto_msgTypes[6]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -433,7 +766,7 @@ func (x *SyncResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use SyncResponse.ProtoReflect.Descriptor instead.
func (*SyncResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{2}
+ return file_management_proto_rawDescGZIP(), []int{6}
}
func (x *SyncResponse) GetNetbirdConfig() *NetbirdConfig {
@@ -490,7 +823,7 @@ type SyncMetaRequest struct {
func (x *SyncMetaRequest) Reset() {
*x = SyncMetaRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[3]
+ mi := &file_management_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -503,7 +836,7 @@ func (x *SyncMetaRequest) String() string {
func (*SyncMetaRequest) ProtoMessage() {}
func (x *SyncMetaRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[3]
+ mi := &file_management_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -516,7 +849,7 @@ func (x *SyncMetaRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SyncMetaRequest.ProtoReflect.Descriptor instead.
func (*SyncMetaRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{3}
+ return file_management_proto_rawDescGZIP(), []int{7}
}
func (x *SyncMetaRequest) GetMeta() *PeerSystemMeta {
@@ -545,7 +878,7 @@ type LoginRequest struct {
func (x *LoginRequest) Reset() {
*x = LoginRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[4]
+ mi := &file_management_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -558,7 +891,7 @@ func (x *LoginRequest) String() string {
func (*LoginRequest) ProtoMessage() {}
func (x *LoginRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[4]
+ mi := &file_management_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -571,7 +904,7 @@ func (x *LoginRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead.
func (*LoginRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{4}
+ return file_management_proto_rawDescGZIP(), []int{8}
}
func (x *LoginRequest) GetSetupKey() string {
@@ -625,7 +958,7 @@ type PeerKeys struct {
func (x *PeerKeys) Reset() {
*x = PeerKeys{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[5]
+ mi := &file_management_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -638,7 +971,7 @@ func (x *PeerKeys) String() string {
func (*PeerKeys) ProtoMessage() {}
func (x *PeerKeys) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[5]
+ mi := &file_management_proto_msgTypes[9]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -651,7 +984,7 @@ func (x *PeerKeys) ProtoReflect() protoreflect.Message {
// Deprecated: Use PeerKeys.ProtoReflect.Descriptor instead.
func (*PeerKeys) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{5}
+ return file_management_proto_rawDescGZIP(), []int{9}
}
func (x *PeerKeys) GetSshPubKey() []byte {
@@ -683,7 +1016,7 @@ type Environment struct {
func (x *Environment) Reset() {
*x = Environment{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[6]
+ mi := &file_management_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -696,7 +1029,7 @@ func (x *Environment) String() string {
func (*Environment) ProtoMessage() {}
func (x *Environment) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[6]
+ mi := &file_management_proto_msgTypes[10]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -709,7 +1042,7 @@ func (x *Environment) ProtoReflect() protoreflect.Message {
// Deprecated: Use Environment.ProtoReflect.Descriptor instead.
func (*Environment) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{6}
+ return file_management_proto_rawDescGZIP(), []int{10}
}
func (x *Environment) GetCloud() string {
@@ -743,7 +1076,7 @@ type File struct {
func (x *File) Reset() {
*x = File{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[7]
+ mi := &file_management_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -756,7 +1089,7 @@ func (x *File) String() string {
func (*File) ProtoMessage() {}
func (x *File) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[7]
+ mi := &file_management_proto_msgTypes[11]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -769,7 +1102,7 @@ func (x *File) ProtoReflect() protoreflect.Message {
// Deprecated: Use File.ProtoReflect.Descriptor instead.
func (*File) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{7}
+ return file_management_proto_rawDescGZIP(), []int{11}
}
func (x *File) GetPath() string {
@@ -818,7 +1151,7 @@ type Flags struct {
func (x *Flags) Reset() {
*x = Flags{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[8]
+ mi := &file_management_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -831,7 +1164,7 @@ func (x *Flags) String() string {
func (*Flags) ProtoMessage() {}
func (x *Flags) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[8]
+ mi := &file_management_proto_msgTypes[12]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -844,7 +1177,7 @@ func (x *Flags) ProtoReflect() protoreflect.Message {
// Deprecated: Use Flags.ProtoReflect.Descriptor instead.
func (*Flags) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{8}
+ return file_management_proto_rawDescGZIP(), []int{12}
}
func (x *Flags) GetRosenpassEnabled() bool {
@@ -980,7 +1313,7 @@ type PeerSystemMeta struct {
func (x *PeerSystemMeta) Reset() {
*x = PeerSystemMeta{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[9]
+ mi := &file_management_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -993,7 +1326,7 @@ func (x *PeerSystemMeta) String() string {
func (*PeerSystemMeta) ProtoMessage() {}
func (x *PeerSystemMeta) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[9]
+ mi := &file_management_proto_msgTypes[13]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1006,7 +1339,7 @@ func (x *PeerSystemMeta) ProtoReflect() protoreflect.Message {
// Deprecated: Use PeerSystemMeta.ProtoReflect.Descriptor instead.
func (*PeerSystemMeta) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{9}
+ return file_management_proto_rawDescGZIP(), []int{13}
}
func (x *PeerSystemMeta) GetHostname() string {
@@ -1144,7 +1477,7 @@ type LoginResponse struct {
func (x *LoginResponse) Reset() {
*x = LoginResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[10]
+ mi := &file_management_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1157,7 +1490,7 @@ func (x *LoginResponse) String() string {
func (*LoginResponse) ProtoMessage() {}
func (x *LoginResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[10]
+ mi := &file_management_proto_msgTypes[14]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1170,7 +1503,7 @@ func (x *LoginResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead.
func (*LoginResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{10}
+ return file_management_proto_rawDescGZIP(), []int{14}
}
func (x *LoginResponse) GetNetbirdConfig() *NetbirdConfig {
@@ -1210,7 +1543,7 @@ type ServerKeyResponse struct {
func (x *ServerKeyResponse) Reset() {
*x = ServerKeyResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[11]
+ mi := &file_management_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1223,7 +1556,7 @@ func (x *ServerKeyResponse) String() string {
func (*ServerKeyResponse) ProtoMessage() {}
func (x *ServerKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[11]
+ mi := &file_management_proto_msgTypes[15]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1236,7 +1569,7 @@ func (x *ServerKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ServerKeyResponse.ProtoReflect.Descriptor instead.
func (*ServerKeyResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{11}
+ return file_management_proto_rawDescGZIP(), []int{15}
}
func (x *ServerKeyResponse) GetKey() string {
@@ -1269,7 +1602,7 @@ type Empty struct {
func (x *Empty) Reset() {
*x = Empty{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[12]
+ mi := &file_management_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1282,7 +1615,7 @@ func (x *Empty) String() string {
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[12]
+ mi := &file_management_proto_msgTypes[16]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1295,7 +1628,7 @@ func (x *Empty) ProtoReflect() protoreflect.Message {
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{12}
+ return file_management_proto_rawDescGZIP(), []int{16}
}
// NetbirdConfig is a common configuration of any Netbird peer. It contains STUN, TURN, Signal and Management servers configurations
@@ -1317,7 +1650,7 @@ type NetbirdConfig struct {
func (x *NetbirdConfig) Reset() {
*x = NetbirdConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[13]
+ mi := &file_management_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1330,7 +1663,7 @@ func (x *NetbirdConfig) String() string {
func (*NetbirdConfig) ProtoMessage() {}
func (x *NetbirdConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[13]
+ mi := &file_management_proto_msgTypes[17]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1343,7 +1676,7 @@ func (x *NetbirdConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetbirdConfig.ProtoReflect.Descriptor instead.
func (*NetbirdConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{13}
+ return file_management_proto_rawDescGZIP(), []int{17}
}
func (x *NetbirdConfig) GetStuns() []*HostConfig {
@@ -1395,7 +1728,7 @@ type HostConfig struct {
func (x *HostConfig) Reset() {
*x = HostConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[14]
+ mi := &file_management_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1408,7 +1741,7 @@ func (x *HostConfig) String() string {
func (*HostConfig) ProtoMessage() {}
func (x *HostConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[14]
+ mi := &file_management_proto_msgTypes[18]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1421,7 +1754,7 @@ func (x *HostConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use HostConfig.ProtoReflect.Descriptor instead.
func (*HostConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{14}
+ return file_management_proto_rawDescGZIP(), []int{18}
}
func (x *HostConfig) GetUri() string {
@@ -1451,7 +1784,7 @@ type RelayConfig struct {
func (x *RelayConfig) Reset() {
*x = RelayConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[15]
+ mi := &file_management_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1464,7 +1797,7 @@ func (x *RelayConfig) String() string {
func (*RelayConfig) ProtoMessage() {}
func (x *RelayConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[15]
+ mi := &file_management_proto_msgTypes[19]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1477,7 +1810,7 @@ func (x *RelayConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use RelayConfig.ProtoReflect.Descriptor instead.
func (*RelayConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{15}
+ return file_management_proto_rawDescGZIP(), []int{19}
}
func (x *RelayConfig) GetUrls() []string {
@@ -1522,7 +1855,7 @@ type FlowConfig struct {
func (x *FlowConfig) Reset() {
*x = FlowConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[16]
+ mi := &file_management_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1535,7 +1868,7 @@ func (x *FlowConfig) String() string {
func (*FlowConfig) ProtoMessage() {}
func (x *FlowConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[16]
+ mi := &file_management_proto_msgTypes[20]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1548,7 +1881,7 @@ func (x *FlowConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use FlowConfig.ProtoReflect.Descriptor instead.
func (*FlowConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{16}
+ return file_management_proto_rawDescGZIP(), []int{20}
}
func (x *FlowConfig) GetUrl() string {
@@ -1607,22 +1940,26 @@ func (x *FlowConfig) GetDnsCollection() bool {
return false
}
-// JWTConfig represents JWT authentication configuration
+// JWTConfig represents JWT authentication configuration for validating tokens.
type JWTConfig struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
- Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"`
+ Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"`
+ // Deprecated: audience is kept for backwards compatibility only. Use audiences instead in the client code but populate this field.
Audience string `protobuf:"bytes,2,opt,name=audience,proto3" json:"audience,omitempty"`
KeysLocation string `protobuf:"bytes,3,opt,name=keysLocation,proto3" json:"keysLocation,omitempty"`
MaxTokenAge int64 `protobuf:"varint,4,opt,name=maxTokenAge,proto3" json:"maxTokenAge,omitempty"`
+ // audiences contains the list of valid audiences for JWT validation.
+ // Tokens matching any audience in this list are considered valid.
+ Audiences []string `protobuf:"bytes,5,rep,name=audiences,proto3" json:"audiences,omitempty"`
}
func (x *JWTConfig) Reset() {
*x = JWTConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[17]
+ mi := &file_management_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1635,7 +1972,7 @@ func (x *JWTConfig) String() string {
func (*JWTConfig) ProtoMessage() {}
func (x *JWTConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[17]
+ mi := &file_management_proto_msgTypes[21]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1648,7 +1985,7 @@ func (x *JWTConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use JWTConfig.ProtoReflect.Descriptor instead.
func (*JWTConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{17}
+ return file_management_proto_rawDescGZIP(), []int{21}
}
func (x *JWTConfig) GetIssuer() string {
@@ -1679,6 +2016,13 @@ func (x *JWTConfig) GetMaxTokenAge() int64 {
return 0
}
+func (x *JWTConfig) GetAudiences() []string {
+ if x != nil {
+ return x.Audiences
+ }
+ return nil
+}
+
// ProtectedHostConfig is similar to HostConfig but has additional user and password
// Mostly used for TURN servers
type ProtectedHostConfig struct {
@@ -1694,7 +2038,7 @@ type ProtectedHostConfig struct {
func (x *ProtectedHostConfig) Reset() {
*x = ProtectedHostConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[18]
+ mi := &file_management_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1707,7 +2051,7 @@ func (x *ProtectedHostConfig) String() string {
func (*ProtectedHostConfig) ProtoMessage() {}
func (x *ProtectedHostConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[18]
+ mi := &file_management_proto_msgTypes[22]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1720,7 +2064,7 @@ func (x *ProtectedHostConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use ProtectedHostConfig.ProtoReflect.Descriptor instead.
func (*ProtectedHostConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{18}
+ return file_management_proto_rawDescGZIP(), []int{22}
}
func (x *ProtectedHostConfig) GetHostConfig() *HostConfig {
@@ -1769,7 +2113,7 @@ type PeerConfig struct {
func (x *PeerConfig) Reset() {
*x = PeerConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[19]
+ mi := &file_management_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1782,7 +2126,7 @@ func (x *PeerConfig) String() string {
func (*PeerConfig) ProtoMessage() {}
func (x *PeerConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[19]
+ mi := &file_management_proto_msgTypes[23]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1795,7 +2139,7 @@ func (x *PeerConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use PeerConfig.ProtoReflect.Descriptor instead.
func (*PeerConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{19}
+ return file_management_proto_rawDescGZIP(), []int{23}
}
func (x *PeerConfig) GetAddress() string {
@@ -1868,7 +2212,7 @@ type AutoUpdateSettings struct {
func (x *AutoUpdateSettings) Reset() {
*x = AutoUpdateSettings{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[20]
+ mi := &file_management_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1881,7 +2225,7 @@ func (x *AutoUpdateSettings) String() string {
func (*AutoUpdateSettings) ProtoMessage() {}
func (x *AutoUpdateSettings) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[20]
+ mi := &file_management_proto_msgTypes[24]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1894,7 +2238,7 @@ func (x *AutoUpdateSettings) ProtoReflect() protoreflect.Message {
// Deprecated: Use AutoUpdateSettings.ProtoReflect.Descriptor instead.
func (*AutoUpdateSettings) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{20}
+ return file_management_proto_rawDescGZIP(), []int{24}
}
func (x *AutoUpdateSettings) GetVersion() string {
@@ -1949,7 +2293,7 @@ type NetworkMap struct {
func (x *NetworkMap) Reset() {
*x = NetworkMap{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[21]
+ mi := &file_management_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1962,7 +2306,7 @@ func (x *NetworkMap) String() string {
func (*NetworkMap) ProtoMessage() {}
func (x *NetworkMap) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[21]
+ mi := &file_management_proto_msgTypes[25]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1975,7 +2319,7 @@ func (x *NetworkMap) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetworkMap.ProtoReflect.Descriptor instead.
func (*NetworkMap) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{21}
+ return file_management_proto_rawDescGZIP(), []int{25}
}
func (x *NetworkMap) GetSerial() uint64 {
@@ -2085,7 +2429,7 @@ type SSHAuth struct {
func (x *SSHAuth) Reset() {
*x = SSHAuth{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[22]
+ mi := &file_management_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2098,7 +2442,7 @@ func (x *SSHAuth) String() string {
func (*SSHAuth) ProtoMessage() {}
func (x *SSHAuth) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[22]
+ mi := &file_management_proto_msgTypes[26]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2111,7 +2455,7 @@ func (x *SSHAuth) ProtoReflect() protoreflect.Message {
// Deprecated: Use SSHAuth.ProtoReflect.Descriptor instead.
func (*SSHAuth) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{22}
+ return file_management_proto_rawDescGZIP(), []int{26}
}
func (x *SSHAuth) GetUserIDClaim() string {
@@ -2146,7 +2490,7 @@ type MachineUserIndexes struct {
func (x *MachineUserIndexes) Reset() {
*x = MachineUserIndexes{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[23]
+ mi := &file_management_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2159,7 +2503,7 @@ func (x *MachineUserIndexes) String() string {
func (*MachineUserIndexes) ProtoMessage() {}
func (x *MachineUserIndexes) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[23]
+ mi := &file_management_proto_msgTypes[27]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2172,7 +2516,7 @@ func (x *MachineUserIndexes) ProtoReflect() protoreflect.Message {
// Deprecated: Use MachineUserIndexes.ProtoReflect.Descriptor instead.
func (*MachineUserIndexes) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{23}
+ return file_management_proto_rawDescGZIP(), []int{27}
}
func (x *MachineUserIndexes) GetIndexes() []uint32 {
@@ -2203,7 +2547,7 @@ type RemotePeerConfig struct {
func (x *RemotePeerConfig) Reset() {
*x = RemotePeerConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[24]
+ mi := &file_management_proto_msgTypes[28]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2216,7 +2560,7 @@ func (x *RemotePeerConfig) String() string {
func (*RemotePeerConfig) ProtoMessage() {}
func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[24]
+ mi := &file_management_proto_msgTypes[28]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2229,7 +2573,7 @@ func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemotePeerConfig.ProtoReflect.Descriptor instead.
func (*RemotePeerConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{24}
+ return file_management_proto_rawDescGZIP(), []int{28}
}
func (x *RemotePeerConfig) GetWgPubKey() string {
@@ -2284,7 +2628,7 @@ type SSHConfig struct {
func (x *SSHConfig) Reset() {
*x = SSHConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[25]
+ mi := &file_management_proto_msgTypes[29]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2297,7 +2641,7 @@ func (x *SSHConfig) String() string {
func (*SSHConfig) ProtoMessage() {}
func (x *SSHConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[25]
+ mi := &file_management_proto_msgTypes[29]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2310,7 +2654,7 @@ func (x *SSHConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use SSHConfig.ProtoReflect.Descriptor instead.
func (*SSHConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{25}
+ return file_management_proto_rawDescGZIP(), []int{29}
}
func (x *SSHConfig) GetSshEnabled() bool {
@@ -2344,7 +2688,7 @@ type DeviceAuthorizationFlowRequest struct {
func (x *DeviceAuthorizationFlowRequest) Reset() {
*x = DeviceAuthorizationFlowRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[26]
+ mi := &file_management_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2357,7 +2701,7 @@ func (x *DeviceAuthorizationFlowRequest) String() string {
func (*DeviceAuthorizationFlowRequest) ProtoMessage() {}
func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[26]
+ mi := &file_management_proto_msgTypes[30]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2370,7 +2714,7 @@ func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeviceAuthorizationFlowRequest.ProtoReflect.Descriptor instead.
func (*DeviceAuthorizationFlowRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{26}
+ return file_management_proto_rawDescGZIP(), []int{30}
}
// DeviceAuthorizationFlow represents Device Authorization Flow information
@@ -2389,7 +2733,7 @@ type DeviceAuthorizationFlow struct {
func (x *DeviceAuthorizationFlow) Reset() {
*x = DeviceAuthorizationFlow{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[27]
+ mi := &file_management_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2402,7 +2746,7 @@ func (x *DeviceAuthorizationFlow) String() string {
func (*DeviceAuthorizationFlow) ProtoMessage() {}
func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[27]
+ mi := &file_management_proto_msgTypes[31]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2415,7 +2759,7 @@ func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeviceAuthorizationFlow.ProtoReflect.Descriptor instead.
func (*DeviceAuthorizationFlow) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{27}
+ return file_management_proto_rawDescGZIP(), []int{31}
}
func (x *DeviceAuthorizationFlow) GetProvider() DeviceAuthorizationFlowProvider {
@@ -2442,7 +2786,7 @@ type PKCEAuthorizationFlowRequest struct {
func (x *PKCEAuthorizationFlowRequest) Reset() {
*x = PKCEAuthorizationFlowRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[28]
+ mi := &file_management_proto_msgTypes[32]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2455,7 +2799,7 @@ func (x *PKCEAuthorizationFlowRequest) String() string {
func (*PKCEAuthorizationFlowRequest) ProtoMessage() {}
func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[28]
+ mi := &file_management_proto_msgTypes[32]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2468,7 +2812,7 @@ func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PKCEAuthorizationFlowRequest.ProtoReflect.Descriptor instead.
func (*PKCEAuthorizationFlowRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{28}
+ return file_management_proto_rawDescGZIP(), []int{32}
}
// PKCEAuthorizationFlow represents Authorization Code Flow information
@@ -2485,7 +2829,7 @@ type PKCEAuthorizationFlow struct {
func (x *PKCEAuthorizationFlow) Reset() {
*x = PKCEAuthorizationFlow{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[29]
+ mi := &file_management_proto_msgTypes[33]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2498,7 +2842,7 @@ func (x *PKCEAuthorizationFlow) String() string {
func (*PKCEAuthorizationFlow) ProtoMessage() {}
func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[29]
+ mi := &file_management_proto_msgTypes[33]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2511,7 +2855,7 @@ func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message {
// Deprecated: Use PKCEAuthorizationFlow.ProtoReflect.Descriptor instead.
func (*PKCEAuthorizationFlow) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{29}
+ return file_management_proto_rawDescGZIP(), []int{33}
}
func (x *PKCEAuthorizationFlow) GetProviderConfig() *ProviderConfig {
@@ -2557,7 +2901,7 @@ type ProviderConfig struct {
func (x *ProviderConfig) Reset() {
*x = ProviderConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[30]
+ mi := &file_management_proto_msgTypes[34]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2570,7 +2914,7 @@ func (x *ProviderConfig) String() string {
func (*ProviderConfig) ProtoMessage() {}
func (x *ProviderConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[30]
+ mi := &file_management_proto_msgTypes[34]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2583,7 +2927,7 @@ func (x *ProviderConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use ProviderConfig.ProtoReflect.Descriptor instead.
func (*ProviderConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{30}
+ return file_management_proto_rawDescGZIP(), []int{34}
}
func (x *ProviderConfig) GetClientID() string {
@@ -2691,7 +3035,7 @@ type Route struct {
func (x *Route) Reset() {
*x = Route{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[31]
+ mi := &file_management_proto_msgTypes[35]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2704,7 +3048,7 @@ func (x *Route) String() string {
func (*Route) ProtoMessage() {}
func (x *Route) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[31]
+ mi := &file_management_proto_msgTypes[35]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2717,7 +3061,7 @@ func (x *Route) ProtoReflect() protoreflect.Message {
// Deprecated: Use Route.ProtoReflect.Descriptor instead.
func (*Route) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{31}
+ return file_management_proto_rawDescGZIP(), []int{35}
}
func (x *Route) GetID() string {
@@ -2806,7 +3150,7 @@ type DNSConfig struct {
func (x *DNSConfig) Reset() {
*x = DNSConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[32]
+ mi := &file_management_proto_msgTypes[36]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2819,7 +3163,7 @@ func (x *DNSConfig) String() string {
func (*DNSConfig) ProtoMessage() {}
func (x *DNSConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[32]
+ mi := &file_management_proto_msgTypes[36]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2832,7 +3176,7 @@ func (x *DNSConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use DNSConfig.ProtoReflect.Descriptor instead.
func (*DNSConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{32}
+ return file_management_proto_rawDescGZIP(), []int{36}
}
func (x *DNSConfig) GetServiceEnable() bool {
@@ -2873,13 +3217,15 @@ type CustomZone struct {
Domain string `protobuf:"bytes,1,opt,name=Domain,proto3" json:"Domain,omitempty"`
Records []*SimpleRecord `protobuf:"bytes,2,rep,name=Records,proto3" json:"Records,omitempty"`
SearchDomainDisabled bool `protobuf:"varint,3,opt,name=SearchDomainDisabled,proto3" json:"SearchDomainDisabled,omitempty"`
- SkipPTRProcess bool `protobuf:"varint,4,opt,name=SkipPTRProcess,proto3" json:"SkipPTRProcess,omitempty"`
+ // NonAuthoritative indicates this is a user-created zone (not the built-in peer DNS zone).
+ // Non-authoritative zones will fallthrough to lower-priority handlers on NXDOMAIN and skip PTR processing.
+ NonAuthoritative bool `protobuf:"varint,4,opt,name=NonAuthoritative,proto3" json:"NonAuthoritative,omitempty"`
}
func (x *CustomZone) Reset() {
*x = CustomZone{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[33]
+ mi := &file_management_proto_msgTypes[37]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2892,7 +3238,7 @@ func (x *CustomZone) String() string {
func (*CustomZone) ProtoMessage() {}
func (x *CustomZone) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[33]
+ mi := &file_management_proto_msgTypes[37]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2905,7 +3251,7 @@ func (x *CustomZone) ProtoReflect() protoreflect.Message {
// Deprecated: Use CustomZone.ProtoReflect.Descriptor instead.
func (*CustomZone) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{33}
+ return file_management_proto_rawDescGZIP(), []int{37}
}
func (x *CustomZone) GetDomain() string {
@@ -2929,9 +3275,9 @@ func (x *CustomZone) GetSearchDomainDisabled() bool {
return false
}
-func (x *CustomZone) GetSkipPTRProcess() bool {
+func (x *CustomZone) GetNonAuthoritative() bool {
if x != nil {
- return x.SkipPTRProcess
+ return x.NonAuthoritative
}
return false
}
@@ -2952,7 +3298,7 @@ type SimpleRecord struct {
func (x *SimpleRecord) Reset() {
*x = SimpleRecord{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[34]
+ mi := &file_management_proto_msgTypes[38]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2965,7 +3311,7 @@ func (x *SimpleRecord) String() string {
func (*SimpleRecord) ProtoMessage() {}
func (x *SimpleRecord) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[34]
+ mi := &file_management_proto_msgTypes[38]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2978,7 +3324,7 @@ func (x *SimpleRecord) ProtoReflect() protoreflect.Message {
// Deprecated: Use SimpleRecord.ProtoReflect.Descriptor instead.
func (*SimpleRecord) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{34}
+ return file_management_proto_rawDescGZIP(), []int{38}
}
func (x *SimpleRecord) GetName() string {
@@ -3031,7 +3377,7 @@ type NameServerGroup struct {
func (x *NameServerGroup) Reset() {
*x = NameServerGroup{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[35]
+ mi := &file_management_proto_msgTypes[39]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3044,7 +3390,7 @@ func (x *NameServerGroup) String() string {
func (*NameServerGroup) ProtoMessage() {}
func (x *NameServerGroup) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[35]
+ mi := &file_management_proto_msgTypes[39]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3057,7 +3403,7 @@ func (x *NameServerGroup) ProtoReflect() protoreflect.Message {
// Deprecated: Use NameServerGroup.ProtoReflect.Descriptor instead.
func (*NameServerGroup) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{35}
+ return file_management_proto_rawDescGZIP(), []int{39}
}
func (x *NameServerGroup) GetNameServers() []*NameServer {
@@ -3102,7 +3448,7 @@ type NameServer struct {
func (x *NameServer) Reset() {
*x = NameServer{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[36]
+ mi := &file_management_proto_msgTypes[40]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3115,7 +3461,7 @@ func (x *NameServer) String() string {
func (*NameServer) ProtoMessage() {}
func (x *NameServer) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[36]
+ mi := &file_management_proto_msgTypes[40]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3128,7 +3474,7 @@ func (x *NameServer) ProtoReflect() protoreflect.Message {
// Deprecated: Use NameServer.ProtoReflect.Descriptor instead.
func (*NameServer) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{36}
+ return file_management_proto_rawDescGZIP(), []int{40}
}
func (x *NameServer) GetIP() string {
@@ -3171,7 +3517,7 @@ type FirewallRule struct {
func (x *FirewallRule) Reset() {
*x = FirewallRule{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[37]
+ mi := &file_management_proto_msgTypes[41]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3184,7 +3530,7 @@ func (x *FirewallRule) String() string {
func (*FirewallRule) ProtoMessage() {}
func (x *FirewallRule) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[37]
+ mi := &file_management_proto_msgTypes[41]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3197,7 +3543,7 @@ func (x *FirewallRule) ProtoReflect() protoreflect.Message {
// Deprecated: Use FirewallRule.ProtoReflect.Descriptor instead.
func (*FirewallRule) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{37}
+ return file_management_proto_rawDescGZIP(), []int{41}
}
func (x *FirewallRule) GetPeerIP() string {
@@ -3261,7 +3607,7 @@ type NetworkAddress struct {
func (x *NetworkAddress) Reset() {
*x = NetworkAddress{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[38]
+ mi := &file_management_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3274,7 +3620,7 @@ func (x *NetworkAddress) String() string {
func (*NetworkAddress) ProtoMessage() {}
func (x *NetworkAddress) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[38]
+ mi := &file_management_proto_msgTypes[42]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3287,7 +3633,7 @@ func (x *NetworkAddress) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetworkAddress.ProtoReflect.Descriptor instead.
func (*NetworkAddress) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{38}
+ return file_management_proto_rawDescGZIP(), []int{42}
}
func (x *NetworkAddress) GetNetIP() string {
@@ -3315,7 +3661,7 @@ type Checks struct {
func (x *Checks) Reset() {
*x = Checks{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[39]
+ mi := &file_management_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3328,7 +3674,7 @@ func (x *Checks) String() string {
func (*Checks) ProtoMessage() {}
func (x *Checks) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[39]
+ mi := &file_management_proto_msgTypes[43]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3341,7 +3687,7 @@ func (x *Checks) ProtoReflect() protoreflect.Message {
// Deprecated: Use Checks.ProtoReflect.Descriptor instead.
func (*Checks) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{39}
+ return file_management_proto_rawDescGZIP(), []int{43}
}
func (x *Checks) GetFiles() []string {
@@ -3366,7 +3712,7 @@ type PortInfo struct {
func (x *PortInfo) Reset() {
*x = PortInfo{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[40]
+ mi := &file_management_proto_msgTypes[44]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3379,7 +3725,7 @@ func (x *PortInfo) String() string {
func (*PortInfo) ProtoMessage() {}
func (x *PortInfo) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[40]
+ mi := &file_management_proto_msgTypes[44]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3392,7 +3738,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use PortInfo.ProtoReflect.Descriptor instead.
func (*PortInfo) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{40}
+ return file_management_proto_rawDescGZIP(), []int{44}
}
func (m *PortInfo) GetPortSelection() isPortInfo_PortSelection {
@@ -3463,7 +3809,7 @@ type RouteFirewallRule struct {
func (x *RouteFirewallRule) Reset() {
*x = RouteFirewallRule{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[41]
+ mi := &file_management_proto_msgTypes[45]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3476,7 +3822,7 @@ func (x *RouteFirewallRule) String() string {
func (*RouteFirewallRule) ProtoMessage() {}
func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[41]
+ mi := &file_management_proto_msgTypes[45]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3489,7 +3835,7 @@ func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message {
// Deprecated: Use RouteFirewallRule.ProtoReflect.Descriptor instead.
func (*RouteFirewallRule) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{41}
+ return file_management_proto_rawDescGZIP(), []int{45}
}
func (x *RouteFirewallRule) GetSourceRanges() []string {
@@ -3580,7 +3926,7 @@ type ForwardingRule struct {
func (x *ForwardingRule) Reset() {
*x = ForwardingRule{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[42]
+ mi := &file_management_proto_msgTypes[46]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3593,7 +3939,7 @@ func (x *ForwardingRule) String() string {
func (*ForwardingRule) ProtoMessage() {}
func (x *ForwardingRule) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[42]
+ mi := &file_management_proto_msgTypes[46]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3606,7 +3952,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message {
// Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead.
func (*ForwardingRule) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{42}
+ return file_management_proto_rawDescGZIP(), []int{46}
}
func (x *ForwardingRule) GetProtocol() RuleProtocol {
@@ -3649,7 +3995,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[44]
+ mi := &file_management_proto_msgTypes[48]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3662,7 +4008,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[44]
+ mi := &file_management_proto_msgTypes[48]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3675,7 +4021,7 @@ func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
// Deprecated: Use PortInfo_Range.ProtoReflect.Descriptor instead.
func (*PortInfo_Range) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{40, 0}
+ return file_management_proto_rawDescGZIP(), []int{44, 0}
}
func (x *PortInfo_Range) GetStart() uint32 {
@@ -3706,585 +4052,626 @@ var file_management_proto_rawDesc = []byte{
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12,
0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62,
0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03,
- 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x3d, 0x0a,
- 0x0b, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04,
- 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e,
- 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74,
- 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xdb, 0x02, 0x0a,
- 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a,
- 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
- 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36,
- 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72,
- 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65,
- 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50,
- 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74,
- 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65,
- 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49,
- 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x36, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
- 0x6b, 0x4d, 0x61, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e,
- 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d,
- 0x61, 0x70, 0x52, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x2a,
- 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63,
- 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x22, 0x41, 0x0a, 0x0f, 0x53, 0x79,
- 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a,
- 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73,
- 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xc6, 0x01,
- 0x0a, 0x0c, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a,
- 0x0a, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65,
- 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d,
- 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77,
- 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77,
- 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65,
- 0x79, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x52, 0x08,
- 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6e, 0x73, 0x4c,
- 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73,
- 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x22, 0x44, 0x0a, 0x08, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65,
- 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18,
- 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79,
- 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x0c, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x22, 0x3f, 0x0a, 0x0b,
- 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x63,
- 0x6c, 0x6f, 0x75, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, 0x6c, 0x6f, 0x75,
- 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x22, 0x5c, 0x0a,
- 0x04, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x78, 0x69,
- 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x12,
- 0x2a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e,
- 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65,
- 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xbf, 0x05, 0x0a, 0x05,
- 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61,
- 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
- 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65,
- 0x64, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65,
- 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13,
+ 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x6b, 0x0a,
+ 0x0a, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x49,
+ 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x49, 0x44, 0x12, 0x36, 0x0a, 0x06, 0x62,
+ 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x50,
+ 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x48, 0x00, 0x52, 0x06, 0x62, 0x75, 0x6e,
+ 0x64, 0x6c, 0x65, 0x42, 0x15, 0x0a, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x6c, 0x6f, 0x61, 0x64, 0x5f,
+ 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x22, 0xac, 0x01, 0x0a, 0x0b, 0x4a,
+ 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x49, 0x44, 0x12, 0x2d, 0x0a, 0x06, 0x73, 0x74,
+ 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75,
+ 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x52, 0x65, 0x61,
+ 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x52, 0x65, 0x61, 0x73, 0x6f,
+ 0x6e, 0x12, 0x32, 0x0a, 0x06, 0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28,
+ 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x42,
+ 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x06, 0x62,
+ 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x42, 0x12, 0x0a, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x6c, 0x6f, 0x61,
+ 0x64, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x9d, 0x01, 0x0a, 0x10, 0x42, 0x75,
+ 0x6e, 0x64, 0x6c, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x1d,
+ 0x0a, 0x0a, 0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x5f, 0x66, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x09, 0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x46, 0x6f, 0x72, 0x12, 0x26, 0x0a,
+ 0x0f, 0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x5f, 0x66, 0x6f, 0x72, 0x5f, 0x74, 0x69, 0x6d, 0x65,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x46, 0x6f,
+ 0x72, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x6c, 0x6f, 0x67, 0x5f, 0x66, 0x69, 0x6c,
+ 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x6c,
+ 0x6f, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x61,
+ 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09,
+ 0x61, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x22, 0x2d, 0x0a, 0x0c, 0x42, 0x75, 0x6e,
+ 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x6c,
+ 0x6f, 0x61, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75,
+ 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x4b, 0x65, 0x79, 0x22, 0x3d, 0x0a, 0x0b, 0x53, 0x79, 0x6e, 0x63,
+ 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74,
+ 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xdb, 0x02, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63,
+ 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62,
+ 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74,
+ 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62,
+ 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65,
+ 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43,
+ 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73,
+ 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f,
+ 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72,
+ 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73,
+ 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72,
+ 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74,
+ 0x79, 0x12, 0x36, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x18,
+ 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x52, 0x0a, 0x4e,
+ 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65,
+ 0x63, 0x6b, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43,
+ 0x68, 0x65, 0x63, 0x6b, 0x73, 0x22, 0x41, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74,
+ 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65,
+ 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xc6, 0x01, 0x0a, 0x0c, 0x4c, 0x6f, 0x67,
+ 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x74,
+ 0x75, 0x70, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x74,
+ 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52,
+ 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65,
+ 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65,
+ 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x04, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x52, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b,
+ 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73,
+ 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c,
+ 0x73, 0x22, 0x44, 0x0a, 0x08, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a,
+ 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c,
+ 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x77,
+ 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77,
+ 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x22, 0x3f, 0x0a, 0x0b, 0x45, 0x6e, 0x76, 0x69, 0x72,
+ 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x12, 0x1a, 0x0a, 0x08,
+ 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
+ 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x22, 0x5c, 0x0a, 0x04, 0x46, 0x69, 0x6c, 0x65,
+ 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+ 0x70, 0x61, 0x74, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x18, 0x02, 0x20,
+ 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x70, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x03,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52,
+ 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xbf, 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73,
+ 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61,
+ 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65,
+ 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13,
0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73,
- 0x69, 0x76, 0x65, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48,
- 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73,
- 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12,
- 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74,
- 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69,
+ 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
+ 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x12, 0x2a,
+ 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77,
+ 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
+ 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69,
0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65,
- 0x73, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76,
- 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13,
+ 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
+ 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x13,
0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75,
- 0x74, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e,
- 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
- 0x44, 0x4e, 0x53, 0x12, 0x28, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69,
- 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x64, 0x69,
- 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x12, 0x26, 0x0a,
- 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18,
- 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41,
- 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e,
- 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x62, 0x6c, 0x6f,
- 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x6c, 0x61, 0x7a,
- 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c,
- 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f,
- 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12,
- 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74,
- 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53,
- 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53,
- 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e,
- 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x12, 0x42, 0x0a, 0x1c, 0x65,
- 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72,
- 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0d, 0x20, 0x01, 0x28,
- 0x08, 0x52, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61,
- 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12,
- 0x44, 0x0a, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f,
+ 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62,
+ 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x1e,
+ 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x18, 0x06, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x12, 0x28,
+ 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
+ 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
+ 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x62, 0x6c, 0x6f, 0x63,
+ 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08,
+ 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73,
+ 0x12, 0x22, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64,
+ 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62,
+ 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e,
+ 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20,
+ 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74,
+ 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e,
+ 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74,
+ 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54,
+ 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53,
+ 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x12, 0x42, 0x0a, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65,
+ 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77,
+ 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x65, 0x6e,
+ 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74,
+ 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x44, 0x0a, 0x1d, 0x65, 0x6e,
+ 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72,
+ 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0e, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f,
0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67,
- 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53,
- 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61,
- 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
- 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64,
- 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x22, 0xf2, 0x04,
- 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61,
- 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04,
- 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53,
- 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65,
- 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08,
- 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
- 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62,
- 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
- 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24,
- 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18,
- 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72,
- 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
- 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69,
- 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64,
- 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
- 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
- 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79,
- 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75,
- 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75,
- 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79,
- 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f,
- 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18,
- 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61,
- 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f,
- 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e,
- 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e,
- 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b,
- 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69,
- 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61,
- 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61,
- 0x67, 0x73, 0x22, 0xb4, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70,
- 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64,
- 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e,
- 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
- 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a,
- 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b,
- 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72,
- 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10,
- 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
- 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 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, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65,
- 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72,
- 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xff, 0x01,
- 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
- 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74,
- 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a,
- 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63,
- 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74,
- 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03,
+ 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75,
+ 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c,
+ 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x22, 0xf2, 0x04, 0x0a, 0x0e, 0x50, 0x65, 0x65,
+ 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68,
+ 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68,
+ 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b,
+ 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72,
+ 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66,
+ 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66,
+ 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65,
+ 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74,
+ 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75,
+ 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+ 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72,
+ 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
+ 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a,
+ 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65,
+ 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72,
+ 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72,
+ 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69,
+ 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f,
+ 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12,
+ 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d,
+ 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64,
+ 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61,
+ 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65,
+ 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74,
+ 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52,
+ 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05,
+ 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66,
+ 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x22, 0xb4, 0x01,
+ 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
+ 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+ 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69,
- 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20,
- 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65,
- 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28,
- 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46,
- 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22,
- 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10,
- 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69,
- 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f,
- 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a,
- 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50,
- 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48,
- 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03,
- 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65,
- 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c,
- 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a,
- 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61,
- 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74,
- 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
- 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c,
- 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18,
- 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f,
- 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26,
- 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67,
- 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76,
- 0x61, 0x6c, 0x18, 0x04, 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, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a,
- 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
- 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74,
- 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74,
- 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43,
- 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52,
- 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74,
- 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63,
- 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43,
- 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x85, 0x01, 0x0a, 0x09, 0x4a, 0x57,
- 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65,
- 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12,
- 0x1a, 0x0a, 0x08, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x08, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b,
- 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12,
- 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04,
- 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67,
- 0x65, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f,
- 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74,
- 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f,
- 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
- 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
- 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
- 0x22, 0xd3, 0x02, 0x0a, 0x0a, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
- 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73,
- 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73,
- 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
- 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
- 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48, 0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50,
+ 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65,
+ 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63,
+ 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68,
+ 0x65, 0x63, 0x6b, 0x73, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65,
+ 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65,
+ 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 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, 0x65, 0x78, 0x70, 0x69,
+ 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+ 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22,
+ 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74,
+ 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74,
+ 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e,
+ 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f,
+ 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12,
+ 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73,
+ 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12,
+ 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61,
+ 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a,
+ 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f,
+ 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48,
+ 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70,
+ 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43,
+ 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08,
+ 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74,
+ 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a,
+ 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02,
+ 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44,
+ 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f,
+ 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03,
+ 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65,
+ 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
+ 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e,
+ 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61,
+ 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e,
+ 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61,
+ 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b,
+ 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b,
+ 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72,
+ 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 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, 0x08,
+ 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62,
+ 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c,
+ 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e,
+ 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63,
+ 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74,
+ 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24,
+ 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18,
+ 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63,
+ 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75,
+ 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75,
+ 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f,
+ 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b, 0x65,
+ 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61,
+ 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
+ 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09,
+ 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52,
+ 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72,
+ 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68,
+ 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65,
+ 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a,
+ 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xd3, 0x02, 0x0a, 0x0a, 0x50, 0x65,
+ 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72,
+ 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65,
+ 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09,
+ 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64,
+ 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48, 0x0a,
+ 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52,
+ 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
+ 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50,
0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e,
- 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52,
- 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73,
- 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34,
- 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e,
- 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c,
- 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61,
- 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28,
- 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, 0x3e, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70,
- 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e,
- 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61,
- 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f,
- 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0x52, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70,
- 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07,
- 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76,
- 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73,
- 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c,
- 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e,
- 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72,
- 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61,
- 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18,
- 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70,
- 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d,
- 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f,
- 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65,
- 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d,
- 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18,
- 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
- 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75,
- 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x52, 0x6f,
- 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69,
- 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09,
- 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66,
- 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32,
- 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d,
- 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f,
- 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0d, 0x46,
- 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03,
- 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x46, 0x69,
- 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66,
- 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d,
- 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77,
- 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12,
- 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
- 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46,
- 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x13, 0x72, 0x6f, 0x75,
+ 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43,
+ 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
+ 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e,
+ 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a,
+ 0x03, 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12,
+ 0x3e, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69,
+ 0x6e, 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22,
+ 0x52, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74,
+ 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
+ 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64,
+ 0x61, 0x74, 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d,
+ 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65,
+ 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72,
+ 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72,
+ 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43,
+ 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
+ 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72,
+ 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12,
+ 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70,
+ 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03,
+ 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a,
+ 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e,
+ 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65,
+ 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72,
+ 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50,
+ 0x65, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c,
+ 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
+ 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52,
+ 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c,
+ 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65,
+ 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74,
+ 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18,
+ 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c,
+ 0x52, 0x75, 0x6c, 0x65, 0x52, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65,
+ 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75,
0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73,
- 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61,
- 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65,
- 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79,
- 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75,
- 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e,
- 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e,
- 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74,
- 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73,
- 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74,
- 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d,
- 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c,
- 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65,
- 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75,
- 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a,
- 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03,
- 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e,
- 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63,
- 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63,
- 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 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, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
- 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63,
- 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52,
- 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61,
- 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73,
- 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
- 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52,
- 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
- 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61,
- 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52,
- 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73,
- 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
- 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
- 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72,
- 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e,
- 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62,
- 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e,
- 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b,
- 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62,
- 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
- 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a,
- 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69,
- 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46,
- 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44,
+ 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72,
+ 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c,
+ 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72,
+ 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03,
+ 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f,
+ 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12,
+ 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53,
+ 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82,
+ 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73,
+ 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f,
+ 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18,
+ 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65,
+ 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e,
+ 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75,
+ 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45,
+ 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65,
+ 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 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, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c,
+ 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65,
+ 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
+ 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73,
+ 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64,
+ 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65,
+ 0x78, 0x65, 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65,
+ 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75,
+ 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75,
+ 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49,
+ 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65,
+ 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09,
+ 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64,
+ 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a,
+ 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+ 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e,
+ 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c,
+ 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09,
+ 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54,
+ 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f,
+ 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75,
+ 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75,
+ 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12,
+ 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44,
0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69,
- 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64,
- 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68,
- 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72,
- 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
- 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
- 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f,
- 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f,
- 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
- 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c,
- 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f,
- 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15,
- 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f,
- 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65,
- 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69,
- 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69,
- 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xb8, 0x03, 0x0a, 0x0e, 0x50, 0x72,
- 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08,
- 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
- 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x12, 0x22, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65,
- 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
- 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06,
- 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f,
- 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65,
- 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65,
- 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e,
- 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65,
- 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74,
- 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e,
- 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e,
- 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18,
- 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a,
- 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08,
- 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15,
- 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64,
- 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74,
- 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69,
- 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52,
- 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65,
- 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c,
- 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70,
- 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46,
- 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
- 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e,
- 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18,
- 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77,
- 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e,
- 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65,
- 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16,
- 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06,
- 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65,
- 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71,
- 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18,
- 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07,
- 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44,
- 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f,
- 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52,
- 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f,
- 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69,
- 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44,
- 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76,
- 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
- 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47,
- 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75,
- 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
- 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65,
- 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f,
- 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d,
- 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65,
- 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f,
- 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f,
- 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb4, 0x01, 0x0a, 0x0a,
- 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f,
- 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61,
- 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20,
- 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52,
- 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68,
- 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61,
- 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x53, 0x6b,
- 0x69, 0x70, 0x50, 0x54, 0x52, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x0e, 0x53, 0x6b, 0x69, 0x70, 0x50, 0x54, 0x52, 0x50, 0x72, 0x6f, 0x63, 0x65,
- 0x73, 0x73, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f,
- 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02,
- 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6c,
- 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73,
- 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54,
- 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d,
- 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b,
- 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
- 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e,
- 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53,
- 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72,
- 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79,
- 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
- 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65,
- 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c,
- 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68,
- 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48,
- 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02,
- 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06,
- 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53,
- 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01,
- 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa7, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72,
- 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x50, 0x65, 0x65,
- 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49,
- 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02,
- 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52,
- 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63,
- 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e,
- 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69,
- 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72,
- 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72,
- 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c,
- 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
- 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f,
- 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
- 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f,
- 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79,
- 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79,
- 0x49, 0x44, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64,
- 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61,
- 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06,
- 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18,
- 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a,
- 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72,
- 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12,
- 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
+ 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52,
+ 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f,
+ 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
+ 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50,
+ 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a,
+ 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53,
+ 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74,
+ 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74,
+ 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42,
+ 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x22, 0xb8, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43,
+ 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49,
+ 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49,
+ 0x44, 0x12, 0x22, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65,
+ 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53,
+ 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18,
+ 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a,
+ 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76,
+ 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18,
+ 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74,
+ 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b,
+ 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12,
+ 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
+ 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f,
+ 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44,
+ 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
+ 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74,
+ 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52,
+ 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28,
+ 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12,
+ 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74,
+ 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73,
+ 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12,
+ 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01,
+ 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02,
+ 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f,
+ 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
+ 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65,
+ 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54,
+ 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69,
+ 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12,
+ 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20,
+ 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12,
+ 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
+ 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73,
+ 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12,
+ 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a,
+ 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70,
+ 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62,
+ 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
+ 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53,
+ 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
+ 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e,
+ 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10,
+ 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73,
+ 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18,
+ 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43,
+ 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f,
+ 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72,
+ 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a,
+ 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52,
+ 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65,
+ 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12,
+ 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44,
+ 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53,
+ 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62,
+ 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72,
+ 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e,
+ 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22,
+ 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12,
+ 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e,
+ 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73,
+ 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a,
+ 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12,
+ 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
+ 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65,
+ 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d,
+ 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65,
+ 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76,
+ 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a,
+ 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07,
+ 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63,
+ 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18,
+ 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d,
+ 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e,
+ 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54,
+ 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70,
+ 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52,
+ 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa7, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61,
+ 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37,
+ 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52,
+ 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69,
+ 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f,
+ 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52,
+ 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f,
+ 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f,
+ 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a,
+ 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72,
+ 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49,
+ 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18,
+ 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x22,
+ 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
+ 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65,
+ 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03,
+ 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f,
+ 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05,
+ 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
+ 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65,
+ 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61,
+ 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12,
+ 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e,
+ 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
+ 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65,
+ 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72,
+ 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06,
+ 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63,
+ 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b,
+ 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34,
+ 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e,
+ 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75,
+ 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74,
+ 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f,
+ 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f,
+ 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61,
+ 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e,
+ 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18,
+ 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26,
+ 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c,
+ 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72,
+ 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79,
+ 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79,
+ 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a,
+ 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12,
+ 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52,
+ 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61,
+ 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74,
- 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61,
- 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05,
- 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61,
- 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52,
- 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65,
- 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46,
- 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73,
- 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
- 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12,
- 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32,
- 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c,
- 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12,
- 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f,
- 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20,
- 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70,
- 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49,
- 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52,
- 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44,
- 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73,
- 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69,
- 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
- 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f,
- 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f,
- 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c,
- 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c,
- 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44,
- 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22,
- 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75,
- 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08,
- 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74,
- 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28,
- 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
- 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61,
- 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e,
- 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20,
- 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41,
- 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c,
- 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74,
- 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64,
- 0x50, 0x6f, 0x72, 0x74, 0x2a, 0x4c, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74,
- 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10,
- 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43,
- 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04,
- 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d,
- 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74,
- 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f,
- 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69,
- 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08,
- 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x32, 0xcd, 0x04, 0x0a, 0x11, 0x4d, 0x61, 0x6e,
- 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45,
- 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
- 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
- 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e,
+ 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f,
+ 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61,
+ 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c,
+ 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72,
+ 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65,
+ 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
+ 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72,
+ 0x74, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12,
+ 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
+ 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x10,
+ 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x4c, 0x0a,
+ 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a,
+ 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c,
+ 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03,
+ 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12,
+ 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, 0x52,
+ 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02,
+ 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a,
+ 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41,
+ 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10,
+ 0x01, 0x32, 0x96, 0x05, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
+ 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
+ 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
+ 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46,
+ 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73,
+ 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+ 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
+ 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72,
+ 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79,
+ 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73,
+ 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12,
+ 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68,
+ 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e,
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79,
0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61,
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
- 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a,
- 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
- 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65,
- 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
- 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74,
- 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
- 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76,
- 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e,
- 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
- 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
- 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74,
- 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
- 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
- 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08,
- 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
- 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
- 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c,
- 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47,
+ 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74,
+ 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
+ 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
- 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+ 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74,
+ 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
+ 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a,
+ 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70,
+ 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
+ 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22,
+ 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
+ 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73,
+ 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70,
+ 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@@ -4299,142 +4686,152 @@ func file_management_proto_rawDescGZIP() []byte {
return file_management_proto_rawDescData
}
-var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
-var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 45)
+var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 6)
+var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 49)
var file_management_proto_goTypes = []interface{}{
- (RuleProtocol)(0), // 0: management.RuleProtocol
- (RuleDirection)(0), // 1: management.RuleDirection
- (RuleAction)(0), // 2: management.RuleAction
- (HostConfig_Protocol)(0), // 3: management.HostConfig.Protocol
- (DeviceAuthorizationFlowProvider)(0), // 4: management.DeviceAuthorizationFlow.provider
- (*EncryptedMessage)(nil), // 5: management.EncryptedMessage
- (*SyncRequest)(nil), // 6: management.SyncRequest
- (*SyncResponse)(nil), // 7: management.SyncResponse
- (*SyncMetaRequest)(nil), // 8: management.SyncMetaRequest
- (*LoginRequest)(nil), // 9: management.LoginRequest
- (*PeerKeys)(nil), // 10: management.PeerKeys
- (*Environment)(nil), // 11: management.Environment
- (*File)(nil), // 12: management.File
- (*Flags)(nil), // 13: management.Flags
- (*PeerSystemMeta)(nil), // 14: management.PeerSystemMeta
- (*LoginResponse)(nil), // 15: management.LoginResponse
- (*ServerKeyResponse)(nil), // 16: management.ServerKeyResponse
- (*Empty)(nil), // 17: management.Empty
- (*NetbirdConfig)(nil), // 18: management.NetbirdConfig
- (*HostConfig)(nil), // 19: management.HostConfig
- (*RelayConfig)(nil), // 20: management.RelayConfig
- (*FlowConfig)(nil), // 21: management.FlowConfig
- (*JWTConfig)(nil), // 22: management.JWTConfig
- (*ProtectedHostConfig)(nil), // 23: management.ProtectedHostConfig
- (*PeerConfig)(nil), // 24: management.PeerConfig
- (*AutoUpdateSettings)(nil), // 25: management.AutoUpdateSettings
- (*NetworkMap)(nil), // 26: management.NetworkMap
- (*SSHAuth)(nil), // 27: management.SSHAuth
- (*MachineUserIndexes)(nil), // 28: management.MachineUserIndexes
- (*RemotePeerConfig)(nil), // 29: management.RemotePeerConfig
- (*SSHConfig)(nil), // 30: management.SSHConfig
- (*DeviceAuthorizationFlowRequest)(nil), // 31: management.DeviceAuthorizationFlowRequest
- (*DeviceAuthorizationFlow)(nil), // 32: management.DeviceAuthorizationFlow
- (*PKCEAuthorizationFlowRequest)(nil), // 33: management.PKCEAuthorizationFlowRequest
- (*PKCEAuthorizationFlow)(nil), // 34: management.PKCEAuthorizationFlow
- (*ProviderConfig)(nil), // 35: management.ProviderConfig
- (*Route)(nil), // 36: management.Route
- (*DNSConfig)(nil), // 37: management.DNSConfig
- (*CustomZone)(nil), // 38: management.CustomZone
- (*SimpleRecord)(nil), // 39: management.SimpleRecord
- (*NameServerGroup)(nil), // 40: management.NameServerGroup
- (*NameServer)(nil), // 41: management.NameServer
- (*FirewallRule)(nil), // 42: management.FirewallRule
- (*NetworkAddress)(nil), // 43: management.NetworkAddress
- (*Checks)(nil), // 44: management.Checks
- (*PortInfo)(nil), // 45: management.PortInfo
- (*RouteFirewallRule)(nil), // 46: management.RouteFirewallRule
- (*ForwardingRule)(nil), // 47: management.ForwardingRule
- nil, // 48: management.SSHAuth.MachineUsersEntry
- (*PortInfo_Range)(nil), // 49: management.PortInfo.Range
- (*timestamppb.Timestamp)(nil), // 50: google.protobuf.Timestamp
- (*durationpb.Duration)(nil), // 51: google.protobuf.Duration
+ (JobStatus)(0), // 0: management.JobStatus
+ (RuleProtocol)(0), // 1: management.RuleProtocol
+ (RuleDirection)(0), // 2: management.RuleDirection
+ (RuleAction)(0), // 3: management.RuleAction
+ (HostConfig_Protocol)(0), // 4: management.HostConfig.Protocol
+ (DeviceAuthorizationFlowProvider)(0), // 5: management.DeviceAuthorizationFlow.provider
+ (*EncryptedMessage)(nil), // 6: management.EncryptedMessage
+ (*JobRequest)(nil), // 7: management.JobRequest
+ (*JobResponse)(nil), // 8: management.JobResponse
+ (*BundleParameters)(nil), // 9: management.BundleParameters
+ (*BundleResult)(nil), // 10: management.BundleResult
+ (*SyncRequest)(nil), // 11: management.SyncRequest
+ (*SyncResponse)(nil), // 12: management.SyncResponse
+ (*SyncMetaRequest)(nil), // 13: management.SyncMetaRequest
+ (*LoginRequest)(nil), // 14: management.LoginRequest
+ (*PeerKeys)(nil), // 15: management.PeerKeys
+ (*Environment)(nil), // 16: management.Environment
+ (*File)(nil), // 17: management.File
+ (*Flags)(nil), // 18: management.Flags
+ (*PeerSystemMeta)(nil), // 19: management.PeerSystemMeta
+ (*LoginResponse)(nil), // 20: management.LoginResponse
+ (*ServerKeyResponse)(nil), // 21: management.ServerKeyResponse
+ (*Empty)(nil), // 22: management.Empty
+ (*NetbirdConfig)(nil), // 23: management.NetbirdConfig
+ (*HostConfig)(nil), // 24: management.HostConfig
+ (*RelayConfig)(nil), // 25: management.RelayConfig
+ (*FlowConfig)(nil), // 26: management.FlowConfig
+ (*JWTConfig)(nil), // 27: management.JWTConfig
+ (*ProtectedHostConfig)(nil), // 28: management.ProtectedHostConfig
+ (*PeerConfig)(nil), // 29: management.PeerConfig
+ (*AutoUpdateSettings)(nil), // 30: management.AutoUpdateSettings
+ (*NetworkMap)(nil), // 31: management.NetworkMap
+ (*SSHAuth)(nil), // 32: management.SSHAuth
+ (*MachineUserIndexes)(nil), // 33: management.MachineUserIndexes
+ (*RemotePeerConfig)(nil), // 34: management.RemotePeerConfig
+ (*SSHConfig)(nil), // 35: management.SSHConfig
+ (*DeviceAuthorizationFlowRequest)(nil), // 36: management.DeviceAuthorizationFlowRequest
+ (*DeviceAuthorizationFlow)(nil), // 37: management.DeviceAuthorizationFlow
+ (*PKCEAuthorizationFlowRequest)(nil), // 38: management.PKCEAuthorizationFlowRequest
+ (*PKCEAuthorizationFlow)(nil), // 39: management.PKCEAuthorizationFlow
+ (*ProviderConfig)(nil), // 40: management.ProviderConfig
+ (*Route)(nil), // 41: management.Route
+ (*DNSConfig)(nil), // 42: management.DNSConfig
+ (*CustomZone)(nil), // 43: management.CustomZone
+ (*SimpleRecord)(nil), // 44: management.SimpleRecord
+ (*NameServerGroup)(nil), // 45: management.NameServerGroup
+ (*NameServer)(nil), // 46: management.NameServer
+ (*FirewallRule)(nil), // 47: management.FirewallRule
+ (*NetworkAddress)(nil), // 48: management.NetworkAddress
+ (*Checks)(nil), // 49: management.Checks
+ (*PortInfo)(nil), // 50: management.PortInfo
+ (*RouteFirewallRule)(nil), // 51: management.RouteFirewallRule
+ (*ForwardingRule)(nil), // 52: management.ForwardingRule
+ nil, // 53: management.SSHAuth.MachineUsersEntry
+ (*PortInfo_Range)(nil), // 54: management.PortInfo.Range
+ (*timestamppb.Timestamp)(nil), // 55: google.protobuf.Timestamp
+ (*durationpb.Duration)(nil), // 56: google.protobuf.Duration
}
var file_management_proto_depIdxs = []int32{
- 14, // 0: management.SyncRequest.meta:type_name -> management.PeerSystemMeta
- 18, // 1: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig
- 24, // 2: management.SyncResponse.peerConfig:type_name -> management.PeerConfig
- 29, // 3: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
- 26, // 4: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
- 44, // 5: management.SyncResponse.Checks:type_name -> management.Checks
- 14, // 6: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta
- 14, // 7: management.LoginRequest.meta:type_name -> management.PeerSystemMeta
- 10, // 8: management.LoginRequest.peerKeys:type_name -> management.PeerKeys
- 43, // 9: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress
- 11, // 10: management.PeerSystemMeta.environment:type_name -> management.Environment
- 12, // 11: management.PeerSystemMeta.files:type_name -> management.File
- 13, // 12: management.PeerSystemMeta.flags:type_name -> management.Flags
- 18, // 13: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig
- 24, // 14: management.LoginResponse.peerConfig:type_name -> management.PeerConfig
- 44, // 15: management.LoginResponse.Checks:type_name -> management.Checks
- 50, // 16: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
- 19, // 17: management.NetbirdConfig.stuns:type_name -> management.HostConfig
- 23, // 18: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig
- 19, // 19: management.NetbirdConfig.signal:type_name -> management.HostConfig
- 20, // 20: management.NetbirdConfig.relay:type_name -> management.RelayConfig
- 21, // 21: management.NetbirdConfig.flow:type_name -> management.FlowConfig
- 3, // 22: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol
- 51, // 23: management.FlowConfig.interval:type_name -> google.protobuf.Duration
- 19, // 24: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
- 30, // 25: management.PeerConfig.sshConfig:type_name -> management.SSHConfig
- 25, // 26: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings
- 24, // 27: management.NetworkMap.peerConfig:type_name -> management.PeerConfig
- 29, // 28: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
- 36, // 29: management.NetworkMap.Routes:type_name -> management.Route
- 37, // 30: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
- 29, // 31: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
- 42, // 32: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
- 46, // 33: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
- 47, // 34: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
- 27, // 35: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
- 48, // 36: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
- 30, // 37: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
- 22, // 38: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
- 4, // 39: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
- 35, // 40: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
- 35, // 41: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
- 40, // 42: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
- 38, // 43: management.DNSConfig.CustomZones:type_name -> management.CustomZone
- 39, // 44: management.CustomZone.Records:type_name -> management.SimpleRecord
- 41, // 45: management.NameServerGroup.NameServers:type_name -> management.NameServer
- 1, // 46: management.FirewallRule.Direction:type_name -> management.RuleDirection
- 2, // 47: management.FirewallRule.Action:type_name -> management.RuleAction
- 0, // 48: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
- 45, // 49: management.FirewallRule.PortInfo:type_name -> management.PortInfo
- 49, // 50: management.PortInfo.range:type_name -> management.PortInfo.Range
- 2, // 51: management.RouteFirewallRule.action:type_name -> management.RuleAction
- 0, // 52: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
- 45, // 53: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
- 0, // 54: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
- 45, // 55: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
- 45, // 56: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
- 28, // 57: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
- 5, // 58: management.ManagementService.Login:input_type -> management.EncryptedMessage
- 5, // 59: management.ManagementService.Sync:input_type -> management.EncryptedMessage
- 17, // 60: management.ManagementService.GetServerKey:input_type -> management.Empty
- 17, // 61: management.ManagementService.isHealthy:input_type -> management.Empty
- 5, // 62: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
- 5, // 63: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
- 5, // 64: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
- 5, // 65: management.ManagementService.Logout:input_type -> management.EncryptedMessage
- 5, // 66: management.ManagementService.Login:output_type -> management.EncryptedMessage
- 5, // 67: management.ManagementService.Sync:output_type -> management.EncryptedMessage
- 16, // 68: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
- 17, // 69: management.ManagementService.isHealthy:output_type -> management.Empty
- 5, // 70: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
- 5, // 71: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
- 17, // 72: management.ManagementService.SyncMeta:output_type -> management.Empty
- 17, // 73: management.ManagementService.Logout:output_type -> management.Empty
- 66, // [66:74] is the sub-list for method output_type
- 58, // [58:66] is the sub-list for method input_type
- 58, // [58:58] is the sub-list for extension type_name
- 58, // [58:58] is the sub-list for extension extendee
- 0, // [0:58] is the sub-list for field type_name
+ 9, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters
+ 0, // 1: management.JobResponse.status:type_name -> management.JobStatus
+ 10, // 2: management.JobResponse.bundle:type_name -> management.BundleResult
+ 19, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta
+ 23, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig
+ 29, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig
+ 34, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
+ 31, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
+ 49, // 8: management.SyncResponse.Checks:type_name -> management.Checks
+ 19, // 9: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta
+ 19, // 10: management.LoginRequest.meta:type_name -> management.PeerSystemMeta
+ 15, // 11: management.LoginRequest.peerKeys:type_name -> management.PeerKeys
+ 48, // 12: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress
+ 16, // 13: management.PeerSystemMeta.environment:type_name -> management.Environment
+ 17, // 14: management.PeerSystemMeta.files:type_name -> management.File
+ 18, // 15: management.PeerSystemMeta.flags:type_name -> management.Flags
+ 23, // 16: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig
+ 29, // 17: management.LoginResponse.peerConfig:type_name -> management.PeerConfig
+ 49, // 18: management.LoginResponse.Checks:type_name -> management.Checks
+ 55, // 19: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
+ 24, // 20: management.NetbirdConfig.stuns:type_name -> management.HostConfig
+ 28, // 21: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig
+ 24, // 22: management.NetbirdConfig.signal:type_name -> management.HostConfig
+ 25, // 23: management.NetbirdConfig.relay:type_name -> management.RelayConfig
+ 26, // 24: management.NetbirdConfig.flow:type_name -> management.FlowConfig
+ 4, // 25: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol
+ 56, // 26: management.FlowConfig.interval:type_name -> google.protobuf.Duration
+ 24, // 27: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
+ 35, // 28: management.PeerConfig.sshConfig:type_name -> management.SSHConfig
+ 30, // 29: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings
+ 29, // 30: management.NetworkMap.peerConfig:type_name -> management.PeerConfig
+ 34, // 31: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
+ 41, // 32: management.NetworkMap.Routes:type_name -> management.Route
+ 42, // 33: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
+ 34, // 34: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
+ 47, // 35: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
+ 51, // 36: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
+ 52, // 37: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
+ 32, // 38: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
+ 53, // 39: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
+ 35, // 40: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
+ 27, // 41: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
+ 5, // 42: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
+ 40, // 43: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+ 40, // 44: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+ 45, // 45: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
+ 43, // 46: management.DNSConfig.CustomZones:type_name -> management.CustomZone
+ 44, // 47: management.CustomZone.Records:type_name -> management.SimpleRecord
+ 46, // 48: management.NameServerGroup.NameServers:type_name -> management.NameServer
+ 2, // 49: management.FirewallRule.Direction:type_name -> management.RuleDirection
+ 3, // 50: management.FirewallRule.Action:type_name -> management.RuleAction
+ 1, // 51: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
+ 50, // 52: management.FirewallRule.PortInfo:type_name -> management.PortInfo
+ 54, // 53: management.PortInfo.range:type_name -> management.PortInfo.Range
+ 3, // 54: management.RouteFirewallRule.action:type_name -> management.RuleAction
+ 1, // 55: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
+ 50, // 56: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
+ 1, // 57: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
+ 50, // 58: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
+ 50, // 59: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
+ 33, // 60: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
+ 6, // 61: management.ManagementService.Login:input_type -> management.EncryptedMessage
+ 6, // 62: management.ManagementService.Sync:input_type -> management.EncryptedMessage
+ 22, // 63: management.ManagementService.GetServerKey:input_type -> management.Empty
+ 22, // 64: management.ManagementService.isHealthy:input_type -> management.Empty
+ 6, // 65: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
+ 6, // 66: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
+ 6, // 67: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
+ 6, // 68: management.ManagementService.Logout:input_type -> management.EncryptedMessage
+ 6, // 69: management.ManagementService.Job:input_type -> management.EncryptedMessage
+ 6, // 70: management.ManagementService.Login:output_type -> management.EncryptedMessage
+ 6, // 71: management.ManagementService.Sync:output_type -> management.EncryptedMessage
+ 21, // 72: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
+ 22, // 73: management.ManagementService.isHealthy:output_type -> management.Empty
+ 6, // 74: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
+ 6, // 75: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
+ 22, // 76: management.ManagementService.SyncMeta:output_type -> management.Empty
+ 22, // 77: management.ManagementService.Logout:output_type -> management.Empty
+ 6, // 78: management.ManagementService.Job:output_type -> management.EncryptedMessage
+ 70, // [70:79] is the sub-list for method output_type
+ 61, // [61:70] is the sub-list for method input_type
+ 61, // [61:61] is the sub-list for extension type_name
+ 61, // [61:61] is the sub-list for extension extendee
+ 0, // [0:61] is the sub-list for field type_name
}
func init() { file_management_proto_init() }
@@ -4456,7 +4853,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SyncRequest); i {
+ switch v := v.(*JobRequest); i {
case 0:
return &v.state
case 1:
@@ -4468,7 +4865,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SyncResponse); i {
+ switch v := v.(*JobResponse); i {
case 0:
return &v.state
case 1:
@@ -4480,7 +4877,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SyncMetaRequest); i {
+ switch v := v.(*BundleParameters); i {
case 0:
return &v.state
case 1:
@@ -4492,7 +4889,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*LoginRequest); i {
+ switch v := v.(*BundleResult); i {
case 0:
return &v.state
case 1:
@@ -4504,7 +4901,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PeerKeys); i {
+ switch v := v.(*SyncRequest); i {
case 0:
return &v.state
case 1:
@@ -4516,7 +4913,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Environment); i {
+ switch v := v.(*SyncResponse); i {
case 0:
return &v.state
case 1:
@@ -4528,7 +4925,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*File); i {
+ switch v := v.(*SyncMetaRequest); i {
case 0:
return &v.state
case 1:
@@ -4540,7 +4937,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Flags); i {
+ switch v := v.(*LoginRequest); i {
case 0:
return &v.state
case 1:
@@ -4552,7 +4949,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PeerSystemMeta); i {
+ switch v := v.(*PeerKeys); i {
case 0:
return &v.state
case 1:
@@ -4564,7 +4961,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*LoginResponse); i {
+ switch v := v.(*Environment); i {
case 0:
return &v.state
case 1:
@@ -4576,7 +4973,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ServerKeyResponse); i {
+ switch v := v.(*File); i {
case 0:
return &v.state
case 1:
@@ -4588,7 +4985,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Empty); i {
+ switch v := v.(*Flags); i {
case 0:
return &v.state
case 1:
@@ -4600,7 +4997,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NetbirdConfig); i {
+ switch v := v.(*PeerSystemMeta); i {
case 0:
return &v.state
case 1:
@@ -4612,7 +5009,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*HostConfig); i {
+ switch v := v.(*LoginResponse); i {
case 0:
return &v.state
case 1:
@@ -4624,7 +5021,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RelayConfig); i {
+ switch v := v.(*ServerKeyResponse); i {
case 0:
return &v.state
case 1:
@@ -4636,7 +5033,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*FlowConfig); i {
+ switch v := v.(*Empty); i {
case 0:
return &v.state
case 1:
@@ -4648,7 +5045,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*JWTConfig); i {
+ switch v := v.(*NetbirdConfig); i {
case 0:
return &v.state
case 1:
@@ -4660,7 +5057,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ProtectedHostConfig); i {
+ switch v := v.(*HostConfig); i {
case 0:
return &v.state
case 1:
@@ -4672,7 +5069,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PeerConfig); i {
+ switch v := v.(*RelayConfig); i {
case 0:
return &v.state
case 1:
@@ -4684,7 +5081,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*AutoUpdateSettings); i {
+ switch v := v.(*FlowConfig); i {
case 0:
return &v.state
case 1:
@@ -4696,7 +5093,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NetworkMap); i {
+ switch v := v.(*JWTConfig); i {
case 0:
return &v.state
case 1:
@@ -4708,7 +5105,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SSHAuth); i {
+ switch v := v.(*ProtectedHostConfig); i {
case 0:
return &v.state
case 1:
@@ -4720,7 +5117,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*MachineUserIndexes); i {
+ switch v := v.(*PeerConfig); i {
case 0:
return &v.state
case 1:
@@ -4732,7 +5129,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RemotePeerConfig); i {
+ switch v := v.(*AutoUpdateSettings); i {
case 0:
return &v.state
case 1:
@@ -4744,7 +5141,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SSHConfig); i {
+ switch v := v.(*NetworkMap); i {
case 0:
return &v.state
case 1:
@@ -4756,7 +5153,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*DeviceAuthorizationFlowRequest); i {
+ switch v := v.(*SSHAuth); i {
case 0:
return &v.state
case 1:
@@ -4768,7 +5165,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*DeviceAuthorizationFlow); i {
+ switch v := v.(*MachineUserIndexes); i {
case 0:
return &v.state
case 1:
@@ -4780,7 +5177,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PKCEAuthorizationFlowRequest); i {
+ switch v := v.(*RemotePeerConfig); i {
case 0:
return &v.state
case 1:
@@ -4792,7 +5189,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PKCEAuthorizationFlow); i {
+ switch v := v.(*SSHConfig); i {
case 0:
return &v.state
case 1:
@@ -4804,7 +5201,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ProviderConfig); i {
+ switch v := v.(*DeviceAuthorizationFlowRequest); i {
case 0:
return &v.state
case 1:
@@ -4816,7 +5213,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Route); i {
+ switch v := v.(*DeviceAuthorizationFlow); i {
case 0:
return &v.state
case 1:
@@ -4828,7 +5225,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*DNSConfig); i {
+ switch v := v.(*PKCEAuthorizationFlowRequest); i {
case 0:
return &v.state
case 1:
@@ -4840,7 +5237,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*CustomZone); i {
+ switch v := v.(*PKCEAuthorizationFlow); i {
case 0:
return &v.state
case 1:
@@ -4852,7 +5249,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SimpleRecord); i {
+ switch v := v.(*ProviderConfig); i {
case 0:
return &v.state
case 1:
@@ -4864,7 +5261,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NameServerGroup); i {
+ switch v := v.(*Route); i {
case 0:
return &v.state
case 1:
@@ -4876,7 +5273,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NameServer); i {
+ switch v := v.(*DNSConfig); i {
case 0:
return &v.state
case 1:
@@ -4888,7 +5285,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*FirewallRule); i {
+ switch v := v.(*CustomZone); i {
case 0:
return &v.state
case 1:
@@ -4900,7 +5297,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NetworkAddress); i {
+ switch v := v.(*SimpleRecord); i {
case 0:
return &v.state
case 1:
@@ -4912,7 +5309,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Checks); i {
+ switch v := v.(*NameServerGroup); i {
case 0:
return &v.state
case 1:
@@ -4924,7 +5321,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PortInfo); i {
+ switch v := v.(*NameServer); i {
case 0:
return &v.state
case 1:
@@ -4936,7 +5333,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RouteFirewallRule); i {
+ switch v := v.(*FirewallRule); i {
case 0:
return &v.state
case 1:
@@ -4948,7 +5345,19 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ForwardingRule); i {
+ switch v := v.(*NetworkAddress); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_management_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*Checks); i {
case 0:
return &v.state
case 1:
@@ -4960,6 +5369,42 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*PortInfo); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_management_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*RouteFirewallRule); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_management_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*ForwardingRule); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_management_proto_msgTypes[48].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*PortInfo_Range); i {
case 0:
return &v.state
@@ -4972,7 +5417,13 @@ func file_management_proto_init() {
}
}
}
- file_management_proto_msgTypes[40].OneofWrappers = []interface{}{
+ file_management_proto_msgTypes[1].OneofWrappers = []interface{}{
+ (*JobRequest_Bundle)(nil),
+ }
+ file_management_proto_msgTypes[2].OneofWrappers = []interface{}{
+ (*JobResponse_Bundle)(nil),
+ }
+ file_management_proto_msgTypes[44].OneofWrappers = []interface{}{
(*PortInfo_Port)(nil),
(*PortInfo_Range_)(nil),
}
@@ -4981,8 +5432,8 @@ func file_management_proto_init() {
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_management_proto_rawDesc,
- NumEnums: 5,
- NumMessages: 45,
+ NumEnums: 6,
+ NumMessages: 49,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto
index f2e591e88..d97d66819 100644
--- a/shared/management/proto/management.proto
+++ b/shared/management/proto/management.proto
@@ -48,6 +48,9 @@ service ManagementService {
// Logout logs out the peer and removes it from the management server
rpc Logout(EncryptedMessage) returns (Empty) {}
+
+ // Executes a job on a target peer (e.g., debug bundle)
+ rpc Job(stream EncryptedMessage) returns (stream EncryptedMessage) {}
}
message EncryptedMessage {
@@ -60,6 +63,42 @@ message EncryptedMessage {
int32 version = 3;
}
+message JobRequest {
+ bytes ID = 1;
+
+ oneof workload_parameters {
+ BundleParameters bundle = 10;
+ //OtherParameters other = 11;
+ }
+}
+
+enum JobStatus {
+ unknown_status = 0; //placeholder
+ succeeded = 1;
+ failed = 2;
+}
+
+message JobResponse{
+ bytes ID = 1;
+ JobStatus status=2;
+ bytes Reason=3;
+ oneof workload_results {
+ BundleResult bundle = 10;
+ //OtherResult other = 11;
+ }
+}
+
+message BundleParameters {
+ bool bundle_for = 1;
+ int64 bundle_for_time = 2;
+ int32 log_file_count = 3;
+ bool anonymize = 4;
+}
+
+message BundleResult {
+ string upload_key = 1;
+}
+
message SyncRequest {
// Meta data of the peer
PeerSystemMeta meta = 1;
@@ -246,12 +285,16 @@ message FlowConfig {
bool dnsCollection = 8;
}
-// JWTConfig represents JWT authentication configuration
+// JWTConfig represents JWT authentication configuration for validating tokens.
message JWTConfig {
string issuer = 1;
+ // Deprecated: audience is kept for backwards compatibility only. Use audiences instead in the client code but populate this field.
string audience = 2;
string keysLocation = 3;
int64 maxTokenAge = 4;
+ // audiences contains the list of valid audiences for JWT validation.
+ // Tokens matching any audience in this list are considered valid.
+ repeated string audiences = 5;
}
// ProtectedHostConfig is similar to HostConfig but has additional user and password
@@ -464,7 +507,9 @@ message CustomZone {
string Domain = 1;
repeated SimpleRecord Records = 2;
bool SearchDomainDisabled = 3;
- bool SkipPTRProcess = 4;
+ // NonAuthoritative indicates this is a user-created zone (not the built-in peer DNS zone).
+ // Non-authoritative zones will fallthrough to lower-priority handlers on NXDOMAIN and skip PTR processing.
+ bool NonAuthoritative = 4;
}
// SimpleRecord represents a dns.SimpleRecord
diff --git a/shared/management/proto/management_grpc.pb.go b/shared/management/proto/management_grpc.pb.go
index 5b189334d..b78e21aaa 100644
--- a/shared/management/proto/management_grpc.pb.go
+++ b/shared/management/proto/management_grpc.pb.go
@@ -50,6 +50,8 @@ type ManagementServiceClient interface {
SyncMeta(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*Empty, error)
// Logout logs out the peer and removes it from the management server
Logout(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*Empty, error)
+ // Executes a job on a target peer (e.g., debug bundle)
+ Job(ctx context.Context, opts ...grpc.CallOption) (ManagementService_JobClient, error)
}
type managementServiceClient struct {
@@ -155,6 +157,37 @@ func (c *managementServiceClient) Logout(ctx context.Context, in *EncryptedMessa
return out, nil
}
+func (c *managementServiceClient) Job(ctx context.Context, opts ...grpc.CallOption) (ManagementService_JobClient, error) {
+ stream, err := c.cc.NewStream(ctx, &ManagementService_ServiceDesc.Streams[1], "/management.ManagementService/Job", opts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &managementServiceJobClient{stream}
+ return x, nil
+}
+
+type ManagementService_JobClient interface {
+ Send(*EncryptedMessage) error
+ Recv() (*EncryptedMessage, error)
+ grpc.ClientStream
+}
+
+type managementServiceJobClient struct {
+ grpc.ClientStream
+}
+
+func (x *managementServiceJobClient) Send(m *EncryptedMessage) error {
+ return x.ClientStream.SendMsg(m)
+}
+
+func (x *managementServiceJobClient) Recv() (*EncryptedMessage, error) {
+ m := new(EncryptedMessage)
+ if err := x.ClientStream.RecvMsg(m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
// ManagementServiceServer is the server API for ManagementService service.
// All implementations must embed UnimplementedManagementServiceServer
// for forward compatibility
@@ -191,6 +224,8 @@ type ManagementServiceServer interface {
SyncMeta(context.Context, *EncryptedMessage) (*Empty, error)
// Logout logs out the peer and removes it from the management server
Logout(context.Context, *EncryptedMessage) (*Empty, error)
+ // Executes a job on a target peer (e.g., debug bundle)
+ Job(ManagementService_JobServer) error
mustEmbedUnimplementedManagementServiceServer()
}
@@ -222,6 +257,9 @@ func (UnimplementedManagementServiceServer) SyncMeta(context.Context, *Encrypted
func (UnimplementedManagementServiceServer) Logout(context.Context, *EncryptedMessage) (*Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented")
}
+func (UnimplementedManagementServiceServer) Job(ManagementService_JobServer) error {
+ return status.Errorf(codes.Unimplemented, "method Job not implemented")
+}
func (UnimplementedManagementServiceServer) mustEmbedUnimplementedManagementServiceServer() {}
// UnsafeManagementServiceServer may be embedded to opt out of forward compatibility for this service.
@@ -382,6 +420,32 @@ func _ManagementService_Logout_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
+func _ManagementService_Job_Handler(srv interface{}, stream grpc.ServerStream) error {
+ return srv.(ManagementServiceServer).Job(&managementServiceJobServer{stream})
+}
+
+type ManagementService_JobServer interface {
+ Send(*EncryptedMessage) error
+ Recv() (*EncryptedMessage, error)
+ grpc.ServerStream
+}
+
+type managementServiceJobServer struct {
+ grpc.ServerStream
+}
+
+func (x *managementServiceJobServer) Send(m *EncryptedMessage) error {
+ return x.ServerStream.SendMsg(m)
+}
+
+func (x *managementServiceJobServer) Recv() (*EncryptedMessage, error) {
+ m := new(EncryptedMessage)
+ if err := x.ServerStream.RecvMsg(m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
// ManagementService_ServiceDesc is the grpc.ServiceDesc for ManagementService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -424,6 +488,12 @@ var ManagementService_ServiceDesc = grpc.ServiceDesc{
Handler: _ManagementService_Sync_Handler,
ServerStreams: true,
},
+ {
+ StreamName: "Job",
+ Handler: _ManagementService_Job_Handler,
+ ServerStreams: true,
+ ClientStreams: true,
+ },
},
Metadata: "management.proto",
}
diff --git a/shared/management/status/error.go b/shared/management/status/error.go
index 09676847e..ea02173e9 100644
--- a/shared/management/status/error.go
+++ b/shared/management/status/error.go
@@ -252,3 +252,13 @@ func NewOperationNotFoundError(operation operations.Operation) error {
func NewRouteNotFoundError(routeID string) error {
return Errorf(NotFound, "route: %s not found", routeID)
}
+
+// NewZoneNotFoundError creates a new Error with NotFound type for a missing dns zone.
+func NewZoneNotFoundError(zoneID string) error {
+ return Errorf(NotFound, "zone: %s not found", zoneID)
+}
+
+// NewDNSRecordNotFoundError creates a new Error with NotFound type for a missing dns record.
+func NewDNSRecordNotFoundError(recordID string) error {
+ return Errorf(NotFound, "dns record: %s not found", recordID)
+}
diff --git a/stun/server.go b/stun/server.go
new file mode 100644
index 000000000..be5717d48
--- /dev/null
+++ b/stun/server.go
@@ -0,0 +1,170 @@
+// Package stun provides an embedded STUN server for NAT traversal discovery.
+package stun
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "sync"
+
+ "github.com/hashicorp/go-multierror"
+ nberrors "github.com/netbirdio/netbird/client/errors"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/formatter"
+ "github.com/pion/stun/v3"
+)
+
+// ErrServerClosed is returned by Listen when the server is shut down gracefully.
+var ErrServerClosed = errors.New("stun: server closed")
+
+// ErrNoListeners is returned by Listen when no UDP connections were provided.
+var ErrNoListeners = errors.New("stun: no listeners configured")
+
+// Server implements a STUN server that responds to binding requests
+// with the client's reflexive transport address.
+type Server struct {
+ conns []*net.UDPConn
+ logger *log.Entry
+ logLevel log.Level
+
+ wg sync.WaitGroup
+}
+
+// NewServer creates a new STUN server with the given UDP listeners.
+// The caller is responsible for creating and providing the listeners.
+// logLevel can be: panic, fatal, error, warn, info, debug, trace
+func NewServer(conns []*net.UDPConn, logLevel string) *Server {
+ level, err := log.ParseLevel(logLevel)
+ if err != nil {
+ level = log.InfoLevel
+ }
+
+ // Create a separate logger with its own level setting
+ // This allows --stun-log-level to work independently of --log-level
+ stunLogger := log.New()
+ stunLogger.SetOutput(log.StandardLogger().Out)
+ stunLogger.SetLevel(level)
+ // Use the formatter package to set up formatter, ReportCaller, and context hook
+ formatter.SetTextFormatter(stunLogger)
+
+ logger := stunLogger.WithField("component", "stun-server")
+ logger.Infof("STUN server log level set to: %s", level.String())
+
+ return &Server{
+ conns: conns,
+ logger: logger,
+ logLevel: level,
+ }
+}
+
+// Listen starts the STUN server and blocks until the server is shut down.
+// Returns ErrServerClosed when shut down gracefully via Shutdown.
+// Returns ErrNoListeners if no UDP connections were provided.
+func (s *Server) Listen() error {
+ if len(s.conns) == 0 {
+ return ErrNoListeners
+ }
+
+ // Start a read loop for each listener
+ for _, conn := range s.conns {
+ s.logger.Infof("STUN server listening on %s", conn.LocalAddr())
+ s.wg.Add(1)
+ go s.readLoop(conn)
+ }
+
+ s.wg.Wait()
+ return ErrServerClosed
+}
+
+// readLoop continuously reads UDP packets and handles STUN requests.
+func (s *Server) readLoop(conn *net.UDPConn) {
+ defer s.wg.Done()
+ buf := make([]byte, 1500) // Standard MTU size
+ for {
+ n, remoteAddr, err := conn.ReadFromUDP(buf)
+
+ if err != nil {
+ // Check if the connection was closed externally
+ if errors.Is(err, net.ErrClosed) {
+ s.logger.Info("UDP connection closed, stopping read loop")
+ return
+ }
+ s.logger.Warnf("failed to read UDP packet: %v", err)
+ continue
+ }
+
+ // Handle packet in the same goroutine to avoid complexity
+ // STUN responses are small and fast
+ s.handlePacket(conn, buf[:n], remoteAddr)
+ }
+}
+
+// handlePacket processes a STUN request and sends a response.
+func (s *Server) handlePacket(conn *net.UDPConn, data []byte, addr *net.UDPAddr) {
+ localPort := conn.LocalAddr().(*net.UDPAddr).Port
+
+ s.logger.Debugf("[port:%d] received %d bytes from %s", localPort, len(data), addr)
+
+ // Check if it's a STUN message
+ if !stun.IsMessage(data) {
+ s.logger.Debugf("[port:%d] not a STUN message (first bytes: %x)", localPort, data[:min(len(data), 8)])
+ return
+ }
+
+ // Parse the STUN message
+ msg := &stun.Message{Raw: data}
+ if err := msg.Decode(); err != nil {
+ s.logger.Warnf("[port:%d] failed to decode STUN message from %s: %v", localPort, addr, err)
+ return
+ }
+
+ s.logger.Debugf("[port:%d] received STUN %s from %s (tx=%x)", localPort, msg.Type, addr, msg.TransactionID[:8])
+
+ // Only handle binding requests
+ if msg.Type != stun.BindingRequest {
+ s.logger.Debugf("[port:%d] ignoring non-binding request: %s", localPort, msg.Type)
+ return
+ }
+
+ // Build the response
+ response, err := stun.Build(
+ stun.NewTransactionIDSetter(msg.TransactionID),
+ stun.BindingSuccess,
+ &stun.XORMappedAddress{
+ IP: addr.IP,
+ Port: addr.Port,
+ },
+ stun.Fingerprint,
+ )
+ if err != nil {
+ s.logger.Errorf("[port:%d] failed to build STUN response: %v", localPort, err)
+ return
+ }
+
+ // Send the response on the same connection it was received on
+ n, err := conn.WriteToUDP(response.Raw, addr)
+ if err != nil {
+ s.logger.Errorf("[port:%d] failed to send STUN response to %s: %v", localPort, addr, err)
+ return
+ }
+
+ s.logger.Debugf("[port:%d] sent STUN BindingSuccess to %s (%d bytes) with XORMappedAddress %s:%d", localPort, addr, n, addr.IP, addr.Port)
+}
+
+// Shutdown gracefully stops the STUN server.
+func (s *Server) Shutdown() error {
+ s.logger.Info("shutting down STUN server")
+
+ var merr *multierror.Error
+
+ for _, conn := range s.conns {
+ if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
+ merr = multierror.Append(merr, fmt.Errorf("close STUN UDP connection: %w", err))
+ }
+ }
+
+ // Wait for all readLoops to finish
+ s.wg.Wait()
+ return nberrors.FormatErrorOrNil(merr)
+}
diff --git a/stun/server_test.go b/stun/server_test.go
new file mode 100644
index 000000000..4fd949863
--- /dev/null
+++ b/stun/server_test.go
@@ -0,0 +1,479 @@
+package stun
+
+import (
+ "errors"
+ "fmt"
+ "math/rand"
+ "net"
+ "os"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/pion/stun/v3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// createTestServer creates a STUN server listening on a random port for testing.
+// Returns the server, the listener connection (caller must close), and the server address.
+func createTestServer(t testing.TB) (*Server, *net.UDPConn, *net.UDPAddr) {
+ t.Helper()
+ conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
+ require.NoError(t, err)
+ server := NewServer([]*net.UDPConn{conn}, "debug")
+ return server, conn, conn.LocalAddr().(*net.UDPAddr)
+}
+
+// waitForServerReady polls the server with STUN binding requests until it responds.
+// This avoids flaky tests on slow CI machines that relied on time.Sleep.
+func waitForServerReady(t testing.TB, serverAddr *net.UDPAddr, timeout time.Duration) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ retryInterval := 10 * time.Millisecond
+
+ clientConn, err := net.DialUDP("udp", nil, serverAddr)
+ require.NoError(t, err)
+ defer clientConn.Close()
+
+ buf := make([]byte, 1500)
+ for time.Now().Before(deadline) {
+ msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
+ require.NoError(t, err)
+
+ _, err = clientConn.Write(msg.Raw)
+ require.NoError(t, err)
+
+ _ = clientConn.SetReadDeadline(time.Now().Add(retryInterval))
+ n, err := clientConn.Read(buf)
+ if err != nil {
+ // Timeout or other error, retry
+ continue
+ }
+
+ response := &stun.Message{Raw: buf[:n]}
+ if err := response.Decode(); err != nil {
+ continue
+ }
+
+ if response.Type == stun.BindingSuccess {
+ return // Server is ready
+ }
+ }
+
+ t.Fatalf("server did not become ready within %v", timeout)
+}
+
+func TestServer_BindingRequest(t *testing.T) {
+ // Start the STUN server on a random port
+ server, listener, serverAddr := createTestServer(t)
+
+ // Start server in background
+ serverErrCh := make(chan error, 1)
+ go func() {
+ serverErrCh <- server.Listen()
+ }()
+
+ // Wait for server to be ready
+ waitForServerReady(t, serverAddr, 2*time.Second)
+
+ // Create a UDP client
+ clientConn, err := net.DialUDP("udp", nil, serverAddr)
+ require.NoError(t, err)
+ defer clientConn.Close()
+
+ // Build a STUN binding request
+ msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
+ require.NoError(t, err)
+
+ // Send the request
+ _, err = clientConn.Write(msg.Raw)
+ require.NoError(t, err)
+
+ // Read the response
+ buf := make([]byte, 1500)
+ _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ n, err := clientConn.Read(buf)
+ require.NoError(t, err)
+
+ // Parse the response
+ response := &stun.Message{Raw: buf[:n]}
+ err = response.Decode()
+ require.NoError(t, err)
+
+ // Verify it's a binding success
+ assert.Equal(t, stun.BindingSuccess, response.Type)
+
+ // Extract the XOR-MAPPED-ADDRESS
+ var xorAddr stun.XORMappedAddress
+ err = xorAddr.GetFrom(response)
+ require.NoError(t, err)
+
+ // Verify the address matches our client's local address
+ clientAddr := clientConn.LocalAddr().(*net.UDPAddr)
+ assert.Equal(t, clientAddr.IP.String(), xorAddr.IP.String())
+ assert.Equal(t, clientAddr.Port, xorAddr.Port)
+
+ // Close listener first to unblock readLoop, then shutdown
+ _ = listener.Close()
+ err = server.Shutdown()
+ require.NoError(t, err)
+}
+
+func TestServer_IgnoresNonSTUNPackets(t *testing.T) {
+ server, listener, serverAddr := createTestServer(t)
+
+ go func() {
+ _ = server.Listen()
+ }()
+
+ waitForServerReady(t, serverAddr, 2*time.Second)
+
+ clientConn, err := net.DialUDP("udp", nil, serverAddr)
+ require.NoError(t, err)
+ defer clientConn.Close()
+
+ // Send non-STUN data
+ _, err = clientConn.Write([]byte("hello world"))
+ require.NoError(t, err)
+
+ // Try to read response (should timeout since server ignores non-STUN)
+ buf := make([]byte, 1500)
+ _ = clientConn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
+ _, err = clientConn.Read(buf)
+ assert.Error(t, err) // Should be a timeout error
+
+ // Close listener first to unblock readLoop, then shutdown
+ _ = listener.Close()
+ _ = server.Shutdown()
+}
+
+func TestServer_Shutdown(t *testing.T) {
+ server, listener, serverAddr := createTestServer(t)
+
+ serverDone := make(chan struct{})
+ go func() {
+ err := server.Listen()
+ assert.True(t, errors.Is(err, ErrServerClosed))
+ close(serverDone)
+ }()
+
+ waitForServerReady(t, serverAddr, 2*time.Second)
+
+ // Close listener first to unblock readLoop, then shutdown
+ _ = listener.Close()
+
+ err := server.Shutdown()
+ require.NoError(t, err)
+
+ // Wait for Listen to return
+ select {
+ case <-serverDone:
+ // Success
+ case <-time.After(3 * time.Second):
+ t.Fatal("server did not shutdown in time")
+ }
+}
+
+func TestServer_MultipleRequests(t *testing.T) {
+ server, listener, serverAddr := createTestServer(t)
+
+ go func() {
+ _ = server.Listen()
+ }()
+
+ waitForServerReady(t, serverAddr, 2*time.Second)
+
+ // Create multiple clients and send requests
+ for i := 0; i < 5; i++ {
+ func() {
+ clientConn, err := net.DialUDP("udp", nil, serverAddr)
+ require.NoError(t, err)
+ defer clientConn.Close()
+
+ msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
+ require.NoError(t, err)
+
+ _, err = clientConn.Write(msg.Raw)
+ require.NoError(t, err)
+
+ buf := make([]byte, 1500)
+ _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ n, err := clientConn.Read(buf)
+ require.NoError(t, err)
+
+ response := &stun.Message{Raw: buf[:n]}
+ err = response.Decode()
+ require.NoError(t, err)
+
+ assert.Equal(t, stun.BindingSuccess, response.Type)
+ }()
+ }
+
+ // Close listener first to unblock readLoop, then shutdown
+ _ = listener.Close()
+ _ = server.Shutdown()
+}
+
+func TestServer_ConcurrentClients(t *testing.T) {
+ numClients := 100
+ requestsPerClient := 5
+ maxStartDelay := 100 * time.Millisecond // Random delay before client starts
+ maxRequestDelay := 500 * time.Millisecond // Random delay between requests
+
+ // Remote server to test against via env var STUN_TEST_SERVER
+ // Example: STUN_TEST_SERVER=example.netbird.io:3478 go test -v ./stun/... -run ConcurrentClients
+ remoteServer := os.Getenv("STUN_TEST_SERVER")
+
+ var serverAddr *net.UDPAddr
+ var server *Server
+ var listener *net.UDPConn
+
+ if remoteServer != "" {
+ // Use remote server
+ var err error
+ serverAddr, err = net.ResolveUDPAddr("udp", remoteServer)
+ require.NoError(t, err)
+ t.Logf("Testing against remote server: %s", remoteServer)
+ } else {
+ // Start local server
+ server, listener, serverAddr = createTestServer(t)
+ go func() {
+ _ = server.Listen()
+ }()
+ waitForServerReady(t, serverAddr, 2*time.Second)
+ t.Logf("Testing against local server: %s", serverAddr)
+ }
+
+ var wg sync.WaitGroup
+ errorz := make(chan error, numClients*requestsPerClient)
+ successCount := make(chan int, numClients)
+
+ startTime := time.Now()
+
+ for i := 0; i < numClients; i++ {
+ wg.Add(1)
+ go func(clientID int) {
+ defer wg.Done()
+
+ // Random delay before starting
+ time.Sleep(time.Duration(rand.Int63n(int64(maxStartDelay))))
+
+ clientConn, err := net.DialUDP("udp", nil, serverAddr)
+ if err != nil {
+ errorz <- fmt.Errorf("client %d: failed to dial: %w", clientID, err)
+ return
+ }
+ defer clientConn.Close()
+
+ success := 0
+ for j := 0; j < requestsPerClient; j++ {
+ // Random delay between requests
+ if j > 0 {
+ time.Sleep(time.Duration(rand.Int63n(int64(maxRequestDelay))))
+ }
+
+ msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
+ if err != nil {
+ errorz <- fmt.Errorf("client %d: failed to build request: %w", clientID, err)
+ continue
+ }
+
+ _, err = clientConn.Write(msg.Raw)
+ if err != nil {
+ errorz <- fmt.Errorf("client %d: failed to write: %w", clientID, err)
+ continue
+ }
+
+ buf := make([]byte, 1500)
+ _ = clientConn.SetReadDeadline(time.Now().Add(5 * time.Second))
+ n, err := clientConn.Read(buf)
+ if err != nil {
+ errorz <- fmt.Errorf("client %d: failed to read: %w", clientID, err)
+ continue
+ }
+
+ response := &stun.Message{Raw: buf[:n]}
+ if err := response.Decode(); err != nil {
+ errorz <- fmt.Errorf("client %d: failed to decode: %w", clientID, err)
+ continue
+ }
+
+ if response.Type != stun.BindingSuccess {
+ errorz <- fmt.Errorf("client %d: unexpected response type: %s", clientID, response.Type)
+ continue
+ }
+
+ success++
+ }
+ successCount <- success
+ }(i)
+ }
+
+ wg.Wait()
+ close(errorz)
+ close(successCount)
+
+ elapsed := time.Since(startTime)
+
+ totalSuccess := 0
+ for count := range successCount {
+ totalSuccess += count
+ }
+
+ var errs []error
+ for err := range errorz {
+ errs = append(errs, err)
+ }
+
+ totalRequests := numClients * requestsPerClient
+ t.Logf("Completed %d/%d requests in %v (%.2f req/s)",
+ totalSuccess, totalRequests, elapsed,
+ float64(totalSuccess)/elapsed.Seconds())
+
+ if len(errs) > 0 {
+ t.Logf("Errors (%d):", len(errs))
+ for i, err := range errs {
+ if i < 10 { // Only show first 10 errors
+ t.Logf(" - %v", err)
+ }
+ }
+ }
+
+ // Require at least 95% success rate
+ successRate := float64(totalSuccess) / float64(totalRequests)
+ require.GreaterOrEqual(t, successRate, 0.95, "success rate too low: %.2f%%", successRate*100)
+
+ // Cleanup local server if used
+ if server != nil {
+ // Close listener first to unblock readLoop, then shutdown
+ _ = listener.Close()
+ _ = server.Shutdown()
+ }
+}
+
+func TestServer_MultiplePorts(t *testing.T) {
+ // Create listeners on two random ports
+ conn1, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
+ require.NoError(t, err)
+ conn2, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
+ require.NoError(t, err)
+
+ addr1 := conn1.LocalAddr().(*net.UDPAddr)
+ addr2 := conn2.LocalAddr().(*net.UDPAddr)
+
+ server := NewServer([]*net.UDPConn{conn1, conn2}, "debug")
+
+ go func() {
+ _ = server.Listen()
+ }()
+
+ // Wait for server to be ready (checking first port is sufficient)
+ waitForServerReady(t, addr1, 2*time.Second)
+
+ // Test requests on both ports
+ for _, serverAddr := range []*net.UDPAddr{addr1, addr2} {
+ func() {
+ clientConn, err := net.DialUDP("udp", nil, serverAddr)
+ require.NoError(t, err)
+ defer clientConn.Close()
+
+ msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
+ require.NoError(t, err)
+
+ _, err = clientConn.Write(msg.Raw)
+ require.NoError(t, err)
+
+ buf := make([]byte, 1500)
+ _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ n, err := clientConn.Read(buf)
+ require.NoError(t, err)
+
+ response := &stun.Message{Raw: buf[:n]}
+ err = response.Decode()
+ require.NoError(t, err)
+
+ assert.Equal(t, stun.BindingSuccess, response.Type)
+
+ var xorAddr stun.XORMappedAddress
+ err = xorAddr.GetFrom(response)
+ require.NoError(t, err)
+
+ clientAddr := clientConn.LocalAddr().(*net.UDPAddr)
+ assert.Equal(t, clientAddr.Port, xorAddr.Port)
+ }()
+ }
+
+ // Close listeners first to unblock readLoops, then shutdown
+ _ = conn1.Close()
+ _ = conn2.Close()
+ _ = server.Shutdown()
+}
+
+// BenchmarkSTUNServer benchmarks the STUN server with concurrent clients
+func BenchmarkSTUNServer(b *testing.B) {
+ server, listener, serverAddr := createTestServer(b)
+
+ go func() {
+ _ = server.Listen()
+ }()
+
+ waitForServerReady(b, serverAddr, 2*time.Second)
+
+ // Capture first error atomically - b.Fatal cannot be called from worker goroutines
+ var firstErr atomic.Pointer[error]
+ setErr := func(err error) {
+ firstErr.CompareAndSwap(nil, &err)
+ }
+
+ b.ResetTimer()
+ b.RunParallel(func(pb *testing.PB) {
+ // Stop work if an error has occurred
+ if firstErr.Load() != nil {
+ return
+ }
+
+ clientConn, err := net.DialUDP("udp", nil, serverAddr)
+ if err != nil {
+ setErr(err)
+ return
+ }
+ defer clientConn.Close()
+
+ buf := make([]byte, 1500)
+
+ for pb.Next() {
+ if firstErr.Load() != nil {
+ return
+ }
+
+ msg, _ := stun.Build(stun.TransactionID, stun.BindingRequest)
+ _, _ = clientConn.Write(msg.Raw)
+
+ _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ n, err := clientConn.Read(buf)
+ if err != nil {
+ setErr(err)
+ return
+ }
+
+ response := &stun.Message{Raw: buf[:n]}
+ if err := response.Decode(); err != nil {
+ setErr(err)
+ return
+ }
+ }
+ })
+
+ b.StopTimer()
+
+ // Fail after RunParallel completes
+ if errPtr := firstErr.Load(); errPtr != nil {
+ b.Fatal(*errPtr)
+ }
+
+ // Close listener first to unblock readLoop, then shutdown
+ _ = listener.Close()
+ _ = server.Shutdown()
+}