From 4955c345d53f63394266305744841c4e1bff8123 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Wed, 20 May 2026 23:25:56 +0900
Subject: [PATCH 01/44] Clean up README header, key features table, and
self-hosted quickstart (#6178)
---
README.md | 153 +++++++++++++++++++++++++-----------------------------
1 file changed, 70 insertions(+), 83 deletions(-)
diff --git a/README.md b/README.md
index dc84af2fd..cc27e2d28 100644
--- a/README.md
+++ b/README.md
@@ -1,147 +1,134 @@
-
-
- Start using NetBird at netbird.io
+
+ Start using NetBird at netbird.io
+
+ See Documentation
+
+ Join our Slack channel or our Community forum
+
- See Documentation
- Join our Slack channel or our Community forum
-
-
-
-
-
- ๐ We are hiring! Join us at careers.netbird.io
-
-
-
-
- New: NetBird terraform provider
-
+
+ ๐ We are hiring! Join us at careers.netbird.io
+
-
-
**NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.**
**Connect.** NetBird creates a WireGuard-based overlay network that automatically connects your machines over an encrypted tunnel, leaving behind the hassle of opening ports, complex firewall rules, VPN gateways, and so forth.
**Secure.** NetBird enables secure remote access by applying granular access policies while allowing you to manage them intuitively from a single place. Works universally on any infrastructure.
-### Open Source Network Security in a Single Platform
-
https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2
-### Self-Host NetBird (Video)
+### Self-host NetBird (video)
+
[](https://youtu.be/bZAgpT6nzaQ)
### Key features
-| Connectivity | Management | Security | Automation| Platforms |
-|----|----|----|----|----|
-| | - - \[x] [Admin Web UI](https://github.com/netbirdio/dashboard)
| - - \[x] [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login)
| - - \[x] [Public API](https://docs.netbird.io/api)
| |
-| - - \[x] Peer-to-peer connections
| - - \[x] Auto peer discovery and configuration
| - - \[x] [Access control - groups & rules](https://docs.netbird.io/how-to/manage-network-access)
| - - \[x] [Setup keys for bulk network provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys)
| - - \[x] Mac
|
-| - - \[x] Connection relay fallback
| - - \[x] [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers)
| - - \[x] [Activity logging](https://docs.netbird.io/how-to/audit-events-logging)
| - - \[x] [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart)
| - - \[x] Windows
|
-| - - \[x] [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks)
| - - \[x] [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network)
| - - \[x] [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks)
| - - \[x] IdP groups sync with JWT
| - - \[x] Android
|
-| - - \[x] NAT traversal with BPF
| - - \[x] [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network)
| - - \[x] Peer-to-peer encryption
|| - - \[x] iOS
|
-||| - - \[x] [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn)
|| - - \[x] OpenWRT
|
-||| - - \[x] [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication)
|| - - \[x] [Serverless](https://docs.netbird.io/how-to/netbird-on-faas)
|
-||||| - - \[x] Docker
|
+| Connectivity | Management | Security | Automation | Platforms |
+|---|---|---|---|---|
+| โ [Kernel WireGuard](https://docs.netbird.io/about-netbird/why-wireguard-with-netbird) | โ [Admin Web UI](https://github.com/netbirdio/dashboard) | โ [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login) | โ [Public API](https://docs.netbird.io/api) | โ [Linux](https://docs.netbird.io/get-started/install/linux) |
+| โ [Peer-to-peer connections](https://docs.netbird.io/about-netbird/how-netbird-works) | โ Auto peer discovery and configuration | โ [Access control: groups & rules](https://docs.netbird.io/how-to/manage-network-access) | โ [Setup keys for bulk provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys) | โ [macOS](https://docs.netbird.io/get-started/install/macos) |
+| โ Connection relay fallback | โ [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers) | โ [Activity logging](https://docs.netbird.io/how-to/audit-events-logging) | โ [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart) | โ [Windows](https://docs.netbird.io/get-started/install/windows) |
+| โ [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks) | โ [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network) | โ [Traffic events](https://docs.netbird.io/manage/activity/traffic-events-logging) | โ [IdP groups sync with JWT](https://docs.netbird.io/manage/team/idp-sync) | โ [Android](https://docs.netbird.io/get-started/install/android) |
+| โ [Domain-based DNS routes](https://docs.netbird.io/manage/dns/dns-aliases-for-routed-networks) | โ [Custom DNS zones](https://docs.netbird.io/manage/dns/custom-zones) | โ [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks) | โ [Terraform provider](https://registry.terraform.io/providers/netbirdio/netbird/latest) | โ [Android TV](https://docs.netbird.io/get-started/install/android-tv) |
+| โ [Exit nodes](https://docs.netbird.io/manage/network-routes/use-cases/exit-nodes) | โ [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network) | โ Peer-to-peer encryption | โ [Ansible collection](https://github.com/netbirdio/ansible-netbird) | โ [iOS](https://docs.netbird.io/get-started/install/ios) |
+| โ [IPv6 dual-stack overlay](https://docs.netbird.io/manage/settings/ipv6) | โ [Multi-account profile switching](https://docs.netbird.io/client/profiles) | โ [SSH with central access policies](https://docs.netbird.io/manage/peers/ssh) | | โ [Apple TV](https://docs.netbird.io/get-started/install/tvos) |
+| โ [Browser SSH & RDP](https://docs.netbird.io/manage/peers/browser-client) | | โ [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn) | | โ FreeBSD |
+| โ [Reverse proxy with auto-TLS](https://docs.netbird.io/manage/reverse-proxy) | | โ [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication) | | โ [pfSense](https://docs.netbird.io/get-started/install/pfsense) |
+| | | | | โ [OPNsense](https://docs.netbird.io/get-started/install/opnsense) |
+| | | | | โ [MikroTik RouterOS](https://docs.netbird.io/use-cases/homelab/client-on-mikrotik-router) |
+| | | | | โ OpenWRT |
+| | | | | โ [Synology](https://docs.netbird.io/get-started/install/synology) |
+| | | | | โ [TrueNAS](https://docs.netbird.io/get-started/install/truenas) |
+| | | | | โ [Proxmox](https://docs.netbird.io/get-started/install/proxmox-ve) |
+| | | | | โ [Raspberry Pi](https://docs.netbird.io/get-started/install/raspberrypi) |
+| | | | | โ [Serverless](https://docs.netbird.io/how-to/netbird-on-faas) |
+| | | | | โ [Container](https://docs.netbird.io/get-started/install/docker) |
### Quickstart with NetBird Cloud
-- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install)
-- Follow the steps to sign-up with Google, Microsoft, GitHub or your email address.
-- Check NetBird [admin UI](https://app.netbird.io/).
-- Add more machines.
+- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install).
+- Follow the steps to sign up with Google, Microsoft, GitHub or your email address.
+- Check the NetBird [admin UI](https://app.netbird.io/).
### Quickstart with self-hosted NetBird
-> This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM.
-Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IDPs.
+This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM. Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IdPs.
**Infrastructure requirements:**
-- A Linux VM with at least **1CPU** and **2GB** of memory.
-- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port: **3478**.
-- **Public domain** name pointing to the VM.
+- A Linux VM with at least **1 CPU** and **2 GB** of memory.
+- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port **3478**.
+- A **public domain** name pointing to the VM.
**Software requirements:**
-- Docker installed on the VM with the docker-compose plugin ([Docker installation guide](https://docs.docker.com/engine/install/)) or docker with docker-compose in version 2 or higher.
-- [jq](https://jqlang.github.io/jq/) installed. In most distributions
- Usually available in the official repositories and can be installed with `sudo apt install jq` or `sudo yum install jq`
-- [curl](https://curl.se/) installed.
- Usually available in the official repositories and can be installed with `sudo apt install curl` or `sudo yum install curl`
+- Docker with the Compose plugin (Compose v2 or higher). See the [Docker installation guide](https://docs.docker.com/engine/install/).
**Steps**
- Download and run the installation script:
```bash
export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bash
```
-- Once finished, you can manage the resources via `docker-compose`
### A bit on NetBird internals
-- Every machine in the network runs [NetBird Agent (or Client)](client/) that manages WireGuard.
-- Every agent connects to [Management Service](management/) that holds network state, manages peer IPs, and distributes network updates to agents (peers).
-- NetBird agent uses WebRTC ICE implemented in [pion/ice library](https://github.com/pion/ice) to discover connection candidates when establishing a peer-to-peer connection between machines.
-- Connection candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers.
-- Agents negotiate a connection through [Signal Service](signal/) passing p2p encrypted messages with candidates.
-- Sometimes the NAT traversal is unsuccessful due to strict NATs (e.g. mobile carrier-grade NAT) and a p2p connection isn't possible. When this occurs the system falls back to a relay server called [TURN](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT), and a secure WireGuard tunnel is established via the TURN server.
-
-[Coturn](https://github.com/coturn/coturn) is the one that has been successfully used for STUN and TURN in NetBird setups.
+- Every machine in the network runs the [NetBird agent](client/), which manages WireGuard.
+- Every agent connects to the [Management Service](management/), which holds network state, manages peer IPs, and distributes updates to agents.
+- Agents use ICE (via [pion/ice](https://github.com/pion/ice)) to discover connection candidates for peer-to-peer connections.
+- Candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers.
+- Agents negotiate a connection through the [Signal Service](signal/), exchanging end-to-end encrypted messages with candidates.
+- When NAT traversal fails (e.g. mobile carrier-grade NAT) and a direct p2p connection isn't possible, the system falls back to a [Relay Service](relay/) and a secure WireGuard tunnel is established through it.
-
+
See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details.
### Community projects
-- [NetBird installer script](https://github.com/physk/netbird-installer)
-- [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/)
-- [netbird-tui](https://github.com/n0pashkov/netbird-tui) โ terminal UI for managing NetBird peers, routes, and settings
+- [NetBird installer script](https://github.com/physk/netbird-installer)
+- [netbird-tui](https://github.com/n0pashkov/netbird-tui) - terminal UI for managing NetBird peers, routes, and settings
+- [caddy-netbird](https://github.com/lixmal/caddy-netbird) - Caddy plugin that embeds a NetBird client for proxying HTTP and TCP/UDP traffic through NetBird networks
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
### Support acknowledgement
-In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by The Federal Ministry of Education and Research of The Federal Republic of Germany. Together with [CISPA Helmholtz Center for Information Security](https://cispa.de/en) NetBird brings the security best practices and simplicity to private networking.
+In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by the Federal Ministry of Education and Research of the Federal Republic of Germany. Together with the [CISPA Helmholtz Center for Information Security](https://cispa.de/en), NetBird brings security best practices and simplicity to private networking.

-### Testimonials
-We use open-source technologies like [WireGuardยฎ](https://www.wireguard.com/), [Pion ICE (WebRTC)](https://github.com/pion/ice), [Coturn](https://github.com/coturn/coturn), and [Rosenpass](https://rosenpass.eu). We very much appreciate the work these guys are doing and we'd greatly appreciate if you could support them in any way (e.g., by giving a star or a contribution).
+### Acknowledgements
+We build on open-source technologies like [WireGuardยฎ](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
### Legal
-This repository is licensed under BSD-3-Clause license that applies to all parts of the repository except for the directories management/, signal/ and relay/.
+This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
_WireGuard_ and the _WireGuard_ logo are [registered trademarks](https://www.wireguard.com/trademark-policy/) of Jason A. Donenfeld.
From 6137a1fcc53ad0ea0f5048ac97369168f1116a69 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Wed, 20 May 2026 18:21:22 +0200
Subject: [PATCH 02/44] [proxy] concurrent proxy snapshot apply (#6207)
---
management/internals/shared/grpc/proxy.go | 399 +++++++++----
.../shared/grpc/sync_mappings_test.go | 411 ++++++++++++++
proxy/handle_mapping_stream_test.go | 7 +-
proxy/internal/metrics/metrics.go | 61 ++
proxy/internal/roundtrip/netbird.go | 62 ++-
proxy/process_mappings_bench_test.go | 300 ++++++++++
proxy/server.go | 278 ++++++++--
proxy/snapshot_reconcile_test.go | 9 +-
proxy/sync_mappings_test.go | 525 ++++++++++++++++++
shared/management/proto/proxy_service.pb.go | 487 +++++++++++++---
shared/management/proto/proxy_service.proto | 41 ++
.../management/proto/proxy_service_grpc.pb.go | 82 +++
12 files changed, 2446 insertions(+), 216 deletions(-)
create mode 100644 management/internals/shared/grpc/sync_mappings_test.go
create mode 100644 proxy/process_mappings_bench_test.go
create mode 100644 proxy/sync_mappings_test.go
diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go
index eada2d86a..4abeb8e7c 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"errors"
"fmt"
+ "io"
"net"
"net/http"
"net/url"
@@ -136,9 +137,12 @@ type proxyConnection struct {
tokenID string
capabilities *proto.ProxyCapabilities
stream proto.ProxyService_GetMappingUpdateServer
- sendChan chan *proto.GetMappingUpdateResponse
- ctx context.Context
- cancel context.CancelFunc
+ // syncStream is set when the proxy connected via SyncMappings.
+ // When non-nil, the sender goroutine uses this instead of stream.
+ syncStream proto.ProxyService_SyncMappingsServer
+ sendChan chan *proto.GetMappingUpdateResponse
+ ctx context.Context
+ cancel context.CancelFunc
}
func enforceAccountScope(ctx context.Context, requestAccountID string) error {
@@ -206,145 +210,322 @@ func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller
s.proxyController = proxyController
}
+// proxyConnectParams holds the validated parameters extracted from either
+// a GetMappingUpdateRequest or a SyncMappingsInit message.
+type proxyConnectParams struct {
+ proxyID string
+ address string
+ capabilities *proto.ProxyCapabilities
+}
+
// GetMappingUpdate handles the control stream with proxy clients
func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest, stream proto.ProxyService_GetMappingUpdateServer) error {
- ctx := stream.Context()
+ params, err := s.validateProxyConnect(req.GetProxyId(), req.GetAddress(), stream.Context())
+ if err != nil {
+ return err
+ }
+ params.capabilities = req.GetCapabilities()
- peerInfo := PeerIPFromContext(ctx)
- log.Infof("New proxy connection from %s", peerInfo)
+ conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{
+ stream: stream,
+ })
+ if err != nil {
+ return err
+ }
- proxyID := req.GetProxyId()
+ if err := s.sendSnapshot(stream.Context(), conn); err != nil {
+ s.cleanupFailedSnapshot(stream.Context(), conn)
+ return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err)
+ }
+
+ errChan := make(chan error, 2)
+ go s.sender(conn, errChan)
+
+ return s.serveProxyConnection(conn, proxyRecord, errChan, false)
+}
+
+// SyncMappings implements the bidirectional SyncMappings RPC.
+// It mirrors GetMappingUpdate but provides application-level back-pressure:
+// management waits for an ack from the proxy before sending the next batch.
+func (s *ProxyServiceServer) SyncMappings(stream proto.ProxyService_SyncMappingsServer) error {
+ init, err := recvSyncInit(stream)
+ if err != nil {
+ return err
+ }
+
+ params, err := s.validateProxyConnect(init.GetProxyId(), init.GetAddress(), stream.Context())
+ if err != nil {
+ return err
+ }
+ params.capabilities = init.GetCapabilities()
+
+ conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{
+ syncStream: stream,
+ })
+ if err != nil {
+ return err
+ }
+
+ if err := s.sendSnapshotSync(stream.Context(), conn, stream); err != nil {
+ s.cleanupFailedSnapshot(stream.Context(), conn)
+ return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err)
+ }
+
+ errChan := make(chan error, 2)
+ go s.sender(conn, errChan)
+ go s.drainRecv(stream, errChan)
+
+ return s.serveProxyConnection(conn, proxyRecord, errChan, true)
+}
+
+// recvSyncInit receives and validates the first message on a SyncMappings stream.
+func recvSyncInit(stream proto.ProxyService_SyncMappingsServer) (*proto.SyncMappingsInit, error) {
+ firstMsg, err := stream.Recv()
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "receive init: %v", err)
+ }
+ init := firstMsg.GetInit()
+ if init == nil {
+ return nil, status.Errorf(codes.InvalidArgument, "first message must be init")
+ }
+ return init, nil
+}
+
+// validateProxyConnect validates the proxy ID and address, and checks cluster
+// address availability for account-scoped tokens.
+func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx context.Context) (proxyConnectParams, error) {
if proxyID == "" {
- return status.Errorf(codes.InvalidArgument, "proxy_id is required")
+ return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy_id is required")
+ }
+ if !isProxyAddressValid(address) {
+ return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy address is invalid")
}
- proxyAddress := req.GetAddress()
- if !isProxyAddressValid(proxyAddress) {
- return status.Errorf(codes.InvalidArgument, "proxy address is invalid")
- }
-
- var accountID *string
token := GetProxyTokenFromContext(ctx)
if token != nil && token.AccountID != nil {
- accountID = token.AccountID
-
- available, err := s.proxyManager.IsClusterAddressAvailable(ctx, proxyAddress, *accountID)
+ available, err := s.proxyManager.IsClusterAddressAvailable(ctx, address, *token.AccountID)
if err != nil {
- return status.Errorf(codes.Internal, "check cluster address: %v", err)
+ return proxyConnectParams{}, status.Errorf(codes.Internal, "check cluster address: %v", err)
}
if !available {
- return status.Errorf(codes.AlreadyExists, "cluster address %s is already in use", proxyAddress)
+ return proxyConnectParams{}, status.Errorf(codes.AlreadyExists, "cluster address %s is already in use", address)
}
}
+ return proxyConnectParams{proxyID: proxyID, address: address}, nil
+}
+
+// registerProxyConnection creates a proxyConnection, registers it with the
+// proxy manager and cluster, and stores it in connectedProxies. The caller
+// provides a partially initialised connSeed with stream-specific fields set;
+// the remaining fields are filled in here.
+func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params proxyConnectParams, connSeed *proxyConnection) (*proxyConnection, *proxy.Proxy, error) {
+ peerInfo := PeerIPFromContext(ctx)
+
+ var accountID *string
var tokenID string
- if token != nil {
+ if token := GetProxyTokenFromContext(ctx); token != nil {
+ if token.AccountID != nil {
+ accountID = token.AccountID
+ }
tokenID = token.ID
}
sessionID := uuid.NewString()
-
- if old, loaded := s.connectedProxies.Load(proxyID); loaded {
- oldConn := old.(*proxyConnection)
- log.WithFields(log.Fields{
- "proxy_id": proxyID,
- "old_session_id": oldConn.sessionID,
- "new_session_id": sessionID,
- }).Info("Superseding existing proxy connection")
- oldConn.cancel()
- }
+ s.supersedePriorConnection(params.proxyID, sessionID)
connCtx, cancel := context.WithCancel(ctx)
- conn := &proxyConnection{
- proxyID: proxyID,
- sessionID: sessionID,
- address: proxyAddress,
- accountID: accountID,
- tokenID: tokenID,
- capabilities: req.GetCapabilities(),
- stream: stream,
- sendChan: make(chan *proto.GetMappingUpdateResponse, 100),
- ctx: connCtx,
- cancel: cancel,
- }
+ connSeed.proxyID = params.proxyID
+ connSeed.sessionID = sessionID
+ connSeed.address = params.address
+ connSeed.accountID = accountID
+ connSeed.tokenID = tokenID
+ connSeed.capabilities = params.capabilities
+ connSeed.sendChan = make(chan *proto.GetMappingUpdateResponse, 100)
+ connSeed.ctx = connCtx
+ connSeed.cancel = cancel
var caps *proxy.Capabilities
- if c := req.GetCapabilities(); c != nil {
+ if c := params.capabilities; c != nil {
caps = &proxy.Capabilities{
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdsec,
}
}
- proxyRecord, err := s.proxyManager.Connect(ctx, proxyID, sessionID, proxyAddress, peerInfo, accountID, caps)
+
+ proxyRecord, err := s.proxyManager.Connect(ctx, params.proxyID, sessionID, params.address, peerInfo, accountID, caps)
if err != nil {
cancel()
if accountID != nil {
- return status.Errorf(codes.Internal, "failed to register BYOP proxy: %v", err)
+ return nil, nil, status.Errorf(codes.Internal, "failed to register BYOP proxy: %v", err)
}
- log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", proxyID, err)
- return status.Errorf(codes.Internal, "register proxy in database: %v", err)
+ log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", params.proxyID, err)
+ return nil, nil, status.Errorf(codes.Internal, "register proxy in database: %v", err)
}
- s.connectedProxies.Store(proxyID, conn)
- if err := s.proxyController.RegisterProxyToCluster(ctx, conn.address, proxyID); err != nil {
- log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", proxyID, err)
+ s.connectedProxies.Store(params.proxyID, connSeed)
+ if err := s.proxyController.RegisterProxyToCluster(ctx, params.address, params.proxyID); err != nil {
+ log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", params.proxyID, err)
}
- if err := s.sendSnapshot(ctx, conn); err != nil {
- if s.connectedProxies.CompareAndDelete(proxyID, conn) {
- if unregErr := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, proxyID); unregErr != nil {
- log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", proxyID, unregErr)
- }
- }
- cancel()
- if disconnErr := s.proxyManager.Disconnect(context.Background(), proxyID, sessionID); disconnErr != nil {
- log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", proxyID, disconnErr)
- }
- return fmt.Errorf("send snapshot to proxy %s: %w", proxyID, err)
+ return connSeed, proxyRecord, nil
+}
+
+// supersedePriorConnection cancels any existing connection for the given proxy.
+func (s *ProxyServiceServer) supersedePriorConnection(proxyID, newSessionID string) {
+ if old, loaded := s.connectedProxies.Load(proxyID); loaded {
+ oldConn := old.(*proxyConnection)
+ log.WithFields(log.Fields{
+ "proxy_id": proxyID,
+ "old_session_id": oldConn.sessionID,
+ "new_session_id": newSessionID,
+ }).Info("Superseding existing proxy connection")
+ oldConn.cancel()
}
+}
- errChan := make(chan error, 2)
- go s.sender(conn, errChan)
+// cleanupFailedSnapshot removes the connection from the cluster and store
+// after a snapshot send failure.
+func (s *ProxyServiceServer) cleanupFailedSnapshot(ctx context.Context, conn *proxyConnection) {
+ if s.connectedProxies.CompareAndDelete(conn.proxyID, conn) {
+ if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil {
+ log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", conn.proxyID, err)
+ }
+ }
+ conn.cancel()
+ if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil {
+ log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", conn.proxyID, err)
+ }
+}
- log.WithFields(log.Fields{
- "proxy_id": proxyID,
- "session_id": sessionID,
- "address": proxyAddress,
- "cluster_addr": proxyAddress,
- "account_id": accountID,
- "total_proxies": len(s.GetConnectedProxies()),
- }).Info("Proxy registered in cluster")
- defer func() {
- if !s.connectedProxies.CompareAndDelete(proxyID, conn) {
- log.Infof("Proxy %s session %s: skipping cleanup, superseded by new connection", proxyID, sessionID)
- cancel()
+// drainRecv consumes and discards messages from a bidirectional stream.
+// The proxy sends an ack for every incremental update; we don't need them
+// after the snapshot phase. Recv errors are forwarded to errChan.
+func (s *ProxyServiceServer) drainRecv(stream proto.ProxyService_SyncMappingsServer, errChan chan<- error) {
+ for {
+ if _, err := stream.Recv(); err != nil {
+ errChan <- err
return
}
+ }
+}
- if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, proxyID); err != nil {
- log.Warnf("Failed to unregister proxy %s from cluster: %v", proxyID, err)
- }
- if err := s.proxyManager.Disconnect(context.Background(), proxyID, sessionID); err != nil {
- log.Warnf("Failed to mark proxy %s as disconnected: %v", proxyID, err)
- }
+// serveProxyConnection runs the post-snapshot lifecycle: heartbeat, sender,
+// and wait for termination. When bidi is true, normal stream closure (EOF,
+// canceled) is treated as a clean disconnect rather than an error.
+func (s *ProxyServiceServer) serveProxyConnection(conn *proxyConnection, proxyRecord *proxy.Proxy, errChan <-chan error, bidi bool) error {
+ log.WithFields(log.Fields{
+ "proxy_id": conn.proxyID,
+ "session_id": conn.sessionID,
+ "address": conn.address,
+ "cluster_addr": conn.address,
+ "account_id": conn.accountID,
+ "total_proxies": len(s.GetConnectedProxies()),
+ }).Info("Proxy registered in cluster")
- cancel()
- log.Infof("Proxy %s session %s disconnected", proxyID, sessionID)
- }()
-
- go s.heartbeat(connCtx, conn, proxyRecord)
+ defer s.disconnectProxy(conn)
+ go s.heartbeat(conn.ctx, conn, proxyRecord)
select {
case err := <-errChan:
- log.WithContext(ctx).Warnf("Failed to send update: %v", err)
- return fmt.Errorf("send update to proxy %s: %w", proxyID, err)
- case <-connCtx.Done():
- log.WithContext(ctx).Infof("Proxy %s context canceled", proxyID)
- return connCtx.Err()
+ if bidi && isStreamClosed(err) {
+ log.Infof("Proxy %s stream closed", conn.proxyID)
+ return nil
+ }
+ log.Warnf("Failed to send update: %v", err)
+ return fmt.Errorf("send update to proxy %s: %w", conn.proxyID, err)
+ case <-conn.ctx.Done():
+ log.Infof("Proxy %s context canceled", conn.proxyID)
+ return conn.ctx.Err()
}
}
+// disconnectProxy removes the connection from cluster and store, unless it
+// has already been superseded by a newer connection.
+func (s *ProxyServiceServer) disconnectProxy(conn *proxyConnection) {
+ if !s.connectedProxies.CompareAndDelete(conn.proxyID, conn) {
+ log.Infof("Proxy %s session %s: skipping cleanup, superseded by new connection", conn.proxyID, conn.sessionID)
+ conn.cancel()
+ return
+ }
+
+ if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil {
+ log.Warnf("Failed to unregister proxy %s from cluster: %v", conn.proxyID, err)
+ }
+ if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil {
+ log.Warnf("Failed to mark proxy %s as disconnected: %v", conn.proxyID, err)
+ }
+
+ conn.cancel()
+ log.Infof("Proxy %s session %s disconnected", conn.proxyID, conn.sessionID)
+}
+
+// sendSnapshotSync sends the initial snapshot with back-pressure: it sends
+// one batch, then waits for the proxy to ack before sending the next.
+func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyConnection, stream proto.ProxyService_SyncMappingsServer) error {
+ if !isProxyAddressValid(conn.address) {
+ return fmt.Errorf("proxy address is invalid")
+ }
+ if s.snapshotBatchSize <= 0 {
+ return fmt.Errorf("invalid snapshot batch size: %d", s.snapshotBatchSize)
+ }
+
+ mappings, err := s.snapshotServiceMappings(ctx, conn)
+ if err != nil {
+ return err
+ }
+
+ for i := 0; i < len(mappings); i += s.snapshotBatchSize {
+ end := i + s.snapshotBatchSize
+ if end > len(mappings) {
+ end = len(mappings)
+ }
+ for _, m := range mappings[i:end] {
+ token, err := s.tokenStore.GenerateToken(m.AccountId, m.Id, s.proxyTokenTTL())
+ if err != nil {
+ return fmt.Errorf("generate auth token for service %s: %w", m.Id, err)
+ }
+ m.AuthToken = token
+ }
+ if err := stream.Send(&proto.SyncMappingsResponse{
+ Mapping: mappings[i:end],
+ InitialSyncComplete: end == len(mappings),
+ }); err != nil {
+ return fmt.Errorf("send snapshot batch: %w", err)
+ }
+
+ if err := waitForAck(stream); err != nil {
+ return err
+ }
+ }
+
+ if len(mappings) == 0 {
+ if err := stream.Send(&proto.SyncMappingsResponse{
+ InitialSyncComplete: true,
+ }); err != nil {
+ return fmt.Errorf("send snapshot completion: %w", err)
+ }
+
+ if err := waitForAck(stream); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func waitForAck(stream proto.ProxyService_SyncMappingsServer) error {
+ msg, err := stream.Recv()
+ if err != nil {
+ return fmt.Errorf("receive ack: %w", err)
+ }
+ if msg.GetAck() == nil {
+ return fmt.Errorf("expected ack, got %T", msg.GetMsg())
+ }
+ return nil
+}
+
// heartbeat updates the proxy's last_seen timestamp every minute and
// disconnects the proxy if its access token has been revoked.
func (s *ProxyServiceServer) heartbeat(ctx context.Context, conn *proxyConnection, p *proxy.Proxy) {
@@ -381,6 +562,9 @@ func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnec
if !isProxyAddressValid(conn.address) {
return fmt.Errorf("proxy address is invalid")
}
+ if s.snapshotBatchSize <= 0 {
+ return fmt.Errorf("invalid snapshot batch size: %d", s.snapshotBatchSize)
+ }
mappings, err := s.snapshotServiceMappings(ctx, conn)
if err != nil {
@@ -460,12 +644,26 @@ func isProxyAddressValid(addr string) bool {
return err == nil
}
-// sender handles sending messages to proxy
+// isStreamClosed returns true for errors that indicate normal stream
+// termination: io.EOF, context cancellation, or gRPC Canceled.
+func isStreamClosed(err error) bool {
+ if err == nil {
+ return false
+ }
+ if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
+ return true
+ }
+ return status.Code(err) == codes.Canceled
+}
+
+// sender handles sending messages to proxy.
+// When conn.syncStream is set the message is sent as SyncMappingsResponse;
+// otherwise the legacy GetMappingUpdateResponse stream is used.
func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error) {
for {
select {
case resp := <-conn.sendChan:
- if err := conn.stream.Send(resp); err != nil {
+ if err := conn.sendResponse(resp); err != nil {
errChan <- err
return
}
@@ -475,6 +673,17 @@ func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error)
}
}
+// sendResponse sends a mapping update on whichever stream the proxy connected with.
+func (conn *proxyConnection) sendResponse(resp *proto.GetMappingUpdateResponse) error {
+ if conn.syncStream != nil {
+ return conn.syncStream.Send(&proto.SyncMappingsResponse{
+ Mapping: resp.Mapping,
+ InitialSyncComplete: resp.InitialSyncComplete,
+ })
+ }
+ return conn.stream.Send(resp)
+}
+
// SendAccessLog processes access log from proxy
func (s *ProxyServiceServer) SendAccessLog(ctx context.Context, req *proto.SendAccessLogRequest) (*proto.SendAccessLogResponse, error) {
accessLog := req.GetLog()
@@ -541,8 +750,8 @@ func (s *ProxyServiceServer) SendServiceUpdate(update *proto.GetMappingUpdateRes
return true
}
connUpdate = &proto.GetMappingUpdateResponse{
- Mapping: filtered,
- InitialSyncComplete: update.InitialSyncComplete,
+ Mapping: filtered,
+ InitialSyncComplete: update.InitialSyncComplete,
}
}
resp := s.perProxyMessage(connUpdate, conn.proxyID)
diff --git a/management/internals/shared/grpc/sync_mappings_test.go b/management/internals/shared/grpc/sync_mappings_test.go
new file mode 100644
index 000000000..97f6183bb
--- /dev/null
+++ b/management/internals/shared/grpc/sync_mappings_test.go
@@ -0,0 +1,411 @@
+package grpc
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// syncRecordingStream is a mock ProxyService_SyncMappingsServer that records
+// sent messages and returns pre-loaded ack responses from Recv.
+type syncRecordingStream struct {
+ grpc.ServerStream
+
+ mu sync.Mutex
+ sent []*proto.SyncMappingsResponse
+ recvMsgs []*proto.SyncMappingsRequest
+ recvIdx int
+}
+
+func (s *syncRecordingStream) Send(m *proto.SyncMappingsResponse) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.sent = append(s.sent, m)
+ return nil
+}
+
+func (s *syncRecordingStream) Recv() (*proto.SyncMappingsRequest, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.recvIdx >= len(s.recvMsgs) {
+ return nil, fmt.Errorf("no more recv messages")
+ }
+ msg := s.recvMsgs[s.recvIdx]
+ s.recvIdx++
+ return msg, nil
+}
+
+func (s *syncRecordingStream) Context() context.Context { return context.Background() }
+func (s *syncRecordingStream) SetHeader(metadata.MD) error { return nil }
+func (s *syncRecordingStream) SendHeader(metadata.MD) error { return nil }
+func (s *syncRecordingStream) SetTrailer(metadata.MD) {}
+func (s *syncRecordingStream) SendMsg(any) error { return nil }
+func (s *syncRecordingStream) RecvMsg(any) error { return nil }
+
+func ackMsg() *proto.SyncMappingsRequest {
+ return &proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ }
+}
+
+func TestSendSnapshotSync_BatchesWithAcks(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 3
+ const totalServices = 7 // 3 + 3 + 1 โ 3 batches, 3 acks (one per batch, including final)
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ stream := &syncRecordingStream{
+ recvMsgs: []*proto.SyncMappingsRequest{ackMsg(), ackMsg(), ackMsg()},
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+
+ require.Len(t, stream.sent, 3, "should send ceil(7/3) = 3 batches")
+
+ assert.Len(t, stream.sent[0].Mapping, 3)
+ assert.False(t, stream.sent[0].InitialSyncComplete)
+
+ assert.Len(t, stream.sent[1].Mapping, 3)
+ assert.False(t, stream.sent[1].InitialSyncComplete)
+
+ assert.Len(t, stream.sent[2].Mapping, 1)
+ assert.True(t, stream.sent[2].InitialSyncComplete)
+
+ // All 3 acks consumed โ including the final batch.
+ assert.Equal(t, 3, stream.recvIdx)
+}
+
+func TestSendSnapshotSync_SingleBatchWaitsForAck(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 100
+ const totalServices = 5
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ stream := &syncRecordingStream{
+ recvMsgs: []*proto.SyncMappingsRequest{ackMsg()},
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+
+ require.Len(t, stream.sent, 1)
+ assert.Len(t, stream.sent[0].Mapping, totalServices)
+ assert.True(t, stream.sent[0].InitialSyncComplete)
+ assert.Equal(t, 1, stream.recvIdx, "final batch ack must be consumed")
+}
+
+func TestSendSnapshotSync_EmptySnapshot(t *testing.T) {
+ const cluster = "cluster.example.com"
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(nil, nil)
+
+ s := newSnapshotTestServer(t, 500)
+ s.serviceManager = mgr
+
+ stream := &syncRecordingStream{
+ recvMsgs: []*proto.SyncMappingsRequest{ackMsg()},
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+
+ require.Len(t, stream.sent, 1, "empty snapshot must still send sync-complete")
+ assert.Empty(t, stream.sent[0].Mapping)
+ assert.True(t, stream.sent[0].InitialSyncComplete)
+ assert.Equal(t, 1, stream.recvIdx, "empty snapshot ack must be consumed")
+}
+
+func TestSendSnapshotSync_MissingAckReturnsError(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 2
+ const totalServices = 4 // 2 batches โ 1 ack needed, but we provide none
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ // No acks available โ Recv will return error.
+ stream := &syncRecordingStream{}
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "receive ack")
+ // First batch should have been sent before the error.
+ require.Len(t, stream.sent, 1)
+}
+
+func TestSendSnapshotSync_WrongMessageInsteadOfAck(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 2
+ const totalServices = 4
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ // Send an init message instead of an ack.
+ stream := &syncRecordingStream{
+ recvMsgs: []*proto.SyncMappingsRequest{
+ {Msg: &proto.SyncMappingsRequest_Init{Init: &proto.SyncMappingsInit{ProxyId: "bad"}}},
+ },
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "expected ack")
+}
+
+func TestSendSnapshotSync_BackPressureOrdering(t *testing.T) {
+ // Verify batches are sent strictly sequentially โ batch N+1 is not sent
+ // until the ack for batch N is received, including the final batch.
+ const cluster = "cluster.example.com"
+ const batchSize = 2
+ const totalServices = 6 // 3 batches, 3 acks
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ var mu sync.Mutex
+ var events []string
+
+ // Build a stream that logs send/recv events so we can verify ordering.
+ ackCh := make(chan struct{}, 3)
+ stream := &orderTrackingStream{
+ mu: &mu,
+ events: &events,
+ ackCh: ackCh,
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ // Feed acks asynchronously after a short delay to simulate real proxy.
+ go func() {
+ for range 3 {
+ time.Sleep(10 * time.Millisecond)
+ ackCh <- struct{}{}
+ }
+ }()
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+
+ mu.Lock()
+ defer mu.Unlock()
+
+ // Expected: send, recv-ack, send, recv-ack, send, recv-ack.
+ require.Len(t, events, 6)
+ assert.Equal(t, "send", events[0])
+ assert.Equal(t, "recv", events[1])
+ assert.Equal(t, "send", events[2])
+ assert.Equal(t, "recv", events[3])
+ assert.Equal(t, "send", events[4])
+ assert.Equal(t, "recv", events[5])
+}
+
+// orderTrackingStream logs "send" and "recv" events and blocks Recv until
+// an ack is signaled via ackCh.
+type orderTrackingStream struct {
+ grpc.ServerStream
+ mu *sync.Mutex
+ events *[]string
+ ackCh chan struct{}
+}
+
+func (s *orderTrackingStream) Send(_ *proto.SyncMappingsResponse) error {
+ s.mu.Lock()
+ *s.events = append(*s.events, "send")
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *orderTrackingStream) Recv() (*proto.SyncMappingsRequest, error) {
+ <-s.ackCh
+ s.mu.Lock()
+ *s.events = append(*s.events, "recv")
+ s.mu.Unlock()
+ return ackMsg(), nil
+}
+
+func (s *orderTrackingStream) Context() context.Context { return context.Background() }
+func (s *orderTrackingStream) SetHeader(metadata.MD) error { return nil }
+func (s *orderTrackingStream) SendHeader(metadata.MD) error { return nil }
+func (s *orderTrackingStream) SetTrailer(metadata.MD) {}
+func (s *orderTrackingStream) SendMsg(any) error { return nil }
+func (s *orderTrackingStream) RecvMsg(any) error { return nil }
+
+func TestSendSnapshotSync_TokensGeneratedPerBatch(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 2
+ const totalServices = 4
+ const ttl = 100 * time.Millisecond
+ const ackDelay = 200 * time.Millisecond
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+ s.tokenTTL = ttl
+
+ // Build a stream that validates tokens immediately on Send, then
+ // delays the ack to ensure the next batch's tokens are generated fresh.
+ var validateErrs []error
+ ackCh := make(chan struct{}, 2)
+ stream := &tokenValidatingSyncStream{
+ tokenStore: s.tokenStore,
+ validateErrs: &validateErrs,
+ ackCh: ackCh,
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ go func() {
+ // Delay first ack so that if tokens were all generated upfront they'd expire.
+ time.Sleep(ackDelay)
+ ackCh <- struct{}{}
+ // Final batch ack โ immediate.
+ ackCh <- struct{}{}
+ }()
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+ require.Empty(t, validateErrs,
+ "tokens must remain valid: per-batch generation guarantees freshness")
+}
+
+type tokenValidatingSyncStream struct {
+ grpc.ServerStream
+ tokenStore *OneTimeTokenStore
+ validateErrs *[]error
+ ackCh chan struct{}
+}
+
+func (s *tokenValidatingSyncStream) Send(m *proto.SyncMappingsResponse) error {
+ for _, mapping := range m.Mapping {
+ if err := s.tokenStore.ValidateAndConsume(mapping.AuthToken, mapping.AccountId, mapping.Id); err != nil {
+ *s.validateErrs = append(*s.validateErrs, fmt.Errorf("svc %s: %w", mapping.Id, err))
+ }
+ }
+ return nil
+}
+
+func (s *tokenValidatingSyncStream) Recv() (*proto.SyncMappingsRequest, error) {
+ <-s.ackCh
+ return ackMsg(), nil
+}
+
+func (s *tokenValidatingSyncStream) Context() context.Context { return context.Background() }
+func (s *tokenValidatingSyncStream) SetHeader(metadata.MD) error { return nil }
+func (s *tokenValidatingSyncStream) SendHeader(metadata.MD) error { return nil }
+func (s *tokenValidatingSyncStream) SetTrailer(metadata.MD) {}
+func (s *tokenValidatingSyncStream) SendMsg(any) error { return nil }
+func (s *tokenValidatingSyncStream) RecvMsg(any) error { return nil }
+
+func TestConnectionSendResponse_RoutesToSyncStream(t *testing.T) {
+ stream := &syncRecordingStream{}
+ conn := &proxyConnection{
+ syncStream: stream,
+ }
+
+ resp := &proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{
+ {Id: "svc-1", AccountId: "acct-1", Domain: "example.com"},
+ },
+ InitialSyncComplete: true,
+ }
+
+ err := conn.sendResponse(resp)
+ require.NoError(t, err)
+
+ require.Len(t, stream.sent, 1)
+ assert.Len(t, stream.sent[0].Mapping, 1)
+ assert.Equal(t, "svc-1", stream.sent[0].Mapping[0].Id)
+ assert.True(t, stream.sent[0].InitialSyncComplete)
+}
+
+func TestConnectionSendResponse_RoutesToLegacyStream(t *testing.T) {
+ stream := &recordingStream{}
+ conn := &proxyConnection{
+ stream: stream,
+ }
+
+ resp := &proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{
+ {Id: "svc-2", AccountId: "acct-2"},
+ },
+ }
+
+ err := conn.sendResponse(resp)
+ require.NoError(t, err)
+
+ require.Len(t, stream.messages, 1)
+ assert.Equal(t, "svc-2", stream.messages[0].Mapping[0].Id)
+}
diff --git a/proxy/handle_mapping_stream_test.go b/proxy/handle_mapping_stream_test.go
index cb16c0814..67c399e44 100644
--- a/proxy/handle_mapping_stream_test.go
+++ b/proxy/handle_mapping_stream_test.go
@@ -4,6 +4,7 @@ import (
"context"
"io"
"testing"
+ "time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -59,7 +60,7 @@ func TestHandleMappingStream_SyncCompleteFlag(t *testing.T) {
}
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.True(t, syncDone, "initial sync should be marked done when flag is set")
}
@@ -79,7 +80,7 @@ func TestHandleMappingStream_NoSyncFlagDoesNotMarkDone(t *testing.T) {
}
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.False(t, syncDone, "initial sync should not be marked done without flag")
}
@@ -97,7 +98,7 @@ func TestHandleMappingStream_NilHealthChecker(t *testing.T) {
}
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.True(t, syncDone, "sync done flag should be set even without health checker")
}
diff --git a/proxy/internal/metrics/metrics.go b/proxy/internal/metrics/metrics.go
index 573485625..41a6b0dd4 100644
--- a/proxy/internal/metrics/metrics.go
+++ b/proxy/internal/metrics/metrics.go
@@ -25,6 +25,11 @@ type Metrics struct {
backendDuration metric.Int64Histogram
certificateIssueDuration metric.Int64Histogram
+ // Management sync metrics.
+ snapshotSyncDuration metric.Int64Histogram
+ snapshotBatchDuration metric.Int64Histogram
+ addPeerDuration metric.Int64Histogram
+
// L4 service-level metrics.
l4Services metric.Int64UpDownCounter
@@ -54,6 +59,9 @@ func New(ctx context.Context, meter metric.Meter) (*Metrics, error) {
if err := m.initHTTPMetrics(meter); err != nil {
return nil, err
}
+ if err := m.initSyncMetrics(meter); err != nil {
+ return nil, err
+ }
if err := m.initL4Metrics(meter); err != nil {
return nil, err
}
@@ -126,6 +134,59 @@ func (m *Metrics) initHTTPMetrics(meter metric.Meter) error {
return err
}
+func (m *Metrics) initSyncMetrics(meter metric.Meter) error {
+ var err error
+
+ m.snapshotSyncDuration, err = meter.Int64Histogram(
+ "proxy.sync.snapshot.duration.ms",
+ metric.WithUnit("milliseconds"),
+ metric.WithDescription("Duration from management connect until the initial snapshot sync is complete"),
+ metric.WithExplicitBucketBoundaries(100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000, 300000),
+ )
+ if err != nil {
+ return err
+ }
+
+ m.snapshotBatchDuration, err = meter.Int64Histogram(
+ "proxy.sync.batch.duration.ms",
+ metric.WithUnit("milliseconds"),
+ metric.WithDescription("Duration to process a single mapping batch during initial snapshot sync"),
+ metric.WithExplicitBucketBoundaries(100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000, 300000),
+ )
+ if err != nil {
+ return err
+ }
+
+ m.addPeerDuration, err = meter.Int64Histogram(
+ "proxy.peer.add.duration.ms",
+ metric.WithUnit("milliseconds"),
+ metric.WithDescription("Duration to add a peer for an account (keygen + gRPC CreateProxyPeer + embed.New)"),
+ metric.WithExplicitBucketBoundaries(10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000),
+ )
+ return err
+}
+
+// RecordSnapshotSyncDuration records the total time from connect to sync-complete.
+func (m *Metrics) RecordSnapshotSyncDuration(d time.Duration) {
+ m.snapshotSyncDuration.Record(m.ctx, d.Milliseconds())
+}
+
+// RecordSnapshotBatchDuration records the time to process one mapping batch during initial sync.
+func (m *Metrics) RecordSnapshotBatchDuration(d time.Duration) {
+ m.snapshotBatchDuration.Record(m.ctx, d.Milliseconds())
+}
+
+// RecordAddPeerDuration records the time to create a new peer for an account.
+func (m *Metrics) RecordAddPeerDuration(d time.Duration, err error) {
+ result := "success"
+ if err != nil {
+ result = "error"
+ }
+ m.addPeerDuration.Record(m.ctx, d.Milliseconds(), metric.WithAttributes(
+ attribute.String("result", result),
+ ))
+}
+
func (m *Metrics) initL4Metrics(meter metric.Meter) error {
var err error
diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go
index e38e3dc4e..28eba7810 100644
--- a/proxy/internal/roundtrip/netbird.go
+++ b/proxy/internal/roundtrip/netbird.go
@@ -76,6 +76,11 @@ type clientEntry struct {
services map[ServiceKey]serviceInfo
createdAt time.Time
started bool
+ // ready is closed once the client has been fully initialized.
+ // Callers that find a pending entry wait on this channel before
+ // accessing the client. A nil initErr means success.
+ ready chan struct{}
+ initErr error
// Per-backend in-flight limiting keyed by target host:port.
// TODO: clean up stale entries when backend targets change.
inflightMu sync.Mutex
@@ -137,6 +142,11 @@ type NetBird struct {
clients map[types.AccountID]*clientEntry
initLogOnce sync.Once
statusNotifier statusNotifier
+
+ // OnAddPeer, when set, is called after AddPeer completes for a new account
+ // (i.e. when a new client was actually created, not when an existing one
+ // was reused). The duration covers keygen + gRPC CreateProxyPeer + embed.New.
+ OnAddPeer func(d time.Duration, err error)
}
// ClientDebugInfo contains debug information about a client.
@@ -157,6 +167,9 @@ type skipTLSVerifyContextKey struct{}
// AddPeer registers a service for an account. If the account doesn't have a client yet,
// one is created by authenticating with the management server using the provided token.
// Multiple services can share the same client.
+//
+// Client creation (WG keygen, gRPC, embed.New) runs without holding clientsMux
+// so that concurrent AddPeer calls for different accounts execute in parallel.
func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error {
si := serviceInfo{serviceID: serviceID}
@@ -164,10 +177,23 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
entry, exists := n.clients[accountID]
if exists {
+ ready := entry.ready
entry.services[key] = si
started := entry.started
n.clientsMux.Unlock()
+ // If the entry is still being initialized by another goroutine, wait.
+ if ready != nil {
+ select {
+ case <-ready:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ if entry.initErr != nil {
+ return fmt.Errorf("peer initialization failed: %w", entry.initErr)
+ }
+ }
+
n.logger.WithFields(log.Fields{
"account_id": accountID,
"service_key": key,
@@ -184,15 +210,43 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
return nil
}
- entry, err := n.createClientEntry(ctx, accountID, key, authToken, si)
+ // Insert a placeholder so other goroutines calling AddPeer for the same
+ // account will wait on the ready channel instead of starting a second
+ // client creation.
+ entry = &clientEntry{
+ services: map[ServiceKey]serviceInfo{key: si},
+ ready: make(chan struct{}),
+ }
+ n.clients[accountID] = entry
+ n.clientsMux.Unlock()
+
+ createStart := time.Now()
+ created, err := n.createClientEntry(ctx, accountID, key, authToken, si)
+ if n.OnAddPeer != nil {
+ n.OnAddPeer(time.Since(createStart), err)
+ }
if err != nil {
+ entry.initErr = err
+ close(entry.ready)
+
+ n.clientsMux.Lock()
+ delete(n.clients, accountID)
n.clientsMux.Unlock()
return err
}
- n.clients[accountID] = entry
+ // Transfer any services that were registered by concurrent AddPeer calls
+ // while we were creating the client.
+ n.clientsMux.Lock()
+ for k, v := range entry.services {
+ created.services[k] = v
+ }
+ created.ready = nil
+ n.clients[accountID] = created
n.clientsMux.Unlock()
+ close(entry.ready)
+
n.logger.WithFields(log.Fields{
"account_id": accountID,
"service_key": key,
@@ -200,13 +254,13 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
// Attempt to start the client in the background; if this fails we will
// retry on the first request via RoundTrip.
- go n.runClientStartup(ctx, accountID, entry.client)
+ go n.runClientStartup(ctx, accountID, created.client)
return nil
}
// createClientEntry generates a WireGuard keypair, authenticates with management,
-// and creates an embedded NetBird client. Must be called with clientsMux held.
+// and creates an embedded NetBird client.
func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) {
serviceID := si.serviceID
n.logger.WithFields(log.Fields{
diff --git a/proxy/process_mappings_bench_test.go b/proxy/process_mappings_bench_test.go
new file mode 100644
index 000000000..ca0792590
--- /dev/null
+++ b/proxy/process_mappings_bench_test.go
@@ -0,0 +1,300 @@
+package proxy
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+
+ "github.com/netbirdio/netbird/proxy/internal/auth"
+ "github.com/netbirdio/netbird/proxy/internal/conntrack"
+ "github.com/netbirdio/netbird/proxy/internal/crowdsec"
+ proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
+ "github.com/netbirdio/netbird/proxy/internal/proxy"
+ "github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ udprelay "github.com/netbirdio/netbird/proxy/internal/udp"
+ "github.com/netbirdio/netbird/shared/management/proto"
+
+ "go.opentelemetry.io/otel/metric/noop"
+)
+
+// latencyMockClient simulates realistic gRPC latency for management calls.
+type latencyMockClient struct {
+ proto.ProxyServiceClient
+ createPeerDelay time.Duration
+ statusUpdateDelay time.Duration
+}
+
+func (m *latencyMockClient) SendStatusUpdate(ctx context.Context, _ *proto.SendStatusUpdateRequest, _ ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) {
+ if m.statusUpdateDelay > 0 {
+ select {
+ case <-time.After(m.statusUpdateDelay):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+ return &proto.SendStatusUpdateResponse{}, nil
+}
+
+func (m *latencyMockClient) CreateProxyPeer(ctx context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ if m.createPeerDelay > 0 {
+ select {
+ case <-time.After(m.createPeerDelay):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+ return &proto.CreateProxyPeerResponse{Success: true}, nil
+}
+
+type discardWriter struct{}
+
+func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
+
+func benchServerWithLatency(b *testing.B, createPeerDelay, statusDelay time.Duration) *Server {
+ b.Helper()
+ logger := log.New()
+ logger.SetLevel(log.FatalLevel)
+ logger.SetOutput(&discardWriter{})
+
+ meter, err := proxymetrics.New(context.Background(), noop.Meter{})
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ mgmtClient := &latencyMockClient{
+ createPeerDelay: createPeerDelay,
+ statusUpdateDelay: statusDelay,
+ }
+
+ nb := roundtrip.NewNetBird("bench-proxy", "bench.test",
+ roundtrip.ClientConfig{MgmtAddr: "http://bench.test:9999"},
+ logger, nil, mgmtClient)
+
+ mainRouter := nbtcp.NewRouter(logger, func(accountID types.AccountID) (types.DialContextFunc, error) {
+ return (&net.Dialer{}).DialContext, nil
+ }, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443})
+
+ return &Server{
+ Logger: logger,
+ mgmtClient: mgmtClient,
+ netbird: nb,
+ proxy: proxy.NewReverseProxy(nil, "auto", nil, logger),
+ auth: auth.NewMiddleware(logger, nil, nil),
+ mainRouter: mainRouter,
+ mainPort: 443,
+ meter: meter,
+ hijackTracker: conntrack.HijackTracker{},
+ crowdsecRegistry: crowdsec.NewRegistry("", "", log.NewEntry(logger)),
+ crowdsecServices: make(map[types.ServiceID]bool),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ portRouters: make(map[uint16]*portRouter),
+ svcPorts: make(map[types.ServiceID][]uint16),
+ udpRelays: make(map[types.ServiceID]*udprelay.Relay),
+ }
+}
+
+// generateHTTPMappings creates N HTTP-mode mappings with the given update type.
+// All belong to a single account to share the embedded client.
+func generateHTTPMappings(n int, updateType proto.ProxyMappingUpdateType) []*proto.ProxyMapping {
+ mappings := make([]*proto.ProxyMapping, n)
+ for i := range n {
+ mappings[i] = &proto.ProxyMapping{
+ Type: updateType,
+ Id: fmt.Sprintf("svc-%d", i),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i),
+ Mode: "http",
+ Path: []*proto.PathMapping{
+ {
+ Path: "/",
+ Target: fmt.Sprintf("http://10.0.%d.%d:8080", (i/256)%256, i%256),
+ },
+ },
+ Auth: &proto.Authentication{},
+ }
+ }
+ return mappings
+}
+
+// generateMultiAccountHTTPMappings creates N HTTP-mode CREATED mappings spread
+// across the given number of accounts. This stresses the AddPeer new-account
+// path which calls CreateProxyPeer + embed.New per unique account.
+func generateMultiAccountHTTPMappings(n, accounts int) []*proto.ProxyMapping {
+ mappings := make([]*proto.ProxyMapping, n)
+ for i := range n {
+ mappings[i] = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: fmt.Sprintf("svc-%d", i),
+ AccountId: fmt.Sprintf("account-%d", i%accounts),
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i),
+ Mode: "http",
+ Path: []*proto.PathMapping{
+ {
+ Path: "/",
+ Target: fmt.Sprintf("http://10.0.%d.%d:8080", (i/256)%256, i%256),
+ },
+ },
+ Auth: &proto.Authentication{},
+ }
+ }
+ return mappings
+}
+
+// generateMixedMappings creates mappings with a realistic distribution:
+// 70% HTTP create, 15% modify existing, 10% TLS on main port, 5% remove.
+// All use a single account to avoid embed.New dialing.
+func generateMixedMappings(n int) []*proto.ProxyMapping {
+ mappings := make([]*proto.ProxyMapping, n)
+ for i := range n {
+ var m *proto.ProxyMapping
+ switch {
+ case i%20 < 14: // 70% HTTP create
+ m = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: fmt.Sprintf("svc-http-%d", i),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i),
+ Mode: "http",
+ Path: []*proto.PathMapping{
+ {Path: "/", Target: fmt.Sprintf("http://10.0.%d.%d:8080", (i/256)%256, i%256)},
+ {Path: "/api", Target: fmt.Sprintf("http://10.0.%d.%d:8081", (i/256)%256, i%256)},
+ },
+ Auth: &proto.Authentication{},
+ }
+ case i%20 < 17: // 15% modify
+ m = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED,
+ Id: fmt.Sprintf("svc-http-%d", i%100),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i%100),
+ Mode: "http",
+ Path: []*proto.PathMapping{
+ {Path: "/", Target: fmt.Sprintf("http://10.1.%d.%d:8080", (i/256)%256, i%256)},
+ },
+ Auth: &proto.Authentication{},
+ }
+ case i%20 < 19: // 10% TLS passthrough on main port
+ m = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: fmt.Sprintf("svc-tls-%d", i),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("tls-%d.bench.example.com", i),
+ Mode: "tls",
+ ListenPort: 443,
+ Path: []*proto.PathMapping{
+ {Path: "/", Target: fmt.Sprintf("10.2.%d.%d:443", (i/256)%256, i%256)},
+ },
+ }
+ default: // 5% remove
+ m = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED,
+ Id: fmt.Sprintf("svc-http-%d", i%50),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i%50),
+ Mode: "http",
+ }
+ }
+ mappings[i] = m
+ }
+ return mappings
+}
+
+const (
+ createPeerLatency = 100 * time.Millisecond
+ statusUpdateLatency = 50 * time.Millisecond
+)
+
+// BenchmarkProcessMappings_HTTPCreate_SingleAccount benchmarks the initial sync
+// scenario: N HTTP mappings all on a single account. Only the first mapping
+// triggers CreateProxyPeer (100ms gRPC). The rest just register with the
+// existing client. This is the "best case" production path.
+func BenchmarkProcessMappings_HTTPCreate_SingleAccount(b *testing.B) {
+ for _, n := range []int{100, 1000, 5000} {
+ b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
+ mappings := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED)
+ for range b.N {
+ s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency)
+ s.processMappings(b.Context(), mappings)
+ }
+ })
+ }
+}
+
+// BenchmarkProcessMappings_HTTPCreate_MultiAccount benchmarks the worst-case
+// initial sync: every mapping belongs to a different account, so each one
+// triggers a full CreateProxyPeer gRPC round-trip (100ms) + embed.New.
+// With 500 accounts this serializes to ~50s of blocking I/O.
+func BenchmarkProcessMappings_HTTPCreate_MultiAccount(b *testing.B) {
+ for _, tc := range []struct {
+ mappings int
+ accounts int
+ }{
+ {100, 10},
+ {100, 50},
+ {1000, 50},
+ {1000, 200},
+ {3000, 500},
+ } {
+ b.Run(fmt.Sprintf("mappings=%d/accounts=%d", tc.mappings, tc.accounts), func(b *testing.B) {
+ mappings := generateMultiAccountHTTPMappings(tc.mappings, tc.accounts)
+ for range b.N {
+ s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency)
+ s.processMappings(b.Context(), mappings)
+ }
+ })
+ }
+}
+
+// BenchmarkProcessMappings_Mixed benchmarks a realistic mixed workload
+// of creates, modifies, TLS, and removes with production-like latency.
+// TLS mappings call SendStatusUpdate (50ms each), serialized.
+func BenchmarkProcessMappings_Mixed(b *testing.B) {
+ for _, n := range []int{100, 1000, 5000} {
+ b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
+ mappings := generateMixedMappings(n)
+ for range b.N {
+ s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency)
+ creates := generateHTTPMappings(100, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED)
+ s.processMappings(b.Context(), creates)
+ s.processMappings(b.Context(), mappings)
+ }
+ })
+ }
+}
+
+// BenchmarkProcessMappings_ModifyOnly benchmarks bulk modification of
+// already-registered mappings (no new peers needed, no gRPC).
+func BenchmarkProcessMappings_ModifyOnly(b *testing.B) {
+ for _, n := range []int{100, 1000, 5000} {
+ b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
+ creates := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED)
+ modifies := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED)
+ for range b.N {
+ s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency)
+ s.processMappings(b.Context(), creates)
+ s.processMappings(b.Context(), modifies)
+ }
+ })
+ }
+}
+
+// BenchmarkProcessMappings_NoLatency measures pure CPU/allocation overhead
+// with zero I/O latency for profiling purposes.
+func BenchmarkProcessMappings_NoLatency(b *testing.B) {
+ for _, n := range []int{1000, 5000} {
+ b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
+ mappings := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED)
+ for range b.N {
+ s := benchServerWithLatency(b, 0, 0)
+ s.processMappings(b.Context(), mappings)
+ }
+ })
+ }
+}
diff --git a/proxy/server.go b/proxy/server.go
index 6980e1df1..6ccfa3e9a 100644
--- a/proxy/server.go
+++ b/proxy/server.go
@@ -32,9 +32,11 @@ import (
"go.opentelemetry.io/otel/sdk/metric"
"golang.org/x/exp/maps"
"google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
+ grpcstatus "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/proxy/internal/accesslog"
@@ -282,6 +284,7 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
WGPort: s.WireguardPort,
PreSharedKey: s.PreSharedKey,
}, s.Logger, s, s.mgmtClient)
+ s.netbird.OnAddPeer = s.meter.RecordAddPeerDuration
// Create health checker before the mapping worker so it can track
// management connectivity from the first stream connection.
@@ -938,6 +941,9 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
Clock: backoff.SystemClock,
}
+ // syncSupported tracks whether management supports SyncMappings.
+ // Starts true; set to false on first Unimplemented error.
+ syncSupported := true
initialSyncDone := false
operation := func() error {
@@ -949,36 +955,25 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
s.healthChecker.SetManagementConnected(false)
}
- supportsCrowdSec := s.crowdsecRegistry.Available()
- mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
- ProxyId: s.ID,
- Version: s.Version,
- StartedAt: timestamppb.New(s.startTime),
- Address: s.ProxyURL,
- Capabilities: &proto.ProxyCapabilities{
- SupportsCustomPorts: &s.SupportsCustomPorts,
- RequireSubdomain: &s.RequireSubdomain,
- SupportsCrowdsec: &supportsCrowdSec,
- },
- })
- if err != nil {
- return fmt.Errorf("create mapping stream: %w", err)
+ var streamErr error
+ if syncSupported {
+ streamErr = s.trySyncMappings(ctx, client, &initialSyncDone)
+ if isSyncUnimplemented(streamErr) {
+ syncSupported = false
+ s.Logger.Info("management does not support SyncMappings, falling back to GetMappingUpdate")
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone)
+ }
+ } else {
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone)
}
- if s.healthChecker != nil {
- s.healthChecker.SetManagementConnected(true)
- }
- s.Logger.Debug("management mapping stream established")
-
- // Stream established โ reset backoff so the next failure retries quickly.
- bo.Reset()
-
- streamErr := s.handleMappingStream(ctx, mappingClient, &initialSyncDone)
-
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(false)
}
+ // Stream established โ reset backoff so the next failure retries quickly.
+ bo.Reset()
+
if streamErr == nil {
return fmt.Errorf("stream closed by server")
}
@@ -995,56 +990,187 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
}
}
-func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient, initialSyncDone *bool) error {
+func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
+ supportsCrowdSec := s.crowdsecRegistry.Available()
+ return &proto.ProxyCapabilities{
+ SupportsCustomPorts: &s.SupportsCustomPorts,
+ RequireSubdomain: &s.RequireSubdomain,
+ SupportsCrowdsec: &supportsCrowdSec,
+ }
+}
+
+func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error {
+ connectTime := time.Now()
+ mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
+ ProxyId: s.ID,
+ Version: s.Version,
+ StartedAt: timestamppb.New(s.startTime),
+ Address: s.ProxyURL,
+ Capabilities: s.proxyCapabilities(),
+ })
+ if err != nil {
+ return fmt.Errorf("create mapping stream: %w", err)
+ }
+
+ if s.healthChecker != nil {
+ s.healthChecker.SetManagementConnected(true)
+ }
+ s.Logger.Debug("management mapping stream established (GetMappingUpdate)")
+
+ return s.handleMappingStream(ctx, mappingClient, initialSyncDone, connectTime)
+}
+
+func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error {
+ connectTime := time.Now()
+ stream, err := client.SyncMappings(ctx)
+ if err != nil {
+ return fmt.Errorf("create sync stream: %w", err)
+ }
+
+ // Send init message.
+ if err := stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: s.ID,
+ Version: s.Version,
+ StartedAt: timestamppb.New(s.startTime),
+ Address: s.ProxyURL,
+ Capabilities: s.proxyCapabilities(),
+ },
+ },
+ }); err != nil {
+ return fmt.Errorf("send sync init: %w", err)
+ }
+
+ if s.healthChecker != nil {
+ s.healthChecker.SetManagementConnected(true)
+ }
+ s.Logger.Debug("management mapping stream established (SyncMappings)")
+
+ return s.handleSyncMappingsStream(ctx, stream, initialSyncDone, connectTime)
+}
+
+func isSyncUnimplemented(err error) bool {
+ if err == nil {
+ return false
+ }
+ st, ok := grpcstatus.FromError(err)
+ return ok && st.Code() == codes.Unimplemented
+}
+
+func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.ProxyService_SyncMappingsClient, initialSyncDone *bool, connectTime time.Time) error {
select {
case <-s.routerReady:
case <-ctx.Done():
return ctx.Err()
}
- var snapshotIDs map[types.ServiceID]struct{}
- if !*initialSyncDone {
- snapshotIDs = make(map[types.ServiceID]struct{})
- }
+ tracker := s.newSnapshotTracker(initialSyncDone, connectTime)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ msg, err := stream.Recv()
+ switch {
+ case errors.Is(err, io.EOF):
+ return nil
+ case err != nil:
+ return fmt.Errorf("receive msg: %w", err)
+ }
+
+ batchStart := time.Now()
+ s.Logger.Debug("Received mapping update, starting processing")
+ s.processMappings(ctx, msg.GetMapping())
+ s.Logger.Debug("Processing mapping update completed")
+ tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
+
+ if err := stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ }); err != nil {
+ return fmt.Errorf("send ack: %w", err)
+ }
+ }
+ }
+}
+
+func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient, initialSyncDone *bool, connectTime time.Time) error {
+ select {
+ case <-s.routerReady:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+
+ tracker := s.newSnapshotTracker(initialSyncDone, connectTime)
for {
- // Check for context completion to gracefully shutdown.
select {
case <-ctx.Done():
- // Shutting down.
return ctx.Err()
default:
msg, err := mappingClient.Recv()
switch {
case errors.Is(err, io.EOF):
- // Mapping connection gracefully terminated by server.
return nil
case err != nil:
- // Something has gone horribly wrong, return and hope the parent retries the connection.
return fmt.Errorf("receive msg: %w", err)
}
+
+ batchStart := time.Now()
s.Logger.Debug("Received mapping update, starting processing")
s.processMappings(ctx, msg.GetMapping())
s.Logger.Debug("Processing mapping update completed")
-
- if !*initialSyncDone {
- for _, m := range msg.GetMapping() {
- snapshotIDs[types.ServiceID(m.GetId())] = struct{}{}
- }
- if msg.GetInitialSyncComplete() {
- s.reconcileSnapshot(ctx, snapshotIDs)
- snapshotIDs = nil
- if s.healthChecker != nil {
- s.healthChecker.SetInitialSyncComplete()
- }
- *initialSyncDone = true
- s.Logger.Info("Initial mapping sync complete")
- }
- }
+ tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
}
}
}
+// snapshotTracker accumulates service IDs during the initial snapshot and
+// finalises sync state when the complete flag arrives.
+type snapshotTracker struct {
+ done *bool
+ connectTime time.Time
+ snapshotIDs map[types.ServiceID]struct{}
+}
+
+func (s *Server) newSnapshotTracker(done *bool, connectTime time.Time) *snapshotTracker {
+ var ids map[types.ServiceID]struct{}
+ if !*done {
+ ids = make(map[types.ServiceID]struct{})
+ }
+ return &snapshotTracker{done: done, connectTime: connectTime, snapshotIDs: ids}
+}
+
+func (t *snapshotTracker) recordBatch(ctx context.Context, s *Server, mappings []*proto.ProxyMapping, syncComplete bool, batchStart time.Time) {
+ if *t.done {
+ return
+ }
+
+ if s.meter != nil {
+ s.meter.RecordSnapshotBatchDuration(time.Since(batchStart))
+ }
+
+ for _, m := range mappings {
+ t.snapshotIDs[types.ServiceID(m.GetId())] = struct{}{}
+ }
+
+ if !syncComplete {
+ return
+ }
+
+ s.reconcileSnapshot(ctx, t.snapshotIDs)
+ t.snapshotIDs = nil
+ if s.healthChecker != nil {
+ s.healthChecker.SetInitialSyncComplete()
+ }
+ *t.done = true
+ if s.meter != nil {
+ s.meter.RecordSnapshotSyncDuration(time.Since(t.connectTime))
+ }
+ s.Logger.Info("Initial mapping sync complete")
+}
+
// reconcileSnapshot removes local mappings that are absent from the snapshot.
// This ensures services deleted while the proxy was disconnected get cleaned up.
func (s *Server) reconcileSnapshot(ctx context.Context, snapshotIDs map[types.ServiceID]struct{}) {
@@ -1067,6 +1193,8 @@ func (s *Server) reconcileSnapshot(ctx context.Context, snapshotIDs map[types.Se
}
func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) {
+ s.ensurePeers(ctx, mappings)
+
for _, mapping := range mappings {
s.Logger.WithFields(log.Fields{
"type": mapping.GetType(),
@@ -1100,6 +1228,60 @@ func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMap
}
}
+// ensurePeers pre-creates NetBird peers for all unique accounts referenced by
+// CREATED mappings. Peers for different accounts are created concurrently,
+// which avoids serializing Nร100ms gRPC round-trips during large initial syncs.
+func (s *Server) ensurePeers(ctx context.Context, mappings []*proto.ProxyMapping) {
+ // Collect one representative mapping per account that needs a new peer.
+ type peerReq struct {
+ accountID types.AccountID
+ svcKey roundtrip.ServiceKey
+ authToken string
+ svcID types.ServiceID
+ }
+ seen := make(map[types.AccountID]struct{})
+ var reqs []peerReq
+ for _, m := range mappings {
+ if m.GetType() != proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED {
+ continue
+ }
+ accountID := types.AccountID(m.GetAccountId())
+ if _, ok := seen[accountID]; ok {
+ continue
+ }
+ seen[accountID] = struct{}{}
+ if s.netbird.HasClient(accountID) {
+ continue
+ }
+ reqs = append(reqs, peerReq{
+ accountID: accountID,
+ svcKey: s.serviceKeyForMapping(m),
+ authToken: m.GetAuthToken(),
+ svcID: types.ServiceID(m.GetId()),
+ })
+ }
+
+ if len(reqs) <= 1 {
+ return
+ }
+
+ var wg sync.WaitGroup
+ wg.Add(len(reqs))
+ for _, r := range reqs {
+ go func() {
+ defer wg.Done()
+ if err := s.netbird.AddPeer(ctx, r.accountID, r.svcKey, r.authToken, r.svcID); err != nil {
+ s.Logger.WithFields(log.Fields{
+ "account_id": r.accountID,
+ "service_id": r.svcID,
+ "error": err,
+ }).Warn("failed to pre-create peer for account")
+ }
+ }()
+ }
+ wg.Wait()
+}
+
// addMapping registers a service mapping and starts the appropriate relay or routes.
func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) error {
accountID := types.AccountID(mapping.GetAccountId())
diff --git a/proxy/snapshot_reconcile_test.go b/proxy/snapshot_reconcile_test.go
index 042d8df77..2e6c80cfc 100644
--- a/proxy/snapshot_reconcile_test.go
+++ b/proxy/snapshot_reconcile_test.go
@@ -4,6 +4,7 @@ import (
"context"
"io"
"testing"
+ "time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -139,7 +140,7 @@ func TestHandleMappingStream_BatchedSnapshotSyncComplete(t *testing.T) {
}
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.True(t, syncDone, "sync should be marked done after final batch")
}
@@ -164,7 +165,7 @@ func TestHandleMappingStream_PostSyncDoesNotReconcile(t *testing.T) {
}
syncDone := true // sync already completed in a previous stream
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
require.NoError(t, err)
assert.Len(t, s.lastMappings, 2,
@@ -185,7 +186,7 @@ func TestHandleMappingStream_ImmediateEOF_NoReconciliation(t *testing.T) {
stream := &mockMappingStream{} // no messages โ immediate EOF
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.False(t, syncDone, "sync should not be marked done on immediate EOF")
@@ -218,7 +219,7 @@ func TestHandleMappingStream_ErrorMidSync_NoReconciliation(t *testing.T) {
s.lastMappings["svc-stale"] = &proto.ProxyMapping{Id: "svc-stale", AccountId: "acct-1"}
syncDone := false
- err := s.handleMappingStream(context.Background(), &mockErrRecvStream{}, &syncDone)
+ err := s.handleMappingStream(context.Background(), &mockErrRecvStream{}, &syncDone, time.Time{})
assert.Error(t, err)
assert.False(t, syncDone)
diff --git a/proxy/sync_mappings_test.go b/proxy/sync_mappings_test.go
new file mode 100644
index 000000000..801587e4c
--- /dev/null
+++ b/proxy/sync_mappings_test.go
@@ -0,0 +1,525 @@
+package proxy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials/insecure"
+ grpcstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+func TestIntegration_SyncMappings_HappyPath(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ // Send init.
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "sync-proxy-1",
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ mappingsByID := make(map[string]*proto.ProxyMapping)
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+ for _, m := range msg.GetMapping() {
+ mappingsByID[m.GetId()] = m
+ }
+
+ // Ack every batch.
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ assert.Len(t, mappingsByID, 2, "Should receive 2 mappings")
+
+ rp1 := mappingsByID["rp-1"]
+ require.NotNil(t, rp1)
+ assert.Equal(t, "app1.test.proxy.io", rp1.GetDomain())
+ assert.Equal(t, "test-account-1", rp1.GetAccountId())
+ assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, rp1.GetType())
+ assert.NotEmpty(t, rp1.GetAuthToken(), "Should have auth token")
+
+ rp2 := mappingsByID["rp-2"]
+ require.NotNil(t, rp2)
+ assert.Equal(t, "app2.test.proxy.io", rp2.GetDomain())
+}
+
+func TestIntegration_SyncMappings_BackPressure(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ // Add enough services to guarantee multiple batches (default batch size 500).
+ addServicesToStore(t, setup, 600, "test.proxy.io")
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "sync-proxy-backpressure",
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ // Strategy: receive batch 1, then hold for a significant delay before
+ // acking. If back-pressure works, batch 2 cannot arrive until after
+ // the ack is sent โ so its receive timestamp must be >= the ack
+ // timestamp. If management were fire-and-forget, all batches would
+ // already be buffered in the gRPC transport and batch 2 would arrive
+ // well before the ack time.
+ const ackDelay = 300 * time.Millisecond
+
+ type batchEvent struct {
+ recvAt time.Time
+ ackAt time.Time
+ count int
+ }
+ var batches []batchEvent
+ var totalMappings int
+
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+
+ recvAt := time.Now()
+ totalMappings += len(msg.GetMapping())
+
+ // Delay the ack on non-final batches to create a measurable gap.
+ if !msg.GetInitialSyncComplete() {
+ time.Sleep(ackDelay)
+ }
+
+ ackAt := time.Now()
+ batches = append(batches, batchEvent{
+ recvAt: recvAt,
+ ackAt: ackAt,
+ count: len(msg.GetMapping()),
+ })
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ // 2 original + 600 added = 602 services total.
+ assert.Equal(t, 602, totalMappings, "should receive all 602 mappings")
+ require.GreaterOrEqual(t, len(batches), 2, "need at least 2 batches to verify back-pressure")
+
+ // For every batch after the first, its receive time must be after the
+ // previous batch's ack time. This proves management waited for the ack
+ // before sending the next batch.
+ for i := 1; i < len(batches); i++ {
+ prevAckAt := batches[i-1].ackAt
+ thisRecvAt := batches[i].recvAt
+ assert.True(t, !thisRecvAt.Before(prevAckAt),
+ "batch %d received at %v, but batch %d was acked at %v โ "+
+ "management sent the next batch before receiving the ack",
+ i, thisRecvAt, i-1, prevAckAt)
+ }
+}
+
+func TestIntegration_SyncMappings_IncrementalUpdate(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "sync-proxy-incremental",
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ // Drain initial snapshot.
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ // Now send an incremental update via the management server.
+ setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED,
+ Id: "rp-1",
+ AccountId: "test-account-1",
+ Domain: "app1.test.proxy.io",
+ }},
+ })
+
+ // Receive the incremental update on the sync stream.
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+ require.NotEmpty(t, msg.GetMapping())
+ assert.Equal(t, "rp-1", msg.GetMapping()[0].GetId())
+ assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msg.GetMapping()[0].GetType())
+}
+
+func TestIntegration_SyncMappings_MixedProxyVersions(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Old proxy uses GetMappingUpdate.
+ legacyStream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
+ ProxyId: "legacy-proxy",
+ Version: "old-v1",
+ Address: "test.proxy.io",
+ })
+ require.NoError(t, err)
+
+ var legacyMappings []*proto.ProxyMapping
+ for {
+ msg, err := legacyStream.Recv()
+ require.NoError(t, err)
+ legacyMappings = append(legacyMappings, msg.GetMapping()...)
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ // New proxy uses SyncMappings.
+ syncStream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = syncStream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "new-proxy",
+ Version: "new-v2",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ var syncMappings []*proto.ProxyMapping
+ for {
+ msg, err := syncStream.Recv()
+ require.NoError(t, err)
+ syncMappings = append(syncMappings, msg.GetMapping()...)
+
+ err = syncStream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ // Both should receive the same set of mappings.
+ assert.Equal(t, len(legacyMappings), len(syncMappings),
+ "legacy and sync proxies should receive the same number of mappings")
+
+ legacyIDs := make(map[string]bool)
+ for _, m := range legacyMappings {
+ legacyIDs[m.GetId()] = true
+ }
+ for _, m := range syncMappings {
+ assert.True(t, legacyIDs[m.GetId()],
+ "mapping %s should be present in both streams", m.GetId())
+ }
+
+ // Both proxies should be connected.
+ proxies := setup.proxyService.GetConnectedProxies()
+ assert.Contains(t, proxies, "legacy-proxy")
+ assert.Contains(t, proxies, "new-proxy")
+
+ // Both should receive incremental updates.
+ setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED,
+ Id: "rp-1",
+ AccountId: "test-account-1",
+ Domain: "app1.test.proxy.io",
+ }},
+ })
+
+ // Legacy proxy receives via GetMappingUpdateResponse.
+ legacyMsg, err := legacyStream.Recv()
+ require.NoError(t, err)
+ assert.Equal(t, "rp-1", legacyMsg.GetMapping()[0].GetId())
+
+ // Sync proxy receives via SyncMappingsResponse.
+ syncMsg, err := syncStream.Recv()
+ require.NoError(t, err)
+ assert.Equal(t, "rp-1", syncMsg.GetMapping()[0].GetId())
+}
+
+func TestIntegration_SyncMappings_Reconnect(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+ proxyID := "sync-proxy-reconnect"
+
+ receiveMappings := func() []*proto.ProxyMapping {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: proxyID,
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ var mappings []*proto.ProxyMapping
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+ mappings = append(mappings, msg.GetMapping()...)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+ return mappings
+ }
+
+ first := receiveMappings()
+ time.Sleep(100 * time.Millisecond)
+ second := receiveMappings()
+
+ assert.Equal(t, len(first), len(second),
+ "should receive same mappings on reconnect")
+
+ firstIDs := make(map[string]bool)
+ for _, m := range first {
+ firstIDs[m.GetId()] = true
+ }
+ for _, m := range second {
+ assert.True(t, firstIDs[m.GetId()],
+ "mapping %s should be present in both connections", m.GetId())
+ }
+}
+
+// --- Fallback tests: old management returns Unimplemented ---
+
+// unimplementedProxyServer embeds UnimplementedProxyServiceServer so
+// SyncMappings returns codes.Unimplemented while GetMappingUpdate works.
+type unimplementedSyncServer struct {
+ proto.UnimplementedProxyServiceServer
+ getMappingCalls atomic.Int32
+}
+
+func (s *unimplementedSyncServer) GetMappingUpdate(_ *proto.GetMappingUpdateRequest, stream proto.ProxyService_GetMappingUpdateServer) error {
+ s.getMappingCalls.Add(1)
+ return stream.Send(&proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{{Id: "svc-1", AccountId: "acct-1", Domain: "example.com"}},
+ InitialSyncComplete: true,
+ })
+}
+
+func TestIntegration_FallbackToGetMappingUpdate(t *testing.T) {
+ // Start a gRPC server that does NOT implement SyncMappings.
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+
+ srv := &unimplementedSyncServer{}
+ grpcServer := grpc.NewServer()
+ proto.RegisterProxyServiceServer(grpcServer, srv)
+ go func() { _ = grpcServer.Serve(lis) }()
+ defer grpcServer.GracefulStop()
+
+ conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+
+ // Try SyncMappings โ should get Unimplemented.
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "test-proxy",
+ Address: "test.example.com",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ _, err = stream.Recv()
+ require.Error(t, err)
+ st, ok := grpcstatus.FromError(err)
+ require.True(t, ok)
+ assert.Equal(t, codes.Unimplemented, st.Code(),
+ "unimplemented SyncMappings should return Unimplemented code")
+
+ // isSyncUnimplemented should detect this.
+ assert.True(t, isSyncUnimplemented(err))
+
+ // The actual fallback: GetMappingUpdate should work.
+ legacyStream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
+ ProxyId: "test-proxy",
+ Address: "test.example.com",
+ })
+ require.NoError(t, err)
+
+ msg, err := legacyStream.Recv()
+ require.NoError(t, err)
+ assert.True(t, msg.GetInitialSyncComplete())
+ assert.Len(t, msg.GetMapping(), 1)
+ assert.Equal(t, int32(1), srv.getMappingCalls.Load())
+}
+
+func TestIsSyncUnimplemented(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {"nil error", nil, false},
+ {"non-grpc error", errors.New("random"), false},
+ {"grpc internal", grpcstatus.Error(codes.Internal, "fail"), false},
+ {"grpc unavailable", grpcstatus.Error(codes.Unavailable, "fail"), false},
+ {"grpc unimplemented", grpcstatus.Error(codes.Unimplemented, "method not found"), true},
+ {
+ "wrapped unimplemented",
+ fmt.Errorf("create sync stream: %w", grpcstatus.Error(codes.Unimplemented, "nope")),
+ // grpc/status.FromError unwraps in recent versions of grpc-go.
+ true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, isSyncUnimplemented(tt.err))
+ })
+ }
+}
+
+// addServicesToStore adds n extra services to the test store for the given cluster.
+func addServicesToStore(t *testing.T, setup *integrationTestSetup, n int, cluster string) {
+ t.Helper()
+ ctx := context.Background()
+ for i := 0; i < n; i++ {
+ svc := &service.Service{
+ ID: fmt.Sprintf("extra-svc-%d", i),
+ AccountID: "test-account-1",
+ Name: fmt.Sprintf("Extra Service %d", i),
+ Domain: fmt.Sprintf("extra-%d.test.proxy.io", i),
+ ProxyCluster: cluster,
+ Enabled: true,
+ Targets: []*service.Target{{
+ Path: strPtr("/"),
+ Host: fmt.Sprintf("10.0.1.%d", i%256),
+ Port: 8080,
+ Protocol: "http",
+ TargetId: fmt.Sprintf("peer-extra-%d", i),
+ TargetType: "peer",
+ Enabled: true,
+ }},
+ }
+ require.NoError(t, setup.store.CreateService(ctx, svc))
+ }
+}
diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go
index 1095b6411..a3a5e4588 100644
--- a/shared/management/proto/proxy_service.pb.go
+++ b/shared/management/proto/proxy_service.pb.go
@@ -1970,6 +1970,269 @@ func (x *ValidateSessionResponse) GetDeniedReason() string {
return ""
}
+// SyncMappingsRequest is sent by the proxy on the bidirectional SyncMappings
+// stream. The first message MUST be an init; all subsequent messages MUST be
+// acks.
+type SyncMappingsRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // Types that are assignable to Msg:
+ //
+ // *SyncMappingsRequest_Init
+ // *SyncMappingsRequest_Ack
+ Msg isSyncMappingsRequest_Msg `protobuf_oneof:"msg"`
+}
+
+func (x *SyncMappingsRequest) Reset() {
+ *x = SyncMappingsRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[25]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SyncMappingsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SyncMappingsRequest) ProtoMessage() {}
+
+func (x *SyncMappingsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[25]
+ 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 SyncMappingsRequest.ProtoReflect.Descriptor instead.
+func (*SyncMappingsRequest) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{25}
+}
+
+func (m *SyncMappingsRequest) GetMsg() isSyncMappingsRequest_Msg {
+ if m != nil {
+ return m.Msg
+ }
+ return nil
+}
+
+func (x *SyncMappingsRequest) GetInit() *SyncMappingsInit {
+ if x, ok := x.GetMsg().(*SyncMappingsRequest_Init); ok {
+ return x.Init
+ }
+ return nil
+}
+
+func (x *SyncMappingsRequest) GetAck() *SyncMappingsAck {
+ if x, ok := x.GetMsg().(*SyncMappingsRequest_Ack); ok {
+ return x.Ack
+ }
+ return nil
+}
+
+type isSyncMappingsRequest_Msg interface {
+ isSyncMappingsRequest_Msg()
+}
+
+type SyncMappingsRequest_Init struct {
+ Init *SyncMappingsInit `protobuf:"bytes,1,opt,name=init,proto3,oneof"`
+}
+
+type SyncMappingsRequest_Ack struct {
+ Ack *SyncMappingsAck `protobuf:"bytes,2,opt,name=ack,proto3,oneof"`
+}
+
+func (*SyncMappingsRequest_Init) isSyncMappingsRequest_Msg() {}
+
+func (*SyncMappingsRequest_Ack) isSyncMappingsRequest_Msg() {}
+
+// SyncMappingsInit is the first message on the stream, carrying the same
+// identification fields as GetMappingUpdateRequest.
+type SyncMappingsInit struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ProxyId string `protobuf:"bytes,1,opt,name=proxy_id,json=proxyId,proto3" json:"proxy_id,omitempty"`
+ Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"`
+ StartedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"`
+ Address string `protobuf:"bytes,4,opt,name=address,proto3" json:"address,omitempty"`
+ Capabilities *ProxyCapabilities `protobuf:"bytes,5,opt,name=capabilities,proto3" json:"capabilities,omitempty"`
+}
+
+func (x *SyncMappingsInit) Reset() {
+ *x = SyncMappingsInit{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[26]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SyncMappingsInit) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SyncMappingsInit) ProtoMessage() {}
+
+func (x *SyncMappingsInit) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[26]
+ 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 SyncMappingsInit.ProtoReflect.Descriptor instead.
+func (*SyncMappingsInit) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{26}
+}
+
+func (x *SyncMappingsInit) GetProxyId() string {
+ if x != nil {
+ return x.ProxyId
+ }
+ return ""
+}
+
+func (x *SyncMappingsInit) GetVersion() string {
+ if x != nil {
+ return x.Version
+ }
+ return ""
+}
+
+func (x *SyncMappingsInit) GetStartedAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.StartedAt
+ }
+ return nil
+}
+
+func (x *SyncMappingsInit) GetAddress() string {
+ if x != nil {
+ return x.Address
+ }
+ return ""
+}
+
+func (x *SyncMappingsInit) GetCapabilities() *ProxyCapabilities {
+ if x != nil {
+ return x.Capabilities
+ }
+ return nil
+}
+
+// SyncMappingsAck is sent by the proxy after it has fully processed a batch.
+// Management waits for this before sending the next batch.
+type SyncMappingsAck struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+}
+
+func (x *SyncMappingsAck) Reset() {
+ *x = SyncMappingsAck{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[27]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SyncMappingsAck) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SyncMappingsAck) ProtoMessage() {}
+
+func (x *SyncMappingsAck) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[27]
+ 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 SyncMappingsAck.ProtoReflect.Descriptor instead.
+func (*SyncMappingsAck) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{27}
+}
+
+// SyncMappingsResponse is a batch of mappings sent by management.
+// Identical semantics to GetMappingUpdateResponse.
+type SyncMappingsResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Mapping []*ProxyMapping `protobuf:"bytes,1,rep,name=mapping,proto3" json:"mapping,omitempty"`
+ // initial_sync_complete is set on the last message of the initial snapshot.
+ InitialSyncComplete bool `protobuf:"varint,2,opt,name=initial_sync_complete,json=initialSyncComplete,proto3" json:"initial_sync_complete,omitempty"`
+}
+
+func (x *SyncMappingsResponse) Reset() {
+ *x = SyncMappingsResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[28]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SyncMappingsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SyncMappingsResponse) ProtoMessage() {}
+
+func (x *SyncMappingsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[28]
+ 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 SyncMappingsResponse.ProtoReflect.Descriptor instead.
+func (*SyncMappingsResponse) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{28}
+}
+
+func (x *SyncMappingsResponse) GetMapping() []*ProxyMapping {
+ if x != nil {
+ return x.Mapping
+ }
+ return nil
+}
+
+func (x *SyncMappingsResponse) GetInitialSyncComplete() bool {
+ if x != nil {
+ return x.InitialSyncComplete
+ }
+ return false
+}
+
var File_proxy_service_proto protoreflect.FileDescriptor
var file_proxy_service_proto_rawDesc = []byte{
@@ -2254,37 +2517,74 @@ var file_proxy_service_proto_rawDesc = []byte{
0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69,
0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73,
0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64,
- 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d,
- 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65,
- 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f,
- 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44,
- 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45,
- 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59,
- 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f,
- 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12,
- 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f,
- 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54,
- 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52,
- 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74,
- 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54,
- 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17,
- 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41,
- 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59,
- 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e,
- 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20,
- 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52,
- 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47,
- 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54,
- 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46,
- 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59,
- 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32,
- 0xfc, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
- 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70,
- 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61,
- 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e,
- 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30,
+ 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d,
+ 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32,
+ 0x0a, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61,
+ 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e,
+ 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e,
+ 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03,
+ 0x61, 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53,
+ 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12,
+ 0x19, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65,
+ 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72,
+ 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f,
+ 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
+ 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
+ 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12,
+ 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70,
+ 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f,
+ 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c,
+ 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f,
+ 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22,
+ 0x7e, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52,
+ 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69,
+ 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69,
+ 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69,
+ 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70,
+ 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74,
+ 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x2a,
+ 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55,
+ 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44,
+ 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44,
+ 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50,
+ 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13,
+ 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f,
+ 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77,
+ 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48,
+ 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54,
+ 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49,
+ 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01,
+ 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a,
+ 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45,
+ 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59,
+ 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01,
+ 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53,
+ 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41,
+ 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53,
+ 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54,
+ 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50,
+ 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54,
+ 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04,
+ 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53,
+ 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xd3, 0x05, 0x0a, 0x0c, 0x50, 0x72, 0x6f,
+ 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74,
+ 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61,
+ 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
+ 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79,
+ 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70,
+ 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70,
+ 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30,
0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c,
0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71,
@@ -2334,7 +2634,7 @@ func file_proxy_service_proto_rawDescGZIP() []byte {
}
var file_proxy_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
-var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
+var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 31)
var file_proxy_service_proto_goTypes = []interface{}{
(ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType
(PathRewriteMode)(0), // 1: management.PathRewriteMode
@@ -2364,19 +2664,23 @@ var file_proxy_service_proto_goTypes = []interface{}{
(*GetOIDCURLResponse)(nil), // 25: management.GetOIDCURLResponse
(*ValidateSessionRequest)(nil), // 26: management.ValidateSessionRequest
(*ValidateSessionResponse)(nil), // 27: management.ValidateSessionResponse
- nil, // 28: management.PathTargetOptions.CustomHeadersEntry
- nil, // 29: management.AccessLog.MetadataEntry
- (*timestamppb.Timestamp)(nil), // 30: google.protobuf.Timestamp
- (*durationpb.Duration)(nil), // 31: google.protobuf.Duration
+ (*SyncMappingsRequest)(nil), // 28: management.SyncMappingsRequest
+ (*SyncMappingsInit)(nil), // 29: management.SyncMappingsInit
+ (*SyncMappingsAck)(nil), // 30: management.SyncMappingsAck
+ (*SyncMappingsResponse)(nil), // 31: management.SyncMappingsResponse
+ nil, // 32: management.PathTargetOptions.CustomHeadersEntry
+ nil, // 33: management.AccessLog.MetadataEntry
+ (*timestamppb.Timestamp)(nil), // 34: google.protobuf.Timestamp
+ (*durationpb.Duration)(nil), // 35: google.protobuf.Duration
}
var file_proxy_service_proto_depIdxs = []int32{
- 30, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp
+ 34, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp
3, // 1: management.GetMappingUpdateRequest.capabilities:type_name -> management.ProxyCapabilities
11, // 2: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping
- 31, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration
+ 35, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration
1, // 4: management.PathTargetOptions.path_rewrite:type_name -> management.PathRewriteMode
- 28, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry
- 31, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration
+ 32, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry
+ 35, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration
6, // 7: management.PathMapping.options:type_name -> management.PathTargetOptions
8, // 8: management.Authentication.header_auths:type_name -> management.HeaderAuth
0, // 9: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType
@@ -2384,31 +2688,38 @@ var file_proxy_service_proto_depIdxs = []int32{
9, // 11: management.ProxyMapping.auth:type_name -> management.Authentication
10, // 12: management.ProxyMapping.access_restrictions:type_name -> management.AccessRestrictions
14, // 13: management.SendAccessLogRequest.log:type_name -> management.AccessLog
- 30, // 14: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp
- 29, // 15: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry
+ 34, // 14: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp
+ 33, // 15: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry
17, // 16: management.AuthenticateRequest.password:type_name -> management.PasswordRequest
18, // 17: management.AuthenticateRequest.pin:type_name -> management.PinRequest
16, // 18: management.AuthenticateRequest.header_auth:type_name -> management.HeaderAuthRequest
2, // 19: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus
- 4, // 20: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest
- 12, // 21: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest
- 15, // 22: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest
- 20, // 23: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest
- 22, // 24: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest
- 24, // 25: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest
- 26, // 26: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest
- 5, // 27: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse
- 13, // 28: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse
- 19, // 29: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse
- 21, // 30: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse
- 23, // 31: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse
- 25, // 32: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse
- 27, // 33: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse
- 27, // [27:34] is the sub-list for method output_type
- 20, // [20:27] is the sub-list for method input_type
- 20, // [20:20] is the sub-list for extension type_name
- 20, // [20:20] is the sub-list for extension extendee
- 0, // [0:20] is the sub-list for field type_name
+ 29, // 20: management.SyncMappingsRequest.init:type_name -> management.SyncMappingsInit
+ 30, // 21: management.SyncMappingsRequest.ack:type_name -> management.SyncMappingsAck
+ 34, // 22: management.SyncMappingsInit.started_at:type_name -> google.protobuf.Timestamp
+ 3, // 23: management.SyncMappingsInit.capabilities:type_name -> management.ProxyCapabilities
+ 11, // 24: management.SyncMappingsResponse.mapping:type_name -> management.ProxyMapping
+ 4, // 25: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest
+ 28, // 26: management.ProxyService.SyncMappings:input_type -> management.SyncMappingsRequest
+ 12, // 27: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest
+ 15, // 28: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest
+ 20, // 29: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest
+ 22, // 30: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest
+ 24, // 31: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest
+ 26, // 32: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest
+ 5, // 33: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse
+ 31, // 34: management.ProxyService.SyncMappings:output_type -> management.SyncMappingsResponse
+ 13, // 35: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse
+ 19, // 36: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse
+ 21, // 37: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse
+ 23, // 38: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse
+ 25, // 39: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse
+ 27, // 40: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse
+ 33, // [33:41] is the sub-list for method output_type
+ 25, // [25:33] is the sub-list for method input_type
+ 25, // [25:25] is the sub-list for extension type_name
+ 25, // [25:25] is the sub-list for extension extendee
+ 0, // [0:25] is the sub-list for field type_name
}
func init() { file_proxy_service_proto_init() }
@@ -2717,6 +3028,54 @@ func file_proxy_service_proto_init() {
return nil
}
}
+ file_proxy_service_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsInit); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsAck); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
}
file_proxy_service_proto_msgTypes[0].OneofWrappers = []interface{}{}
file_proxy_service_proto_msgTypes[12].OneofWrappers = []interface{}{
@@ -2726,13 +3085,17 @@ func file_proxy_service_proto_init() {
}
file_proxy_service_proto_msgTypes[17].OneofWrappers = []interface{}{}
file_proxy_service_proto_msgTypes[20].OneofWrappers = []interface{}{}
+ file_proxy_service_proto_msgTypes[25].OneofWrappers = []interface{}{
+ (*SyncMappingsRequest_Init)(nil),
+ (*SyncMappingsRequest_Ack)(nil),
+ }
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proxy_service_proto_rawDesc,
NumEnums: 3,
- NumMessages: 27,
+ NumMessages: 31,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto
index e359f0cbd..d1171b27e 100644
--- a/shared/management/proto/proxy_service.proto
+++ b/shared/management/proto/proxy_service.proto
@@ -12,6 +12,15 @@ import "google/protobuf/timestamp.proto";
service ProxyService {
rpc GetMappingUpdate(GetMappingUpdateRequest) returns (stream GetMappingUpdateResponse);
+ // SyncMappings is a bidirectional stream that replaces GetMappingUpdate for
+ // new proxies. The proxy sends an initial SyncMappingsRequest to start the
+ // stream and then sends an ack after each batch is fully processed.
+ // Management waits for the ack before sending the next batch, providing
+ // application-level back-pressure during large initial syncs.
+ // Old proxies continue using GetMappingUpdate; old management servers
+ // return Unimplemented for this RPC and proxies fall back.
+ rpc SyncMappings(stream SyncMappingsRequest) returns (stream SyncMappingsResponse);
+
rpc SendAccessLog(SendAccessLogRequest) returns (SendAccessLogResponse);
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
@@ -246,3 +255,35 @@ message ValidateSessionResponse {
string user_email = 3;
string denied_reason = 4;
}
+
+// SyncMappingsRequest is sent by the proxy on the bidirectional SyncMappings
+// stream. The first message MUST be an init; all subsequent messages MUST be
+// acks.
+message SyncMappingsRequest {
+ oneof msg {
+ SyncMappingsInit init = 1;
+ SyncMappingsAck ack = 2;
+ }
+}
+
+// SyncMappingsInit is the first message on the stream, carrying the same
+// identification fields as GetMappingUpdateRequest.
+message SyncMappingsInit {
+ string proxy_id = 1;
+ string version = 2;
+ google.protobuf.Timestamp started_at = 3;
+ string address = 4;
+ ProxyCapabilities capabilities = 5;
+}
+
+// SyncMappingsAck is sent by the proxy after it has fully processed a batch.
+// Management waits for this before sending the next batch.
+message SyncMappingsAck {}
+
+// SyncMappingsResponse is a batch of mappings sent by management.
+// Identical semantics to GetMappingUpdateResponse.
+message SyncMappingsResponse {
+ repeated ProxyMapping mapping = 1;
+ // initial_sync_complete is set on the last message of the initial snapshot.
+ bool initial_sync_complete = 2;
+}
diff --git a/shared/management/proto/proxy_service_grpc.pb.go b/shared/management/proto/proxy_service_grpc.pb.go
index 627b217d8..fdc031ed7 100644
--- a/shared/management/proto/proxy_service_grpc.pb.go
+++ b/shared/management/proto/proxy_service_grpc.pb.go
@@ -19,6 +19,14 @@ const _ = grpc.SupportPackageIsVersion7
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type ProxyServiceClient interface {
GetMappingUpdate(ctx context.Context, in *GetMappingUpdateRequest, opts ...grpc.CallOption) (ProxyService_GetMappingUpdateClient, error)
+ // SyncMappings is a bidirectional stream that replaces GetMappingUpdate for
+ // new proxies. The proxy sends an initial SyncMappingsRequest to start the
+ // stream and then sends an ack after each batch is fully processed.
+ // Management waits for the ack before sending the next batch, providing
+ // application-level back-pressure during large initial syncs.
+ // Old proxies continue using GetMappingUpdate; old management servers
+ // return Unimplemented for this RPC and proxies fall back.
+ SyncMappings(ctx context.Context, opts ...grpc.CallOption) (ProxyService_SyncMappingsClient, error)
SendAccessLog(ctx context.Context, in *SendAccessLogRequest, opts ...grpc.CallOption) (*SendAccessLogResponse, error)
Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*AuthenticateResponse, error)
SendStatusUpdate(ctx context.Context, in *SendStatusUpdateRequest, opts ...grpc.CallOption) (*SendStatusUpdateResponse, error)
@@ -69,6 +77,37 @@ func (x *proxyServiceGetMappingUpdateClient) Recv() (*GetMappingUpdateResponse,
return m, nil
}
+func (c *proxyServiceClient) SyncMappings(ctx context.Context, opts ...grpc.CallOption) (ProxyService_SyncMappingsClient, error) {
+ stream, err := c.cc.NewStream(ctx, &ProxyService_ServiceDesc.Streams[1], "/management.ProxyService/SyncMappings", opts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &proxyServiceSyncMappingsClient{stream}
+ return x, nil
+}
+
+type ProxyService_SyncMappingsClient interface {
+ Send(*SyncMappingsRequest) error
+ Recv() (*SyncMappingsResponse, error)
+ grpc.ClientStream
+}
+
+type proxyServiceSyncMappingsClient struct {
+ grpc.ClientStream
+}
+
+func (x *proxyServiceSyncMappingsClient) Send(m *SyncMappingsRequest) error {
+ return x.ClientStream.SendMsg(m)
+}
+
+func (x *proxyServiceSyncMappingsClient) Recv() (*SyncMappingsResponse, error) {
+ m := new(SyncMappingsResponse)
+ if err := x.ClientStream.RecvMsg(m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
func (c *proxyServiceClient) SendAccessLog(ctx context.Context, in *SendAccessLogRequest, opts ...grpc.CallOption) (*SendAccessLogResponse, error) {
out := new(SendAccessLogResponse)
err := c.cc.Invoke(ctx, "/management.ProxyService/SendAccessLog", in, out, opts...)
@@ -128,6 +167,14 @@ func (c *proxyServiceClient) ValidateSession(ctx context.Context, in *ValidateSe
// for forward compatibility
type ProxyServiceServer interface {
GetMappingUpdate(*GetMappingUpdateRequest, ProxyService_GetMappingUpdateServer) error
+ // SyncMappings is a bidirectional stream that replaces GetMappingUpdate for
+ // new proxies. The proxy sends an initial SyncMappingsRequest to start the
+ // stream and then sends an ack after each batch is fully processed.
+ // Management waits for the ack before sending the next batch, providing
+ // application-level back-pressure during large initial syncs.
+ // Old proxies continue using GetMappingUpdate; old management servers
+ // return Unimplemented for this RPC and proxies fall back.
+ SyncMappings(ProxyService_SyncMappingsServer) error
SendAccessLog(context.Context, *SendAccessLogRequest) (*SendAccessLogResponse, error)
Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateResponse, error)
SendStatusUpdate(context.Context, *SendStatusUpdateRequest) (*SendStatusUpdateResponse, error)
@@ -146,6 +193,9 @@ type UnimplementedProxyServiceServer struct {
func (UnimplementedProxyServiceServer) GetMappingUpdate(*GetMappingUpdateRequest, ProxyService_GetMappingUpdateServer) error {
return status.Errorf(codes.Unimplemented, "method GetMappingUpdate not implemented")
}
+func (UnimplementedProxyServiceServer) SyncMappings(ProxyService_SyncMappingsServer) error {
+ return status.Errorf(codes.Unimplemented, "method SyncMappings not implemented")
+}
func (UnimplementedProxyServiceServer) SendAccessLog(context.Context, *SendAccessLogRequest) (*SendAccessLogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SendAccessLog not implemented")
}
@@ -198,6 +248,32 @@ func (x *proxyServiceGetMappingUpdateServer) Send(m *GetMappingUpdateResponse) e
return x.ServerStream.SendMsg(m)
}
+func _ProxyService_SyncMappings_Handler(srv interface{}, stream grpc.ServerStream) error {
+ return srv.(ProxyServiceServer).SyncMappings(&proxyServiceSyncMappingsServer{stream})
+}
+
+type ProxyService_SyncMappingsServer interface {
+ Send(*SyncMappingsResponse) error
+ Recv() (*SyncMappingsRequest, error)
+ grpc.ServerStream
+}
+
+type proxyServiceSyncMappingsServer struct {
+ grpc.ServerStream
+}
+
+func (x *proxyServiceSyncMappingsServer) Send(m *SyncMappingsResponse) error {
+ return x.ServerStream.SendMsg(m)
+}
+
+func (x *proxyServiceSyncMappingsServer) Recv() (*SyncMappingsRequest, error) {
+ m := new(SyncMappingsRequest)
+ if err := x.ServerStream.RecvMsg(m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
func _ProxyService_SendAccessLog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SendAccessLogRequest)
if err := dec(in); err != nil {
@@ -344,6 +420,12 @@ var ProxyService_ServiceDesc = grpc.ServiceDesc{
Handler: _ProxyService_GetMappingUpdate_Handler,
ServerStreams: true,
},
+ {
+ StreamName: "SyncMappings",
+ Handler: _ProxyService_SyncMappings_Handler,
+ ServerStreams: true,
+ ClientStreams: true,
+ },
},
Metadata: "proxy_service.proto",
}
From 454ff66518feaef2d9ceb10fb1960da28f14fd87 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Wed, 20 May 2026 18:24:00 +0200
Subject: [PATCH 03/44] [management] scope network router update call (#6222)
---
.../networks_handler_integration_test.go | 8 +-
management/server/networks/manager_test.go | 7 +-
management/server/networks/routers/manager.go | 15 ++-
.../server/networks/routers/manager_test.go | 97 +++++++++++++++++
management/server/store/sql_store.go | 24 ++++-
management/server/store/sql_store_test.go | 37 ++++++-
management/server/store/store.go | 3 +-
management/server/store/store_mock.go | 102 ++++++++++--------
management/server/testdata/networks.sql | 4 +
9 files changed, 235 insertions(+), 62 deletions(-)
diff --git a/management/server/http/testing/integration/networks_handler_integration_test.go b/management/server/http/testing/integration/networks_handler_integration_test.go
index 54f204a8f..a0a49a9ec 100644
--- a/management/server/http/testing/integration/networks_handler_integration_test.go
+++ b/management/server/http/testing/integration/networks_handler_integration_test.go
@@ -1319,7 +1319,7 @@ func Test_NetworkRouters_Update(t *testing.T) {
},
},
{
- name: "Update non-existing router creates it",
+ name: "Update non-existing router returns not found",
networkId: "testNetworkId",
routerId: "nonExistingRouterId",
requestBody: &api.NetworkRouterRequest{
@@ -1328,11 +1328,7 @@ func Test_NetworkRouters_Update(t *testing.T) {
Metric: 100,
Enabled: true,
},
- expectedStatus: http.StatusOK,
- verifyResponse: func(t *testing.T, router *api.NetworkRouter) {
- t.Helper()
- assert.Equal(t, "nonExistingRouterId", router.Id)
- },
+ expectedStatus: http.StatusNotFound,
},
{
name: "Update router with both peer and peer_groups",
diff --git a/management/server/networks/manager_test.go b/management/server/networks/manager_test.go
index 6fb19d157..24d5f49b7 100644
--- a/management/server/networks/manager_test.go
+++ b/management/server/networks/manager_test.go
@@ -34,8 +34,11 @@ func Test_GetAllNetworksReturnsNetworks(t *testing.T) {
networks, err := manager.GetAllNetworks(ctx, accountID, userID)
require.NoError(t, err)
- require.Len(t, networks, 1)
- require.Equal(t, "testNetworkId", networks[0].ID)
+ ids := make([]string, 0, len(networks))
+ for _, n := range networks {
+ ids = append(ids, n.ID)
+ }
+ require.ElementsMatch(t, []string{"testNetworkId", "secondNetworkId"}, ids)
}
func Test_GetAllNetworksReturnsPermissionDenied(t *testing.T) {
diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go
index c7c3f2ff4..ed5b0e558 100644
--- a/management/server/networks/routers/manager.go
+++ b/management/server/networks/routers/manager.go
@@ -102,7 +102,7 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
router.ID = xid.New().String()
- err = transaction.SaveNetworkRouter(ctx, router)
+ err = transaction.CreateNetworkRouter(ctx, router)
if err != nil {
return fmt.Errorf("failed to create network router: %w", err)
}
@@ -162,11 +162,20 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t
return fmt.Errorf("failed to get network: %w", err)
}
- if network.ID != router.NetworkID {
+ existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, router.AccountID, router.ID)
+ if err != nil {
+ return fmt.Errorf("failed to get network router: %w", err)
+ }
+
+ if existing.AccountID != router.AccountID {
+ return status.NewNetworkRouterNotFoundError(router.ID)
+ }
+
+ if existing.NetworkID != router.NetworkID {
return status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID)
}
- err = transaction.SaveNetworkRouter(ctx, router)
+ err = transaction.UpdateNetworkRouter(ctx, router)
if err != nil {
return fmt.Errorf("failed to update network router: %w", err)
}
diff --git a/management/server/networks/routers/manager_test.go b/management/server/networks/routers/manager_test.go
index 6be90baa7..7b6d5f14f 100644
--- a/management/server/networks/routers/manager_test.go
+++ b/management/server/networks/routers/manager_test.go
@@ -195,6 +195,7 @@ func Test_UpdateRouterSuccessfully(t *testing.T) {
if err != nil {
require.NoError(t, err)
}
+ router.ID = "testRouterId"
s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir())
if err != nil {
@@ -210,6 +211,102 @@ func Test_UpdateRouterSuccessfully(t *testing.T) {
require.Equal(t, router.Metric, updatedRouter.Metric)
}
+func Test_UpdateRouterRejectsCrossAccountID(t *testing.T) {
+ ctx := context.Background()
+ userID := "testAdminId"
+
+ // Admin of testAccountId tries to update a router that belongs to otherAccountId
+ // by passing the other account's router ID through the URL.
+ router, err := types.NewNetworkRouter("testAccountId", "testNetworkId", "testPeerId", []string{}, false, 1, true)
+ if err != nil {
+ require.NoError(t, err)
+ }
+ router.ID = "otherRouterId"
+
+ s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(cleanUp)
+ permissionsManager := permissions.NewManager(s)
+ am := mock_server.MockAccountManager{}
+ manager := NewManager(s, permissionsManager, &am)
+
+ updatedRouter, err := manager.UpdateRouter(ctx, userID, router)
+ require.Error(t, err)
+ require.Nil(t, updatedRouter)
+
+ // The other account's router must be untouched.
+ stored, err := s.GetNetworkRouterByID(ctx, store.LockingStrengthNone, "otherAccountId", "otherRouterId")
+ require.NoError(t, err)
+ require.Equal(t, "otherAccountId", stored.AccountID)
+ require.Equal(t, "otherNetworkId", stored.NetworkID)
+ require.Equal(t, "otherPeer", stored.Peer)
+ require.Equal(t, 1, stored.Metric)
+}
+
+func Test_CreateRouterRejectsCrossAccountID(t *testing.T) {
+ ctx := context.Background()
+ userID := "testAdminId"
+
+ // Admin of testAccountId tries to create a router in otherAccountId's network.
+ // The permission check is on router.AccountID (their own), but the network
+ // lookup must fail because (testAccountId, otherNetworkId) does not exist.
+ router, err := types.NewNetworkRouter("testAccountId", "otherNetworkId", "testPeerId", []string{}, false, 1, true)
+ if err != nil {
+ require.NoError(t, err)
+ }
+
+ s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(cleanUp)
+ permissionsManager := permissions.NewManager(s)
+ am := mock_server.MockAccountManager{}
+ manager := NewManager(s, permissionsManager, &am)
+
+ createdRouter, err := manager.CreateRouter(ctx, userID, router)
+ require.Error(t, err)
+ require.Nil(t, createdRouter)
+
+ // No router should have been created in either account's scope under otherNetworkId.
+ routersInOther, err := s.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, "otherAccountId", "otherNetworkId")
+ require.NoError(t, err)
+ require.Len(t, routersInOther, 1)
+ require.Equal(t, "otherRouterId", routersInOther[0].ID)
+}
+
+func Test_UpdateRouterRejectsNetworkMismatch(t *testing.T) {
+ ctx := context.Background()
+ userID := "testAdminId"
+
+ // The router exists in testNetworkId, but the caller submits secondNetworkId
+ // (a different network in the same account). The update must be refused.
+ router, err := types.NewNetworkRouter("testAccountId", "secondNetworkId", "testPeerId", []string{}, false, 1, true)
+ if err != nil {
+ require.NoError(t, err)
+ }
+ router.ID = "testRouterId"
+
+ s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(cleanUp)
+ permissionsManager := permissions.NewManager(s)
+ am := mock_server.MockAccountManager{}
+ manager := NewManager(s, permissionsManager, &am)
+
+ updatedRouter, err := manager.UpdateRouter(ctx, userID, router)
+ require.Error(t, err)
+ require.Nil(t, updatedRouter)
+
+ stored, err := s.GetNetworkRouterByID(ctx, store.LockingStrengthNone, "testAccountId", "testRouterId")
+ require.NoError(t, err)
+ require.Equal(t, "testNetworkId", stored.NetworkID)
+}
+
func Test_UpdateRouterFailsWithPermissionDenied(t *testing.T) {
ctx := context.Background()
userID := "testUserId"
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index f3c6b741b..279c0e21f 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -4315,11 +4315,27 @@ func (s *SqlStore) GetNetworkRouterByID(ctx context.Context, lockStrength Lockin
return netRouter, nil
}
-func (s *SqlStore) SaveNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error {
- result := s.db.Save(router)
+func (s *SqlStore) CreateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error {
+ if err := s.db.Create(router).Error; err != nil {
+ log.WithContext(ctx).Errorf("failed to create network router in store: %v", err)
+ return status.Errorf(status.Internal, "failed to create network router in store")
+ }
+
+ return nil
+}
+
+func (s *SqlStore) UpdateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error {
+ result := s.db.
+ Select("*").
+ Where(accountAndIDQueryCondition, router.AccountID, router.ID).
+ Updates(router)
if result.Error != nil {
- log.WithContext(ctx).Errorf("failed to save network router to store: %v", result.Error)
- return status.Errorf(status.Internal, "failed to save network router to store")
+ log.WithContext(ctx).Errorf("failed to update network router in store: %v", result.Error)
+ return status.Errorf(status.Internal, "failed to update network router in store")
+ }
+
+ if result.RowsAffected == 0 {
+ return status.NewNetworkRouterNotFoundError(router.ID)
}
return nil
diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go
index 7515add62..41e3290b6 100644
--- a/management/server/store/sql_store_test.go
+++ b/management/server/store/sql_store_test.go
@@ -2399,7 +2399,7 @@ func TestSqlStore_GetNetworkRouterByID(t *testing.T) {
}
}
-func TestSqlStore_SaveNetworkRouter(t *testing.T) {
+func TestSqlStore_CreateNetworkRouter(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
@@ -2410,7 +2410,7 @@ func TestSqlStore_SaveNetworkRouter(t *testing.T) {
netRouter, err := routerTypes.NewNetworkRouter(accountID, networkID, "", []string{"net-router-grp"}, true, 0, true)
require.NoError(t, err)
- err = store.SaveNetworkRouter(context.Background(), netRouter)
+ err = store.CreateNetworkRouter(context.Background(), netRouter)
require.NoError(t, err)
savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, netRouter.ID)
@@ -2418,6 +2418,39 @@ func TestSqlStore_SaveNetworkRouter(t *testing.T) {
require.Equal(t, netRouter, savedNetRouter)
}
+func TestSqlStore_UpdateNetworkRouter(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+ networkID := "ct286bi7qv930dsrrug0"
+ routerID := "ctc20ji7qv9ck2sebc80"
+
+ netRouter := &routerTypes.NetworkRouter{
+ ID: routerID,
+ AccountID: accountID,
+ NetworkID: networkID,
+ Peer: "",
+ PeerGroups: []string{"net-router-grp"},
+ Masquerade: true,
+ Metric: 42,
+ Enabled: true,
+ }
+
+ err = store.UpdateNetworkRouter(context.Background(), netRouter)
+ require.NoError(t, err)
+
+ savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, routerID)
+ require.NoError(t, err)
+ require.Equal(t, netRouter, savedNetRouter)
+
+ // Updating a router under a different account must not match any row.
+ netRouter.AccountID = "non-existent-account"
+ err = store.UpdateNetworkRouter(context.Background(), netRouter)
+ require.Error(t, err)
+}
+
func TestSqlStore_DeleteNetworkRouter(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanup)
diff --git a/management/server/store/store.go b/management/server/store/store.go
index 42cdcf36d..39b1c0ed3 100644
--- a/management/server/store/store.go
+++ b/management/server/store/store.go
@@ -228,7 +228,8 @@ type Store interface {
GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*routerTypes.NetworkRouter, error)
GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error)
GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*routerTypes.NetworkRouter, error)
- SaveNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error
+ CreateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error
+ UpdateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error
DeleteNetworkRouter(ctx context.Context, accountID, routerID string) error
GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*resourceTypes.NetworkResource, error)
diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go
index 4f9d875d2..c7e86c2db 100644
--- a/management/server/store/store_mock.go
+++ b/management/server/store/store_mock.go
@@ -310,6 +310,20 @@ func (mr *MockStoreMockRecorder) CreateGroups(ctx, accountID, groups interface{}
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroups", reflect.TypeOf((*MockStore)(nil).CreateGroups), ctx, accountID, groups)
}
+// CreateNetworkRouter mocks base method.
+func (m *MockStore) CreateNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CreateNetworkRouter", ctx, router)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// CreateNetworkRouter indicates an expected call of CreateNetworkRouter.
+func (mr *MockStoreMockRecorder) CreateNetworkRouter(ctx, router interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNetworkRouter", reflect.TypeOf((*MockStore)(nil).CreateNetworkRouter), ctx, router)
+}
+
// CreatePeerJob mocks base method.
func (m *MockStore) CreatePeerJob(ctx context.Context, job *types2.Job) error {
m.ctrl.T.Helper()
@@ -2612,6 +2626,36 @@ func (mr *MockStoreMockRecorder) MarkPATUsed(ctx, patID interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPATUsed", reflect.TypeOf((*MockStore)(nil).MarkPATUsed), ctx, patID)
}
+// MarkPeerConnectedIfNewerSession mocks base method.
+func (m *MockStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "MarkPeerConnectedIfNewerSession", ctx, accountID, peerID, newSessionStartedAt)
+ ret0, _ := ret[0].(bool)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// MarkPeerConnectedIfNewerSession indicates an expected call of MarkPeerConnectedIfNewerSession.
+func (mr *MockStoreMockRecorder) MarkPeerConnectedIfNewerSession(ctx, accountID, peerID, newSessionStartedAt interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnectedIfNewerSession", reflect.TypeOf((*MockStore)(nil).MarkPeerConnectedIfNewerSession), ctx, accountID, peerID, newSessionStartedAt)
+}
+
+// MarkPeerDisconnectedIfSameSession mocks base method.
+func (m *MockStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "MarkPeerDisconnectedIfSameSession", ctx, accountID, peerID, sessionStartedAt)
+ ret0, _ := ret[0].(bool)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// MarkPeerDisconnectedIfSameSession indicates an expected call of MarkPeerDisconnectedIfSameSession.
+func (mr *MockStoreMockRecorder) MarkPeerDisconnectedIfSameSession(ctx, accountID, peerID, sessionStartedAt interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnectedIfSameSession", reflect.TypeOf((*MockStore)(nil).MarkPeerDisconnectedIfSameSession), ctx, accountID, peerID, sessionStartedAt)
+}
+
// MarkPendingJobsAsFailed mocks base method.
func (m *MockStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error {
m.ctrl.T.Helper()
@@ -2822,20 +2866,6 @@ func (mr *MockStoreMockRecorder) SaveNetworkResource(ctx, resource interface{})
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetworkResource", reflect.TypeOf((*MockStore)(nil).SaveNetworkResource), ctx, resource)
}
-// SaveNetworkRouter mocks base method.
-func (m *MockStore) SaveNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "SaveNetworkRouter", ctx, router)
- ret0, _ := ret[0].(error)
- return ret0
-}
-
-// SaveNetworkRouter indicates an expected call of SaveNetworkRouter.
-func (mr *MockStoreMockRecorder) SaveNetworkRouter(ctx, router interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetworkRouter", reflect.TypeOf((*MockStore)(nil).SaveNetworkRouter), ctx, router)
-}
-
// SavePAT mocks base method.
func (m *MockStore) SavePAT(ctx context.Context, pat *types2.PersonalAccessToken) error {
m.ctrl.T.Helper()
@@ -2892,36 +2922,6 @@ func (mr *MockStoreMockRecorder) SavePeerStatus(ctx, accountID, peerID, status i
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerStatus", reflect.TypeOf((*MockStore)(nil).SavePeerStatus), ctx, accountID, peerID, status)
}
-// MarkPeerConnectedIfNewerSession mocks base method.
-func (m *MockStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "MarkPeerConnectedIfNewerSession", ctx, accountID, peerID, newSessionStartedAt)
- ret0, _ := ret[0].(bool)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// MarkPeerConnectedIfNewerSession indicates an expected call of MarkPeerConnectedIfNewerSession.
-func (mr *MockStoreMockRecorder) MarkPeerConnectedIfNewerSession(ctx, accountID, peerID, newSessionStartedAt interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnectedIfNewerSession", reflect.TypeOf((*MockStore)(nil).MarkPeerConnectedIfNewerSession), ctx, accountID, peerID, newSessionStartedAt)
-}
-
-// MarkPeerDisconnectedIfSameSession mocks base method.
-func (m *MockStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "MarkPeerDisconnectedIfSameSession", ctx, accountID, peerID, sessionStartedAt)
- ret0, _ := ret[0].(bool)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// MarkPeerDisconnectedIfSameSession indicates an expected call of MarkPeerDisconnectedIfSameSession.
-func (mr *MockStoreMockRecorder) MarkPeerDisconnectedIfSameSession(ctx, accountID, peerID, sessionStartedAt interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnectedIfSameSession", reflect.TypeOf((*MockStore)(nil).MarkPeerDisconnectedIfSameSession), ctx, accountID, peerID, sessionStartedAt)
-}
-
// SavePolicy mocks base method.
func (m *MockStore) SavePolicy(ctx context.Context, policy *types2.Policy) error {
m.ctrl.T.Helper()
@@ -3173,6 +3173,20 @@ func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{}
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroups", reflect.TypeOf((*MockStore)(nil).UpdateGroups), ctx, accountID, groups)
}
+// UpdateNetworkRouter mocks base method.
+func (m *MockStore) UpdateNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateNetworkRouter", ctx, router)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateNetworkRouter indicates an expected call of UpdateNetworkRouter.
+func (mr *MockStoreMockRecorder) UpdateNetworkRouter(ctx, router interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateNetworkRouter", reflect.TypeOf((*MockStore)(nil).UpdateNetworkRouter), ctx, router)
+}
+
// UpdateProxyHeartbeat mocks base method.
func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error {
m.ctrl.T.Helper()
diff --git a/management/server/testdata/networks.sql b/management/server/testdata/networks.sql
index bcb202084..911b3bb27 100644
--- a/management/server/testdata/networks.sql
+++ b/management/server/testdata/networks.sql
@@ -9,9 +9,13 @@ INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM
CREATE TABLE `networks` (`id` text,`account_id` text,`name` text,`description` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_networks` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`));
INSERT INTO networks VALUES('testNetworkId','testAccountId','some-name','some-description');
+INSERT INTO networks VALUES('secondNetworkId','testAccountId','second-name','second-description');
CREATE TABLE `network_routers` (`id` text,`network_id` text,`account_id` text,`peer` text,`peer_groups` text,`masquerade` numeric,`metric` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_routers` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`));
INSERT INTO network_routers VALUES('testRouterId','testNetworkId','testAccountId','','["csquuo4jcko732k1ag00"]',0,9999);
+INSERT INTO accounts VALUES('otherAccountId','','2024-10-02 16:01:38.000000000+00:00','other.com','private',1,'otherNetworkIdentifier','{"IP":"100.65.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL);
+INSERT INTO networks VALUES('otherNetworkId','otherAccountId','other-net','other-description');
+INSERT INTO network_routers VALUES('otherRouterId','otherNetworkId','otherAccountId','otherPeer',NULL,0,1);
CREATE TABLE `network_resources` (`id` text,`network_id` text,`account_id` text,`name` text,`description` text,`type` text,`address` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_resources` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`));
INSERT INTO network_resources VALUES('testResourceId','testNetworkId','testAccountId','some-name','some-description','host','3.3.3.3/32');
From 37052fd5bc050f8eaf80b986d511f3c278233cca Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Thu, 21 May 2026 01:46:51 +0900
Subject: [PATCH 04/44] [client] Fix nil channel panic in external chain
monitor stop (#6224)
---
client/firewall/nftables/external_chain_monitor_linux.go | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/client/firewall/nftables/external_chain_monitor_linux.go b/client/firewall/nftables/external_chain_monitor_linux.go
index 2a2e04c09..9c91c95cf 100644
--- a/client/firewall/nftables/external_chain_monitor_linux.go
+++ b/client/firewall/nftables/external_chain_monitor_linux.go
@@ -52,9 +52,10 @@ func (m *externalChainMonitor) start() {
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
- m.done = make(chan struct{})
+ done := make(chan struct{})
+ m.done = done
- go m.run(ctx)
+ go m.run(ctx, done)
}
func (m *externalChainMonitor) stop() {
@@ -72,8 +73,8 @@ func (m *externalChainMonitor) stop() {
<-done
}
-func (m *externalChainMonitor) run(ctx context.Context) {
- defer close(m.done)
+func (m *externalChainMonitor) run(ctx context.Context, done chan struct{}) {
+ defer close(done)
bo := &backoff.ExponentialBackOff{
InitialInterval: externalMonitorInitInterval,
From 0358be23136da50e829ba99a83e54ef555071a7f Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Thu, 21 May 2026 23:27:12 +0900
Subject: [PATCH 05/44] [client] Revert "Clean up legacy 32-bit and HKCU
registry entries on Windows install (#6176)" (#6232)
This reverts commit d927ef468a73a15c734987b9cd5478f2a5b12738.
---
client/installer.nsis | 23 +++++------------------
client/netbird.wxs | 25 -------------------------
2 files changed, 5 insertions(+), 43 deletions(-)
diff --git a/client/installer.nsis b/client/installer.nsis
index 3e057df10..63bff1c5b 100644
--- a/client/installer.nsis
+++ b/client/installer.nsis
@@ -260,23 +260,15 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
-; Drop Run, App Paths and Uninstall entries left in the 32-bit registry view
-; or HKCU by legacy installers.
-DetailPrint "Cleaning legacy 32-bit / HKCU entries..."
-DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-SetRegView 32
-DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-DeleteRegKey HKLM "${REG_APP_PATH}"
-DeleteRegKey HKLM "${UI_REG_APP_PATH}"
-DeleteRegKey HKLM "${UNINSTALL_PATH}"
-SetRegView 64
-
+; Create autostart registry entry based on checkbox
DetailPrint "Autostart enabled: $AutostartEnabled"
${If} $AutostartEnabled == "1"
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
${Else}
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
+ ; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
+ DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
DetailPrint "Autostart not enabled by user"
${EndIf}
@@ -307,16 +299,11 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
DetailPrint "Terminating Netbird UI process..."
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
-; Remove autostart entries from every view a previous installer may have used.
+; Remove autostart registry entry
DetailPrint "Removing autostart registry entry if exists..."
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
+; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-SetRegView 32
-DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-DeleteRegKey HKLM "${REG_APP_PATH}"
-DeleteRegKey HKLM "${UI_REG_APP_PATH}"
-DeleteRegKey HKLM "${UNINSTALL_PATH}"
-SetRegView 64
; Handle data deletion based on checkbox
DetailPrint "Checking if user requested data deletion..."
diff --git a/client/netbird.wxs b/client/netbird.wxs
index 96814ce52..6f18b63b5 100644
--- a/client/netbird.wxs
+++ b/client/netbird.wxs
@@ -64,13 +64,6 @@
-
-
-
-
-
@@ -83,28 +76,10 @@
-
-
-
-
-
-
-
-
-
-
-
From 7aebdd69dd3acd40f827ae87b14d3a4b3e620dbf Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Mon, 25 May 2026 17:41:50 +0200
Subject: [PATCH 06/44] [management, client, proxy] add expose NetBird-only
services over tunnel peers (#6226)
Adds a new "private" service mode for the reverse proxy: services reachable exclusively over the embedded WireGuard tunnel, gated by per-peer group membership instead of operator auth schemes.
Wire contract
- ProxyMapping.private (field 13): the proxy MUST call ValidateTunnelPeer and fail closed; operator schemes are bypassed.
- ProxyCapabilities.private (4) + supports_private_service (5): capability gate. Management never streams private mappings to proxies that don't claim the capability; the broadcast path applies the same filter via filterMappingsForProxy.
- ValidateTunnelPeer RPC: resolves an inbound tunnel IP to a peer, checks the peer's groups against service.AccessGroups, and mints a session JWT on success. checkPeerGroupAccess fails closed when a private service has empty AccessGroups.
- ValidateSession/ValidateTunnelPeer responses now carry peer_group_ids + peer_group_names so the proxy can authorise policy-aware middlewares without an extra management round-trip.
- ProxyInboundListener + SendStatusUpdate.inbound_listener: per-account inbound listener state surfaced to dashboards.
- PathTargetOptions.direct_upstream (11): bypass the embedded NetBird client and dial the target via the proxy host's network stack for upstreams reachable without WireGuard.
Data model
- Service.Private (bool) + Service.AccessGroups ([]string, JSON- serialised). Validate() rejects bearer auth on private services. Copy() deep-copies AccessGroups. pgx getServices loads the columns.
- DomainConfig.Private threaded into the proxy auth middleware. Request handler routes private services through forwardWithTunnelPeer and returns 403 on validation failure.
- Account-level SynthesizePrivateServiceZones (synthetic DNS) and injectPrivateServicePolicies (synthetic ACL) gate on len(svc.AccessGroups) > 0.
Proxy
- /netbird proxy --private (embedded mode) flag; Config.Private in proxy/lifecycle.go.
- Per-account inbound listener (proxy/inbound.go) binding HTTP/HTTPS on the embedded NetBird client's WireGuard tunnel netstack.
- proxy/internal/auth/tunnel_cache: ValidateTunnelPeer response cache with single-flight de-duplication and per-account eviction.
- Local peerstore short-circuit: when the inbound IP isn't in the account roster, deny fast without an RPC.
- proxy/server.go reports SupportsPrivateService=true and redacts the full ProxyMapping JSON from info logs (auth_token + header-auth hashed values now only at debug level).
Identity forwarding
- ValidateSessionJWT returns user_id, email, method, groups, group_names. sessionkey.Claims carries Email + Groups + GroupNames so the proxy can stamp identity onto upstream requests without an extra management round-trip on every cookie-bearing request.
- CapturedData carries userEmail / userGroups / userGroupNames; the proxy stamps X-NetBird-User and X-NetBird-Groups on r.Out from the authenticated identity (strips client-supplied values first to prevent spoofing).
- AccessLog.UserGroups: access-log enrichment captures the user's group memberships at write time so the dashboard can render group context without reverse-resolving stale memberships.
OpenAPI/dashboard surface
- ReverseProxyService gains private + access_groups; ReverseProxyCluster gains private + supports_private. ReverseProxyTarget target_type enum gains "cluster". ServiceTargetOptions gains direct_upstream. ProxyAccessLog gains user_groups.
---
.github/workflows/proto-version-check.yml | 66 +-
client/embed/embed.go | 22 +
client/internal/dns/local/local.go | 100 ++
client/internal/dns/local/local_test.go | 126 ++
client/internal/dns/server.go | 27 +
client/internal/peer/status.go | 82 +-
client/internal/peer/status_test.go | 27 +
management/internals/modules/peers/manager.go | 29 +
.../internals/modules/peers/manager_mock.go | 61 +-
.../modules/reverseproxy/domain/domain.go | 2 +
.../reverseproxy/domain/manager/api.go | 1 +
.../reverseproxy/domain/manager/manager.go | 3 +
.../domain/manager/manager_test.go | 7 +-
.../modules/reverseproxy/proxy/manager.go | 1 +
.../reverseproxy/proxy/manager/manager.go | 7 +-
.../proxy/manager/manager_test.go | 23 +-
.../reverseproxy/proxy/manager_mock.go | 14 +
.../modules/reverseproxy/proxy/proxy.go | 8 +-
.../reverseproxy/service/manager/api.go | 1 +
.../reverseproxy/service/manager/manager.go | 20 +-
.../service/manager/manager_test.go | 63 +
.../modules/reverseproxy/service/service.go | 144 +-
.../reverseproxy/service/service_test.go | 189 +++
.../reverseproxy/sessionkey/sessionkey.go | 27 +-
management/internals/shared/grpc/proxy.go | 272 +++-
.../shared/grpc/proxy_group_access_test.go | 51 +
.../shared/grpc/validate_session_test.go | 4 +-
management/server/metrics/selfhosted.go | 39 +
management/server/metrics/selfhosted_test.go | 71 +-
management/server/peer.go | 23 +
management/server/store/file_store.go | 6 +
management/server/store/sql_store.go | 57 +-
.../server/store/sql_store_service_test.go | 46 +
management/server/store/store.go | 30 +
management/server/store/store_mock.go | 29 +
management/server/types/account.go | 162 ++-
management/server/types/account_components.go | 1 +
.../types/account_private_netmap_test.go | 85 ++
.../types/account_private_zones_test.go | 256 ++++
management/server/types/account_test.go | 208 ++-
management/server/users/manager.go | 34 +
proxy/auth/auth.go | 44 +-
proxy/cmd/proxy/cmd/root.go | 11 +-
proxy/inbound.go | 547 ++++++++
proxy/inbound_test.go | 502 +++++++
proxy/internal/auth/identity.go | 47 +
proxy/internal/auth/middleware.go | 216 ++-
proxy/internal/auth/middleware_test.go | 260 +++-
proxy/internal/auth/tunnel_cache.go | 171 +++
proxy/internal/auth/tunnel_cache_test.go | 171 +++
proxy/internal/auth/tunnel_lookup_test.go | 325 +++++
proxy/internal/debug/client.go | 53 +-
proxy/internal/debug/handler.go | 65 +-
proxy/internal/proxy/context.go | 86 +-
proxy/internal/proxy/reverseproxy.go | 72 +
proxy/internal/proxy/reverseproxy_test.go | 242 ++++
proxy/internal/proxy/servicemapping.go | 4 +
proxy/internal/restrict/restrict.go | 12 +
proxy/internal/restrict/restrict_test.go | 28 +
proxy/internal/roundtrip/multi.go | 112 ++
proxy/internal/roundtrip/multi_test.go | 134 ++
proxy/internal/roundtrip/netbird.go | 170 ++-
proxy/internal/roundtrip/netbird_test.go | 31 +
proxy/internal/tcp/bench_test.go | 4 +-
proxy/internal/tcp/router.go | 130 +-
proxy/internal/tcp/router_test.go | 94 ++
proxy/internal/tcp/snipeek.go | 16 +-
proxy/internal/tcp/snipeek_test.go | 16 +-
proxy/internal/types/types.go | 20 +
proxy/lifecycle.go | 160 +++
proxy/management_integration_test.go | 5 +
proxy/server.go | 630 ++++++---
proxy/server_test.go | 156 +++
shared/management/client/rest/client.go | 5 +
.../client/rest/reverse_proxy_clusters.go | 21 +-
.../rest/reverse_proxy_clusters_test.go | 90 ++
.../rest/reverse_proxy_services_test.go | 25 +-
.../client/rest/reverse_proxy_tokens.go | 72 +
.../client/rest/reverse_proxy_tokens_test.go | 131 ++
shared/management/http/api/openapi.yml | 41 +-
shared/management/http/api/types.gen.go | 35 +-
shared/management/proto/proxy_service.pb.go | 1226 +++++++++++------
shared/management/proto/proxy_service.proto | 89 ++
.../management/proto/proxy_service_grpc.pb.go | 50 +
84 files changed, 7810 insertions(+), 933 deletions(-)
create mode 100644 management/server/store/sql_store_service_test.go
create mode 100644 management/server/types/account_private_netmap_test.go
create mode 100644 management/server/types/account_private_zones_test.go
create mode 100644 proxy/inbound.go
create mode 100644 proxy/inbound_test.go
create mode 100644 proxy/internal/auth/identity.go
create mode 100644 proxy/internal/auth/tunnel_cache.go
create mode 100644 proxy/internal/auth/tunnel_cache_test.go
create mode 100644 proxy/internal/auth/tunnel_lookup_test.go
create mode 100644 proxy/internal/roundtrip/multi.go
create mode 100644 proxy/internal/roundtrip/multi_test.go
create mode 100644 proxy/lifecycle.go
create mode 100644 shared/management/client/rest/reverse_proxy_clusters_test.go
create mode 100644 shared/management/client/rest/reverse_proxy_tokens.go
create mode 100644 shared/management/client/rest/reverse_proxy_tokens_test.go
diff --git a/.github/workflows/proto-version-check.yml b/.github/workflows/proto-version-check.yml
index ea300419d..bec503b36 100644
--- a/.github/workflows/proto-version-check.yml
+++ b/.github/workflows/proto-version-check.yml
@@ -20,34 +20,66 @@ jobs:
per_page: 100,
});
- const pbFiles = files.filter(f => f.filename.endsWith('.pb.go'));
- const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename);
- if (missingPatch.length > 0) {
- core.setFailed(
- `Cannot inspect patch data for:\n` +
- missingPatch.map(f => `- ${f}`).join('\n') +
- `\nThis can happen with very large PRs. Verify proto versions manually.`
- );
+ const modifiedPbFiles = files.filter(
+ f => f.filename.endsWith('.pb.go') && f.status === 'modified'
+ );
+ if (modifiedPbFiles.length === 0) {
+ console.log('No modified .pb.go files to check');
return;
}
- const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
- const violations = [];
- for (const file of pbFiles) {
- const changed = file.patch
- .split('\n')
- .filter(line => versionPattern.test(line));
- if (changed.length > 0) {
+ const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
+ const baseSha = context.payload.pull_request.base.sha;
+ const headSha = context.payload.pull_request.head.sha;
+
+ async function getVersionHeader(path, ref) {
+ try {
+ const res = await github.rest.repos.getContent({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ path,
+ ref,
+ });
+ if (!res.data.content) {
+ return { ok: false, reason: 'no inline content (file too large)' };
+ }
+ const content = Buffer.from(res.data.content, 'base64').toString('utf8');
+ const lines = content
+ .split('\n')
+ .slice(0, 20)
+ .filter(line => versionPattern.test(line));
+ return { ok: true, lines };
+ } catch (e) {
+ return { ok: false, reason: e.message };
+ }
+ }
+
+ const violations = [];
+ for (const file of modifiedPbFiles) {
+ const [base, head] = await Promise.all([
+ getVersionHeader(file.filename, baseSha),
+ getVersionHeader(file.filename, headSha),
+ ]);
+ if (!base.ok || !head.ok) {
+ core.warning(
+ `Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
+ );
+ continue;
+ }
+ if (base.lines.join('\n') !== head.lines.join('\n')) {
violations.push({
file: file.filename,
- lines: changed,
+ base: base.lines,
+ head: head.lines,
});
}
}
if (violations.length > 0) {
const details = violations.map(v =>
- `${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}`
+ `${v.file}:\n` +
+ ` base:\n${v.base.map(l => ' ' + l).join('\n') || ' (none)'}\n` +
+ ` head:\n${v.head.map(l => ' ' + l).join('\n') || ' (none)'}`
).join('\n\n');
core.setFailed(
diff --git a/client/embed/embed.go b/client/embed/embed.go
index 8b669e547..7e7f6c337 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -84,6 +84,12 @@ type Options struct {
DisableIPv6 bool
// BlockInbound blocks all inbound connections from peers
BlockInbound bool
+ // BlockLANAccess blocks the embedded peer from reaching the host's
+ // LAN (RFC 1918, link-local, loopback) when it's used as a routing
+ // peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
+ // when the embedded client must never act as a stepping stone into
+ // the host's local network (e.g. the proxy's overlay peer).
+ BlockLANAccess bool
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
WireguardPort *int
// MTU is the MTU for the tunnel interface.
@@ -175,6 +181,7 @@ func New(opts Options) (*Client, error) {
DisableClientRoutes: &opts.DisableClientRoutes,
DisableIPv6: &opts.DisableIPv6,
BlockInbound: &opts.BlockInbound,
+ BlockLANAccess: &opts.BlockLANAccess,
WireguardPort: opts.WireguardPort,
MTU: opts.MTU,
DNSLabels: parsedLabels,
@@ -405,6 +412,21 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
}, nil
}
+// IdentityForIP looks up a remote peer by its tunnel IP using the
+// embedded client's status recorder. Returns the peer's WireGuard public
+// key and FQDN. ok=false means the IP isn't in this client's peer
+// roster โ callers should treat that as "unknown peer".
+func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
+ if !ip.IsValid() || c.recorder == nil {
+ return "", "", false
+ }
+ state, found := c.recorder.PeerStateByIP(ip.String())
+ if !found {
+ return "", "", false
+ }
+ return state.PubKey, state.FQDN, true
+}
+
// Status returns the current status of the client.
func (c *Client) Status() (peer.FullStatus, error) {
c.mu.Lock()
diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go
index 4a75a76b6..d13aa672e 100644
--- a/client/internal/dns/local/local.go
+++ b/client/internal/dns/local/local.go
@@ -26,6 +26,19 @@ type resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
+// PeerConnectivity reports whether a tunnel IP belongs to a peer the
+// client knows about and whether that peer is currently connected. The
+// local resolver uses this to suppress A/AAAA answers whose RDATA points
+// at a disconnected peer (typical case: a synthesized private-service
+// record pointing at an embedded proxy peer that just went offline).
+//
+// known=false means the IP isn't in the local peerstore at all โ the
+// record is left alone (it points at something outside our mesh, e.g.
+// a non-peer upstream).
+type PeerConnectivity interface {
+ IsConnectedByIP(ip string) (known, connected bool)
+}
+
type Resolver struct {
mu sync.RWMutex
records map[dns.Question][]dns.RR
@@ -33,6 +46,11 @@ type Resolver struct {
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
zones map[domain.Domain]bool
resolver resolver
+ // peerConn, when non-nil, is consulted on every A/AAAA answer to
+ // drop records pointing at disconnected peers. nil disables the
+ // filter and preserves the legacy "return whatever is registered"
+ // behaviour for callers that never wire a status source.
+ peerConn PeerConnectivity
ctx context.Context
cancel context.CancelFunc
@@ -49,6 +67,15 @@ func NewResolver() *Resolver {
}
}
+// SetPeerConnectivity wires the per-IP connectivity check used to filter
+// out A/AAAA answers pointing at disconnected peers. Pass nil to disable.
+// Safe to call multiple times; the latest value wins.
+func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.peerConn = p
+}
+
func (d *Resolver) MatchSubdomains() bool {
return true
}
@@ -95,6 +122,7 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
replyMessage.RecursionAvailable = true
result := d.lookupRecords(logger, question)
+ result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
replyMessage.Authoritative = !result.hasExternalData
replyMessage.Answer = result.records
replyMessage.Rcode = d.determineRcode(question, result)
@@ -436,6 +464,78 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
}
}
+// filterDisconnectedPeerAnswers drops A/AAAA records whose RDATA matches
+// a known but disconnected peer. The synthesized private-service zones
+// emit one A record per connected proxy peer in a cluster; when a peer
+// goes offline, the server-side refresh removes the record from the
+// next netmap, but the client may still hold the previous netmap for a
+// short window. This filter is the local belt to that braces โ even on
+// the stale netmap, the resolver hides the offline target.
+//
+// Records pointing at unknown IPs (outside the local peerstore, e.g.
+// non-mesh upstreams) are never dropped. Non-A/AAAA records pass
+// through untouched.
+//
+// Escape hatch: if filtering would leave the answer empty AND at least
+// one record was filtered, the original list is returned. Better to
+// hand the client a record that may not respond than NXDOMAIN it
+// completely when every proxy peer is offline (the upstream may still
+// be reachable some other way, or the peerstore may be stale).
+func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
+ if len(records) == 0 {
+ return records
+ }
+ d.mu.RLock()
+ checker := d.peerConn
+ d.mu.RUnlock()
+ if checker == nil {
+ return records
+ }
+
+ kept := make([]dns.RR, 0, len(records))
+ var dropped int
+ for _, rr := range records {
+ ip := extractRecordIP(rr)
+ if ip == "" {
+ kept = append(kept, rr)
+ continue
+ }
+ known, connected := checker.IsConnectedByIP(ip)
+ if known && !connected {
+ dropped++
+ continue
+ }
+ kept = append(kept, rr)
+ }
+ if dropped == 0 {
+ return records
+ }
+ if len(kept) == 0 {
+ logger.Debugf("all %d answers for %s point at disconnected peers; returning the original list", dropped, question.Name)
+ return records
+ }
+ logger.Tracef("dropped %d disconnected-peer answer(s) for %s, returning %d", dropped, question.Name, len(kept))
+ return kept
+}
+
+// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
+// an A or AAAA record, or "" for any other record type.
+func extractRecordIP(rr dns.RR) string {
+ switch r := rr.(type) {
+ case *dns.A:
+ if r.A == nil {
+ return ""
+ }
+ return r.A.String()
+ case *dns.AAAA:
+ if r.AAAA == nil {
+ return ""
+ }
+ return r.AAAA.String()
+ }
+ return ""
+}
+
// Update replaces all zones and their records
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
d.mu.Lock()
diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go
index 2c6b7dbc3..fdf7f2659 100644
--- a/client/internal/dns/local/local_test.go
+++ b/client/internal/dns/local/local_test.go
@@ -30,6 +30,21 @@ func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([
return nil, nil
}
+// mockPeerConnectivity returns canned (known, connected) results per IP.
+// Used by the disconnected-peer filter tests below. IPs not in the map
+// are reported as unknown so the filter leaves them alone.
+type mockPeerConnectivity struct {
+ byIP map[string]struct{ known, connected bool }
+}
+
+func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
+ v, ok := m.byIP[ip]
+ if !ok {
+ return false, false
+ }
+ return v.known, v.connected
+}
+
func TestLocalResolver_ServeDNS(t *testing.T) {
recordA := nbdns.SimpleRecord{
Name: "peera.netbird.cloud.",
@@ -2652,3 +2667,114 @@ func BenchmarkIsInManagedZone_ManyZones(b *testing.B) {
resolver.isInManagedZone(qname)
}
}
+
+// TestLocalResolver_FilterDisconnectedPeerAnswers verifies the
+// connectivity-aware filtering layered on top of lookupRecords:
+// when an A record's IP belongs to a known peer that's disconnected,
+// the record is dropped from the answer. Records for unknown IPs pass
+// through. If filtering would empty the answer entirely and at least
+// one record was dropped, the original list is restored (escape hatch
+// for the "all proxies offline" case).
+func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
+ zone := "svc.cluster.netbird."
+ connectedRec := nbdns.SimpleRecord{
+ Name: zone,
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 5,
+ RData: "100.64.0.10",
+ }
+ disconnectedRec := nbdns.SimpleRecord{
+ Name: zone,
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 5,
+ RData: "100.64.0.11",
+ }
+ unknownRec := nbdns.SimpleRecord{
+ Name: zone,
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 5,
+ RData: "203.0.113.5",
+ }
+
+ type ipState struct{ known, connected bool }
+ tests := []struct {
+ name string
+ records []nbdns.SimpleRecord
+ connByIP map[string]ipState
+ wantInOrder []string
+ }{
+ {
+ name: "drops disconnected peer, keeps connected",
+ records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.10": {known: true, connected: true},
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"100.64.0.10"},
+ },
+ {
+ name: "unknown IPs pass through untouched",
+ records: []nbdns.SimpleRecord{unknownRec, disconnectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"203.0.113.5"},
+ },
+ {
+ name: "all disconnected falls back to original list",
+ records: []nbdns.SimpleRecord{disconnectedRec, connectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.10": {known: true, connected: false},
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"100.64.0.11", "100.64.0.10"},
+ },
+ {
+ name: "no checker wired returns all records",
+ records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
+ connByIP: nil,
+ wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ resolver := NewResolver()
+ if tc.connByIP != nil {
+ cm := mockPeerConnectivity{byIP: make(map[string]struct{ known, connected bool }, len(tc.connByIP))}
+ for ip, st := range tc.connByIP {
+ cm.byIP[ip] = struct{ known, connected bool }{st.known, st.connected}
+ }
+ resolver.SetPeerConnectivity(cm)
+ }
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: strings.TrimSuffix(zone, "."),
+ Records: tc.records,
+ NonAuthoritative: true,
+ }})
+
+ var got *dns.Msg
+ writer := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ got = m
+ return nil
+ },
+ }
+ req := new(dns.Msg).SetQuestion(zone, dns.TypeA)
+ resolver.ServeDNS(writer, req)
+
+ require.NotNil(t, got, "resolver must produce a response")
+ require.Len(t, got.Answer, len(tc.wantInOrder),
+ "answer count must match expected: %v", tc.wantInOrder)
+ for i, want := range tc.wantInOrder {
+ a, ok := got.Answer[i].(*dns.A)
+ require.True(t, ok, "answer[%d] must be an A record", i)
+ assert.Equal(t, want, a.A.String(),
+ "answer[%d] expected %s got %s", i, want, a.A.String())
+ }
+ })
+ }
+}
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index e689f3586..7a35e56d8 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -301,6 +301,11 @@ func newDefaultServer(
warningDelayBase: defaultWarningDelayBase,
healthRefresh: make(chan struct{}, 1),
}
+ // Wire the local resolver against the peer status recorder so it can
+ // suppress A/AAAA answers that point at disconnected peers (typical
+ // case: synthesised private-service records pointing at an embedded
+ // proxy peer that just went offline).
+ defaultServer.localResolver.SetPeerConnectivity(localPeerConnectivity{statusRecorder})
// register with root zone, handler chain takes care of the routing
dnsService.RegisterMux(".", handlerChain)
@@ -1386,3 +1391,25 @@ func (s *DefaultServer) PopulateManagementDomain(mgmtURL *url.URL) error {
}
return nil
}
+
+// localPeerConnectivity adapts *peer.Status to local.PeerConnectivity so
+// the local resolver can ask "is this IP a known peer and is it
+// connected?" without taking on the peer package as a dependency.
+// A nil status recorder always reports known=false so the resolver
+// short-circuits to the legacy "return everything" path.
+type localPeerConnectivity struct {
+ status *peer.Status
+}
+
+// IsConnectedByIP looks the IP up in the peerstore and surfaces both
+// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
+func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
+ if l.status == nil {
+ return false, false
+ }
+ state, ok := l.status.PeerStateByIP(ip)
+ if !ok {
+ return false, false
+ }
+ return true, state.ConnStatus == peer.StatusConnected
+}
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index df746fa13..f9eb9adf5 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -185,9 +185,12 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
return s.eventsChan
}
-// Status holds a state of peers, signal, management connections and relays
+// Status holds a state of peers, signal, management connections and relays.
+// mux is an RWMutex so hot read paths (notably PeerStateByIP, called for
+// every private-service request) don't contend against each other.
+// Pure read methods take RLock; anything that mutates state takes Lock.
type Status struct {
- mux sync.Mutex
+ mux sync.RWMutex
peers map[string]State
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
signalState bool
@@ -283,8 +286,8 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
// GetPeer adds peer to Daemon status map
func (d *Status) GetPeer(peerPubKey string) (State, error) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
state, ok := d.peers[peerPubKey]
if !ok {
@@ -294,8 +297,8 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) {
}
func (d *Status) PeerByIP(ip string) (string, bool) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
for _, state := range d.peers {
if state.IP == ip {
@@ -305,6 +308,25 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
return "", false
}
+// PeerStateByIP returns the full peer State for the given tunnel IP.
+// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
+// address so dual-stack peers are reachable on either family. Returns the
+// zero State and false when no peer matches or the input is empty.
+func (d *Status) PeerStateByIP(ip string) (State, bool) {
+ if ip == "" {
+ return State{}, false
+ }
+ d.mux.RLock()
+ defer d.mux.RUnlock()
+
+ for _, state := range d.peers {
+ if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
+ return state, true
+ }
+ }
+ return State{}, false
+}
+
// RemovePeer removes peer from Daemon status map
func (d *Status) RemovePeer(peerPubKey string) error {
d.mux.Lock()
@@ -702,8 +724,8 @@ func (d *Status) UnsubscribePeerStateChanges(subscription *StatusChangeSubscript
// GetLocalPeerState returns the local peer state
func (d *Status) GetLocalPeerState() LocalPeerState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return d.localPeer.Clone()
}
@@ -909,8 +931,8 @@ func (d *Status) DeleteResolvedDomainsStates(domain domain.Domain) {
}
func (d *Status) GetRosenpassState() RosenpassState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return RosenpassState{
d.rosenpassEnabled,
d.rosenpassPermissive,
@@ -918,14 +940,14 @@ func (d *Status) GetRosenpassState() RosenpassState {
}
func (d *Status) GetLazyConnection() bool {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return d.lazyConnectionEnabled
}
func (d *Status) GetManagementState() ManagementState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return ManagementState{
d.mgmAddress,
d.managementState,
@@ -951,8 +973,8 @@ func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error {
// IsLoginRequired determines if a peer's login has expired.
func (d *Status) IsLoginRequired() bool {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
// if peer is connected to the management then login is not expired
if d.managementState {
@@ -967,8 +989,8 @@ func (d *Status) IsLoginRequired() bool {
}
func (d *Status) GetSignalState() SignalState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return SignalState{
d.signalAddress,
d.signalState,
@@ -978,8 +1000,8 @@ func (d *Status) GetSignalState() SignalState {
// GetRelayStates returns the stun/turn/permanent relay states
func (d *Status) GetRelayStates() []relay.ProbeResult {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
if d.relayMgr == nil {
return d.relayStates
}
@@ -1008,8 +1030,8 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
}
func (d *Status) ForwardingRules() []firewall.ForwardRule {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
if d.ingressGwMgr == nil {
return nil
}
@@ -1018,16 +1040,16 @@ func (d *Status) ForwardingRules() []firewall.ForwardRule {
}
func (d *Status) GetDNSStates() []NSGroupState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
// shallow copy is good enough, as slices fields are currently not updated
return slices.Clone(d.nsGroupStates)
}
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return maps.Clone(d.resolvedDomainsStates)
}
@@ -1043,8 +1065,8 @@ func (d *Status) GetFullStatus() FullStatus {
LazyConnectionEnabled: d.GetLazyConnection(),
}
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
fullStatus.LocalPeerState = d.localPeer
@@ -1219,8 +1241,8 @@ func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
}
func (d *Status) PeersStatus() (*configurer.Stats, error) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
if d.wgIface == nil {
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
}
diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go
index 9bafca55a..8d889b0ae 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -63,6 +63,33 @@ func TestUpdatePeerState(t *testing.T) {
assert.Equal(t, ip, state.IP, "ip should be equal")
}
+func TestStatus_PeerStateByIP(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", ""))
+ req.NoError(status.AddPeer("pk-2", "peer-2.netbird", "100.64.0.11", ""))
+
+ state, ok := status.PeerStateByIP("100.64.0.10")
+ req.True(ok, "known tunnel IP should resolve to a peer state")
+ req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
+ req.Equal("peer-1.netbird", state.FQDN, "matching state must carry the right FQDN")
+
+ _, ok = status.PeerStateByIP("100.64.0.99")
+ req.False(ok, "unknown IP must report ok=false")
+}
+
+func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
+
+ state, ok := status.PeerStateByIP("fd00::1")
+ req.True(ok, "IPv6-only match must resolve to the peer state")
+ req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
+}
+
func TestStatus_UpdatePeerFQDN(t *testing.T) {
key := "abc"
fqdn := "peer-a.netbird.local"
diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go
index c913efb92..75ae8de91 100644
--- a/management/internals/modules/peers/manager.go
+++ b/management/internals/modules/peers/manager.go
@@ -5,6 +5,7 @@ package peers
import (
"context"
"fmt"
+ "net"
"time"
"github.com/rs/xid"
@@ -35,6 +36,14 @@ type Manager interface {
SetAccountManager(accountManager account.Manager)
GetPeerID(ctx context.Context, peerKey string) (string, error)
CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error
+ // GetPeerByTunnelIP looks up a peer in accountID by its WireGuard tunnel IP.
+ // Returns nil with an error when no match exists. No permission check;
+ // callers (the proxy's ValidateTunnelPeer RPC) are trusted server components.
+ GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error)
+ // GetPeerWithGroups returns the peer and the list of *types.Group it belongs
+ // to. Used by the proxy's auth path to authorise a request by the calling
+ // peer's group memberships.
+ GetPeerWithGroups(ctx context.Context, accountID, peerID string) (*peer.Peer, []*types.Group, error)
}
type managerImpl struct {
@@ -99,6 +108,26 @@ func (m *managerImpl) GetPeersByGroupIDs(ctx context.Context, accountID string,
return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs)
}
+// GetPeerByTunnelIP delegates to the store's indexed lookup.
+func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) {
+ return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip)
+}
+
+// GetPeerWithGroups returns the peer plus its group memberships. Any store
+// error returns (nil, nil, err) so callers never receive a valid peer
+// alongside a non-nil error.
+func (m *managerImpl) GetPeerWithGroups(ctx context.Context, accountID, peerID string) (*peer.Peer, []*types.Group, error) {
+ p, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
+ if err != nil {
+ return nil, nil, err
+ }
+ groups, err := m.store.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peerID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return p, groups, nil
+}
+
func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
diff --git a/management/internals/modules/peers/manager_mock.go b/management/internals/modules/peers/manager_mock.go
index d6c9ebacc..3836ac909 100644
--- a/management/internals/modules/peers/manager_mock.go
+++ b/management/internals/modules/peers/manager_mock.go
@@ -6,6 +6,7 @@ package peers
import (
context "context"
+ net "net"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
@@ -13,6 +14,7 @@ import (
account "github.com/netbirdio/netbird/management/server/account"
integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
peer "github.com/netbirdio/netbird/management/server/peer"
+ types "github.com/netbirdio/netbird/management/server/types"
)
// MockManager is a mock of Manager interface.
@@ -38,6 +40,20 @@ func (m *MockManager) EXPECT() *MockManagerMockRecorder {
return m.recorder
}
+// CreateProxyPeer mocks base method.
+func (m *MockManager) CreateProxyPeer(ctx context.Context, accountID, peerKey, cluster string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CreateProxyPeer", ctx, accountID, peerKey, cluster)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// CreateProxyPeer indicates an expected call of CreateProxyPeer.
+func (mr *MockManagerMockRecorder) CreateProxyPeer(ctx, accountID, peerKey, cluster interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProxyPeer", reflect.TypeOf((*MockManager)(nil).CreateProxyPeer), ctx, accountID, peerKey, cluster)
+}
+
// DeletePeers mocks base method.
func (m *MockManager) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
m.ctrl.T.Helper()
@@ -97,6 +113,21 @@ func (mr *MockManagerMockRecorder) GetPeerAccountID(ctx, peerID interface{}) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerAccountID", reflect.TypeOf((*MockManager)(nil).GetPeerAccountID), ctx, peerID)
}
+// GetPeerByTunnelIP mocks base method.
+func (m *MockManager) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPeerByTunnelIP", ctx, accountID, ip)
+ ret0, _ := ret[0].(*peer.Peer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetPeerByTunnelIP indicates an expected call of GetPeerByTunnelIP.
+func (mr *MockManagerMockRecorder) GetPeerByTunnelIP(ctx, accountID, ip interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByTunnelIP", reflect.TypeOf((*MockManager)(nil).GetPeerByTunnelIP), ctx, accountID, ip)
+}
+
// GetPeerID mocks base method.
func (m *MockManager) GetPeerID(ctx context.Context, peerKey string) (string, error) {
m.ctrl.T.Helper()
@@ -112,6 +143,22 @@ func (mr *MockManagerMockRecorder) GetPeerID(ctx, peerKey interface{}) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerID", reflect.TypeOf((*MockManager)(nil).GetPeerID), ctx, peerKey)
}
+// GetPeerWithGroups mocks base method.
+func (m *MockManager) GetPeerWithGroups(ctx context.Context, accountID, peerID string) (*peer.Peer, []*types.Group, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPeerWithGroups", ctx, accountID, peerID)
+ ret0, _ := ret[0].(*peer.Peer)
+ ret1, _ := ret[1].([]*types.Group)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// GetPeerWithGroups indicates an expected call of GetPeerWithGroups.
+func (mr *MockManagerMockRecorder) GetPeerWithGroups(ctx, accountID, peerID interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerWithGroups", reflect.TypeOf((*MockManager)(nil).GetPeerWithGroups), ctx, accountID, peerID)
+}
+
// GetPeersByGroupIDs mocks base method.
func (m *MockManager) GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) {
m.ctrl.T.Helper()
@@ -162,17 +209,3 @@ func (mr *MockManagerMockRecorder) SetNetworkMapController(networkMapController
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetNetworkMapController", reflect.TypeOf((*MockManager)(nil).SetNetworkMapController), networkMapController)
}
-
-// CreateProxyPeer mocks base method.
-func (m *MockManager) CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "CreateProxyPeer", ctx, accountID, peerKey, cluster)
- ret0, _ := ret[0].(error)
- return ret0
-}
-
-// CreateProxyPeer indicates an expected call of CreateProxyPeer.
-func (mr *MockManagerMockRecorder) CreateProxyPeer(ctx, accountID, peerKey, cluster interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProxyPeer", reflect.TypeOf((*MockManager)(nil).CreateProxyPeer), ctx, accountID, peerKey, cluster)
-}
diff --git a/management/internals/modules/reverseproxy/domain/domain.go b/management/internals/modules/reverseproxy/domain/domain.go
index f65e31a07..08d7ad19b 100644
--- a/management/internals/modules/reverseproxy/domain/domain.go
+++ b/management/internals/modules/reverseproxy/domain/domain.go
@@ -23,6 +23,8 @@ type Domain struct {
// SupportsCrowdSec is populated at query time from proxy cluster capabilities.
// Not persisted.
SupportsCrowdSec *bool `gorm:"-"`
+ // SupportsPrivate is populated at query time from proxy cluster capabilities. Not persisted.
+ SupportsPrivate *bool `gorm:"-"`
}
// EventMeta returns activity event metadata for a domain
diff --git a/management/internals/modules/reverseproxy/domain/manager/api.go b/management/internals/modules/reverseproxy/domain/manager/api.go
index 4493ef0ad..f01329010 100644
--- a/management/internals/modules/reverseproxy/domain/manager/api.go
+++ b/management/internals/modules/reverseproxy/domain/manager/api.go
@@ -49,6 +49,7 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
SupportsCustomPorts: d.SupportsCustomPorts,
RequireSubdomain: d.RequireSubdomain,
SupportsCrowdsec: d.SupportsCrowdSec,
+ SupportsPrivate: d.SupportsPrivate,
}
if d.TargetCluster != "" {
resp.TargetCluster = &d.TargetCluster
diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go
index 2790b5f20..2a026c7fa 100644
--- a/management/internals/modules/reverseproxy/domain/manager/manager.go
+++ b/management/internals/modules/reverseproxy/domain/manager/manager.go
@@ -35,6 +35,7 @@ type proxyManager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
type Manager struct {
@@ -93,6 +94,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
d.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, cluster)
d.RequireSubdomain = m.proxyManager.ClusterRequireSubdomain(ctx, cluster)
d.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, cluster)
+ d.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, cluster)
ret = append(ret, d)
}
@@ -109,6 +111,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
if d.TargetCluster != "" {
cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster)
cd.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, d.TargetCluster)
+ cd.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, d.TargetCluster)
}
// Custom domains never require a subdomain by default since
// the account owns them and should be able to use the bare domain.
diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_test.go
index 5e7bbfc36..53a8dedae 100644
--- a/management/internals/modules/reverseproxy/domain/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/domain/manager/manager_test.go
@@ -10,7 +10,7 @@ import (
)
type mockProxyManager struct {
- getActiveClusterAddressesFunc func(ctx context.Context) ([]string, error)
+ getActiveClusterAddressesFunc func(ctx context.Context) ([]string, error)
getActiveClusterAddressesForAccountFunc func(ctx context.Context, accountID string) ([]string, error)
}
@@ -40,6 +40,10 @@ func (m *mockProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
+func (m *mockProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
+ return nil
+}
+
func TestGetClusterAllowList_BYOPMergedWithPublic(t *testing.T) {
pm := &mockProxyManager{
getActiveClusterAddressesForAccountFunc: func(_ context.Context, accID string) ([]string, error) {
@@ -151,4 +155,3 @@ func TestGetClusterAllowList_PublicEmpty_BYOPOnly(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, []string{"byop.example.com"}, result)
}
-
diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go
index 07ea6f0ab..22f1007ec 100644
--- a/management/internals/modules/reverseproxy/proxy/manager.go
+++ b/management/internals/modules/reverseproxy/proxy/manager.go
@@ -19,6 +19,7 @@ type Manager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStale(ctx context.Context, inactivityDuration time.Duration) error
GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error)
CountAccountProxies(ctx context.Context, accountID string) (int64, error)
diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go
index 510500e0c..943766004 100644
--- a/management/internals/modules/reverseproxy/proxy/manager/manager.go
+++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go
@@ -21,6 +21,7 @@ type store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
@@ -137,6 +138,11 @@ func (m Manager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string
return m.store.GetClusterSupportsCrowdSec(ctx, clusterAddr)
}
+// ClusterSupportsPrivate reports whether any active proxy claims the private capability (nil = unreported).
+func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
+ return m.store.GetClusterSupportsPrivate(ctx, clusterAddr)
+}
+
// CleanupStale removes proxies that haven't sent heartbeat in the specified duration
func (m *Manager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error {
if err := m.store.CleanupStaleProxies(ctx, inactivityDuration); err != nil {
@@ -178,4 +184,3 @@ func (m *Manager) DeleteAccountCluster(ctx context.Context, clusterAddress, acco
}
return nil
}
-
diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go
index 3436216b4..5c44470a3 100644
--- a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go
@@ -15,16 +15,16 @@ import (
)
type mockStore struct {
- saveProxyFunc func(ctx context.Context, p *proxy.Proxy) error
- disconnectProxyFunc func(ctx context.Context, proxyID, sessionID string) error
- updateProxyHeartbeatFunc func(ctx context.Context, p *proxy.Proxy) error
- getActiveProxyClusterAddressesFunc func(ctx context.Context) ([]string, error)
- getActiveProxyClusterAddressesForAccFunc func(ctx context.Context, accountID string) ([]string, error)
- cleanupStaleProxiesFunc func(ctx context.Context, d time.Duration) error
- getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error)
- countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error)
- isClusterAddressConflictingFunc func(ctx context.Context, clusterAddress, accountID string) (bool, error)
- deleteAccountClusterFunc func(ctx context.Context, clusterAddress, accountID string) error
+ saveProxyFunc func(ctx context.Context, p *proxy.Proxy) error
+ disconnectProxyFunc func(ctx context.Context, proxyID, sessionID string) error
+ updateProxyHeartbeatFunc func(ctx context.Context, p *proxy.Proxy) error
+ getActiveProxyClusterAddressesFunc func(ctx context.Context) ([]string, error)
+ getActiveProxyClusterAddressesForAccFunc func(ctx context.Context, accountID string) ([]string, error)
+ cleanupStaleProxiesFunc func(ctx context.Context, d time.Duration) error
+ getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error)
+ countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error)
+ isClusterAddressConflictingFunc func(ctx context.Context, clusterAddress, accountID string) (bool, error)
+ deleteAccountClusterFunc func(ctx context.Context, clusterAddress, accountID string) error
}
func (m *mockStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error {
@@ -99,6 +99,9 @@ func (m *mockStore) GetClusterRequireSubdomain(_ context.Context, _ string) *boo
func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *bool {
return nil
}
+func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool {
+ return nil
+}
func newTestManager(s store) *Manager {
meter := noop.NewMeterProvider().Meter("test")
diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go
index a0e360a1b..d2be46c9f 100644
--- a/management/internals/modules/reverseproxy/proxy/manager_mock.go
+++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go
@@ -92,6 +92,20 @@ func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCrowdSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCrowdSec), ctx, clusterAddr)
}
+// ClusterSupportsPrivate mocks base method.
+func (m *MockManager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ClusterSupportsPrivate", ctx, clusterAddr)
+ ret0, _ := ret[0].(*bool)
+ return ret0
+}
+
+// ClusterSupportsPrivate indicates an expected call of ClusterSupportsPrivate.
+func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsPrivate", reflect.TypeOf((*MockManager)(nil).ClusterSupportsPrivate), ctx, clusterAddr)
+}
+
// Connect mocks base method.
func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *Capabilities) (*Proxy, error) {
m.ctrl.T.Helper()
diff --git a/management/internals/modules/reverseproxy/proxy/proxy.go b/management/internals/modules/reverseproxy/proxy/proxy.go
index 9da7910df..4404b0d24 100644
--- a/management/internals/modules/reverseproxy/proxy/proxy.go
+++ b/management/internals/modules/reverseproxy/proxy/proxy.go
@@ -20,6 +20,9 @@ type Capabilities struct {
RequireSubdomain *bool
// SupportsCrowdsec indicates whether this proxy has CrowdSec configured.
SupportsCrowdsec *bool
+ // Private indicates whether this proxy supports inbound access via Wireguard
+ // tunnel and netbird-only authentication policies
+ Private *bool
}
// Proxy represents a reverse proxy instance
@@ -67,10 +70,9 @@ type Cluster struct {
Type ClusterType
Online bool
ConnectedProxies int
- // Capability flags. *bool because nil means "no proxy reported a
- // capability for this cluster" โ the dashboard renders these as
- // unknown rather than false.
+ // *bool: nil = no proxy reported the capability; the dashboard renders that as unknown.
SupportsCustomPorts *bool
RequireSubdomain *bool
SupportsCrowdSec *bool
+ Private *bool
}
diff --git a/management/internals/modules/reverseproxy/service/manager/api.go b/management/internals/modules/reverseproxy/service/manager/api.go
index 9d93d52ee..7298b4261 100644
--- a/management/internals/modules/reverseproxy/service/manager/api.go
+++ b/management/internals/modules/reverseproxy/service/manager/api.go
@@ -204,6 +204,7 @@ func (h *handler) getClusters(w http.ResponseWriter, r *http.Request) {
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdSec,
+ Private: c.Private,
})
}
diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go
index ca0c5540f..f0ac68ed0 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager.go
@@ -82,6 +82,7 @@ type CapabilityProvider interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
type Manager struct {
@@ -136,6 +137,7 @@ func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]
clusters[i].SupportsCustomPorts = m.capabilities.ClusterSupportsCustomPorts(ctx, clusters[i].Address)
clusters[i].RequireSubdomain = m.capabilities.ClusterRequireSubdomain(ctx, clusters[i].Address)
clusters[i].SupportsCrowdSec = m.capabilities.ClusterSupportsCrowdSec(ctx, clusters[i].Address)
+ clusters[i].Private = m.capabilities.ClusterSupportsPrivate(ctx, clusters[i].Address)
}
return clusters, nil
@@ -208,6 +210,9 @@ func (m *Manager) replaceHostByLookup(ctx context.Context, accountID string, s *
target.Host = resource.Domain
case service.TargetTypeSubnet:
// For subnets we do not do any lookups on the resource
+ case service.TargetTypeCluster:
+ // Cluster targets carry the upstream address on target_id; the
+ // proxy resolves the destination at request time.
default:
return fmt.Errorf("unknown target type: %s", target.TargetType)
}
@@ -779,6 +784,10 @@ func validateTargetReferences(ctx context.Context, transaction store.Store, acco
if err := validateResourceTarget(ctx, transaction, accountID, target); err != nil {
return err
}
+ case service.TargetTypeCluster:
+ if err := validateClusterTarget(target); err != nil {
+ return err
+ }
default:
return status.Errorf(status.InvalidArgument, "unknown target type %q for target %q", target.TargetType, target.TargetId)
}
@@ -786,6 +795,13 @@ func validateTargetReferences(ctx context.Context, transaction store.Store, acco
return nil
}
+func validateClusterTarget(target *service.Target) error {
+ if !target.Options.DirectUpstream {
+ return status.Errorf(status.InvalidArgument, "cluster target %s has direct upstream disabled", target.Host)
+ }
+ return nil
+}
+
func validatePeerTarget(ctx context.Context, transaction store.Store, accountID string, target *service.Target) error {
if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthShare, accountID, target.TargetId); err != nil {
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound {
@@ -962,12 +978,14 @@ func (m *Manager) ReloadAllServicesForAccount(ctx context.Context, accountID str
return fmt.Errorf("failed to get services: %w", err)
}
+ oidcCfg := m.proxyController.GetOIDCValidationConfig()
+
for _, s := range services {
err = m.replaceHostByLookup(ctx, accountID, s)
if err != nil {
return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
}
- m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
+ m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster)
}
return nil
diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go
index 47b8b3865..f3ab89a25 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go
@@ -1344,3 +1344,66 @@ func TestValidateSubdomainRequirement(t *testing.T) {
})
}
}
+
+func TestValidateTargetReferences_ClusterTargetSkipsLookup(t *testing.T) {
+ ctx := context.Background()
+ ctrl := gomock.NewController(t)
+ mockStore := store.NewMockStore(ctrl)
+ accountID := "test-account"
+
+ // No peer or resource lookups must be issued for cluster targets.
+ targets := []*rpservice.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: rpservice.TargetTypeCluster,
+ Options: rpservice.TargetOptions{DirectUpstream: true},
+ },
+ }
+ require.NoError(t, validateTargetReferences(ctx, mockStore, accountID, targets), "cluster target must validate without store lookups")
+}
+
+// TestValidateTargetReferences_ClusterTargetRequiresDirectUpstream pins the
+// store-side check that cluster targets must opt into the host-stack dial
+// path. Without DirectUpstream the proxy would route this target through
+// the embedded NetBird client and fail on every request.
+func TestValidateTargetReferences_ClusterTargetRequiresDirectUpstream(t *testing.T) {
+ ctx := context.Background()
+ ctrl := gomock.NewController(t)
+ mockStore := store.NewMockStore(ctrl)
+ accountID := "test-account"
+
+ targets := []*rpservice.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: rpservice.TargetTypeCluster,
+ Host: "backend.lan",
+ },
+ }
+ err := validateTargetReferences(ctx, mockStore, accountID, targets)
+ require.Error(t, err, "cluster target without direct_upstream must be rejected")
+ assert.ErrorContains(t, err, "direct upstream disabled")
+}
+
+func TestReplaceHostByLookup_SkipsClusterTarget(t *testing.T) {
+ ctx := context.Background()
+ ctrl := gomock.NewController(t)
+ mockStore := store.NewMockStore(ctrl)
+ accountID := "test-account"
+
+ mgr := &Manager{store: mockStore}
+
+ svc := &rpservice.Service{
+ ID: "svc-1",
+ AccountID: accountID,
+ Targets: []*rpservice.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: rpservice.TargetTypeCluster,
+ Host: "127.0.0.1",
+ },
+ },
+ }
+
+ require.NoError(t, mgr.replaceHostByLookup(ctx, accountID, svc), "cluster target must not trigger peer/resource lookup")
+ assert.Equal(t, "127.0.0.1", svc.Targets[0].Host, "operator-supplied host must be preserved for cluster target")
+}
diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go
index 166a66a5f..27f6d914d 100644
--- a/management/internals/modules/reverseproxy/service/service.go
+++ b/management/internals/modules/reverseproxy/service/service.go
@@ -45,10 +45,11 @@ const (
StatusCertificateFailed Status = "certificate_failed"
StatusError Status = "error"
- TargetTypePeer TargetType = "peer"
- TargetTypeHost TargetType = "host"
- TargetTypeDomain TargetType = "domain"
- TargetTypeSubnet TargetType = "subnet"
+ TargetTypePeer TargetType = "peer"
+ TargetTypeHost TargetType = "host"
+ TargetTypeDomain TargetType = "domain"
+ TargetTypeSubnet TargetType = "subnet"
+ TargetTypeCluster TargetType = "cluster"
SourcePermanent = "permanent"
SourceEphemeral = "ephemeral"
@@ -60,6 +61,11 @@ type TargetOptions struct {
SessionIdleTimeout time.Duration `json:"session_idle_timeout,omitempty"`
PathRewrite PathRewriteMode `json:"path_rewrite,omitempty"`
CustomHeaders map[string]string `gorm:"serializer:json" json:"custom_headers,omitempty"`
+ // DirectUpstream bypasses the proxy's embedded NetBird client and dials
+ // the target via the proxy host's network stack. Useful for upstreams
+ // reachable without WireGuard (public APIs, LAN services, localhost
+ // sidecars). Default false.
+ DirectUpstream bool `json:"direct_upstream,omitempty"`
}
type Target struct {
@@ -67,7 +73,7 @@ type Target struct {
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
Path *string `json:"path,omitempty"`
- Host string `json:"host"` // the Host field is only used for subnet targets, otherwise ignored
+ Host string `json:"host"`
Port uint16 `gorm:"index:idx_target_port" json:"port"`
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
@@ -200,6 +206,10 @@ type Service struct {
Mode string `gorm:"default:'http'"`
ListenPort uint16
PortAutoAssigned bool
+ // Private marks the service as NetBird-only: auth via ValidateTunnelPeer against AccessGroups instead of SSO. HTTP-only.
+ Private bool
+ // AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO.
+ AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"`
}
// InitNewRecord generates a new unique ID and resets metadata for a newly created
@@ -299,6 +309,12 @@ func (s *Service) ToAPIResponse() *api.Service {
Mode: &mode,
ListenPort: &listenPort,
PortAutoAssigned: &s.PortAutoAssigned,
+ Private: &s.Private,
+ }
+
+ if len(s.AccessGroups) > 0 {
+ groups := append([]string(nil), s.AccessGroups...)
+ resp.AccessGroups = &groups
}
if s.ProxyCluster != "" {
@@ -308,6 +324,7 @@ func (s *Service) ToAPIResponse() *api.Service {
return resp
}
+// ToProtoMapping converts the service into the wire format the proxy consumes.
func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig) *proto.ProxyMapping {
pathMappings := s.buildPathMappings()
@@ -349,6 +366,7 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf
RewriteRedirects: s.RewriteRedirects,
Mode: s.Mode,
ListenPort: int32(s.ListenPort), //nolint:gosec
+ Private: s.Private,
}
if r := restrictionsToProto(s.Restrictions); r != nil {
@@ -455,7 +473,8 @@ func pathRewriteToProto(mode PathRewriteMode) proto.PathRewriteMode {
}
func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions {
- if !opts.SkipTLSVerify && opts.RequestTimeout == 0 && opts.SessionIdleTimeout == 0 && opts.PathRewrite == "" && len(opts.CustomHeaders) == 0 {
+ if !opts.SkipTLSVerify && opts.RequestTimeout == 0 && opts.SessionIdleTimeout == 0 &&
+ opts.PathRewrite == "" && len(opts.CustomHeaders) == 0 && !opts.DirectUpstream {
return nil
}
apiOpts := &api.ServiceTargetOptions{}
@@ -477,17 +496,22 @@ func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions {
if len(opts.CustomHeaders) > 0 {
apiOpts.CustomHeaders = &opts.CustomHeaders
}
+ if opts.DirectUpstream {
+ apiOpts.DirectUpstream = &opts.DirectUpstream
+ }
return apiOpts
}
func targetOptionsToProto(opts TargetOptions) *proto.PathTargetOptions {
- if !opts.SkipTLSVerify && opts.PathRewrite == "" && opts.RequestTimeout == 0 && len(opts.CustomHeaders) == 0 {
+ if !opts.SkipTLSVerify && opts.PathRewrite == "" && opts.RequestTimeout == 0 &&
+ len(opts.CustomHeaders) == 0 && !opts.DirectUpstream {
return nil
}
popts := &proto.PathTargetOptions{
- SkipTlsVerify: opts.SkipTLSVerify,
- PathRewrite: pathRewriteToProto(opts.PathRewrite),
- CustomHeaders: opts.CustomHeaders,
+ SkipTlsVerify: opts.SkipTLSVerify,
+ PathRewrite: pathRewriteToProto(opts.PathRewrite),
+ CustomHeaders: opts.CustomHeaders,
+ DirectUpstream: opts.DirectUpstream,
}
if opts.RequestTimeout != 0 {
popts.RequestTimeout = durationpb.New(opts.RequestTimeout)
@@ -537,6 +561,9 @@ func targetOptionsFromAPI(idx int, o *api.ServiceTargetOptions) (TargetOptions,
if o.CustomHeaders != nil {
opts.CustomHeaders = *o.CustomHeaders
}
+ if o.DirectUpstream != nil {
+ opts.DirectUpstream = *o.DirectUpstream
+ }
return opts, nil
}
@@ -551,6 +578,14 @@ func (s *Service) FromAPIRequest(req *api.ServiceRequest, accountID string) erro
if req.ListenPort != nil {
s.ListenPort = uint16(*req.ListenPort) //nolint:gosec
}
+ if req.Private != nil {
+ s.Private = *req.Private
+ }
+ if req.AccessGroups != nil {
+ s.AccessGroups = append([]string(nil), *req.AccessGroups...)
+ } else {
+ s.AccessGroups = nil
+ }
targets, err := targetsFromAPI(accountID, req.Targets)
if err != nil {
@@ -740,6 +775,9 @@ func (s *Service) Validate() error {
if err := validateAccessRestrictions(&s.Restrictions); err != nil {
return err
}
+ if err := s.validatePrivateRequirements(); err != nil {
+ return err
+ }
switch s.Mode {
case ModeHTTP:
@@ -753,6 +791,23 @@ func (s *Service) Validate() error {
}
}
+// validatePrivateRequirements enforces the private-service contract: HTTP mode, โฅ1 access group, no bearer auth.
+func (s *Service) validatePrivateRequirements() error {
+ if !s.Private {
+ return nil
+ }
+ if s.Mode != "" && s.Mode != ModeHTTP {
+ return fmt.Errorf("private services only support HTTP mode, got %q", s.Mode)
+ }
+ if len(s.AccessGroups) == 0 {
+ return errors.New("private services require at least one access group")
+ }
+ if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled {
+ return errors.New("private services cannot enable bearer auth (SSO): NetBird-only access and SSO are mutually exclusive")
+ }
+ return nil
+}
+
func (s *Service) validateHTTPMode() error {
if s.Domain == "" {
return errors.New("service domain is required")
@@ -799,11 +854,21 @@ func (s *Service) validateHTTPTargets() error {
for i, target := range s.Targets {
switch target.TargetType {
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
- // host field will be ignored
+ // Host is normally overwritten by replaceHostByLookup with the
+ // resolved peer IP / resource address; operator-supplied values
+ // are honored only when DirectUpstream is set. Validate the
+ // override here so misconfigured hosts fail fast at API time.
+ if err := validateDirectUpstreamHost(i, target); err != nil {
+ return err
+ }
case TargetTypeSubnet:
if target.Host == "" {
return fmt.Errorf("target %d has empty host but target_type is %q", i, target.TargetType)
}
+ case TargetTypeCluster:
+ if err := validateClusterTarget(i, target); err != nil {
+ return err
+ }
default:
return fmt.Errorf("target %d has invalid target_type %q", i, target.TargetType)
}
@@ -821,25 +886,67 @@ func (s *Service) validateHTTPTargets() error {
return nil
}
+// validateClusterTarget cluster targets should not have empty hosts and should have direct upstream enabled.
+func validateClusterTarget(idx int, target *Target) error {
+ host := strings.TrimSpace(target.Host)
+ if host == "" {
+ return fmt.Errorf("target %d: has empty host", idx)
+ }
+ if !target.Options.DirectUpstream {
+ return fmt.Errorf("target %d: %s has direct upstream disabled", idx, target.Host)
+ }
+ return validateDirectUpstreamHost(idx, target)
+}
+
+// validateDirectUpstreamHost validates the operator-supplied Host on a
+// peer/host/domain target when DirectUpstream is set. Empty Host is
+// allowed โ the lookup fills in the default peer IP / resource address.
+// Without DirectUpstream the Host value is silently overwritten by
+// replaceHostByLookup, so we don't validate it (preserves the historical
+// behaviour where APIs accepted any value and dropped it). Non-empty
+// Host with DirectUpstream must look like a hostname or IP and must
+// not carry a port (port lives on Target.Port).
+func validateDirectUpstreamHost(idx int, target *Target) error {
+ if !target.Options.DirectUpstream {
+ return nil
+ }
+ host := strings.TrimSpace(target.Host)
+ if host == "" {
+ return nil
+ }
+ if strings.ContainsAny(host, " \t/") {
+ return fmt.Errorf("target %d: host %q contains invalid characters", idx, host)
+ }
+ if _, _, err := net.SplitHostPort(host); err == nil {
+ return fmt.Errorf("target %d: host %q must not include a port (set target.port instead)", idx, host)
+ }
+ return nil
+}
+
func (s *Service) validateL4Target(target *Target) error {
// L4 services have a single target; per-target disable is meaningless
// (use the service-level Enabled flag instead). Force it on so that
// buildPathMappings always includes the target in the proto.
target.Enabled = true
- if target.Port == 0 {
- return errors.New("target port is required for L4 services")
- }
if target.TargetId == "" {
return errors.New("target_id is required for L4 services")
}
+ if target.TargetType != TargetTypeCluster && target.Port == 0 {
+ return errors.New("target port is required for L4 services")
+ }
switch target.TargetType {
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
- // OK
+ if err := validateDirectUpstreamHost(0, target); err != nil {
+ return err
+ }
case TargetTypeSubnet:
if target.Host == "" {
return errors.New("target host is required for subnet targets")
}
+ case TargetTypeCluster:
+ // target_id carries the cluster address; the proxy resolves
+ // the upstream at request time.
default:
return fmt.Errorf("invalid target_type %q for L4 service", target.TargetType)
}
@@ -1174,6 +1281,11 @@ func (s *Service) Copy() *Service {
}
}
+ var accessGroups []string
+ if len(s.AccessGroups) > 0 {
+ accessGroups = append([]string(nil), s.AccessGroups...)
+ }
+
return &Service{
ID: s.ID,
AccountID: s.AccountID,
@@ -1195,6 +1307,8 @@ func (s *Service) Copy() *Service {
Mode: s.Mode,
ListenPort: s.ListenPort,
PortAutoAssigned: s.PortAutoAssigned,
+ Private: s.Private,
+ AccessGroups: accessGroups,
}
}
diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go
index f1349ff65..ba63d76ed 100644
--- a/management/internals/modules/reverseproxy/service/service_test.go
+++ b/management/internals/modules/reverseproxy/service/service_test.go
@@ -12,6 +12,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
"github.com/netbirdio/netbird/shared/hash/argon2id"
+ "github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -1116,3 +1117,191 @@ func TestValidate_HeaderAuths(t *testing.T) {
assert.Contains(t, err.Error(), "exceeds maximum length")
})
}
+
+func TestValidate_HTTPClusterTarget(t *testing.T) {
+ rp := validProxy()
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ require.NoError(t, rp.Validate(), "HTTP cluster target with target_id, host, and direct_upstream must validate")
+}
+
+func TestValidate_HTTPClusterTarget_RequiresTargetId(t *testing.T) {
+ rp := validProxy()
+ rp.Targets = []*Target{{
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "empty target_id", "cluster target must reject empty target_id")
+}
+
+// TestValidate_HTTPClusterTarget_RequiresHost pins the new cluster-target
+// rule that operator-supplied Host is mandatory: cluster targets dial the
+// upstream via the host network stack (direct_upstream is implied), so an
+// empty Host leaves the proxy with nothing to dial.
+func TestValidate_HTTPClusterTarget_RequiresHost(t *testing.T) {
+ rp := validProxy()
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "empty host", "cluster target must reject empty host")
+}
+
+// TestValidate_HTTPClusterTarget_RequiresDirectUpstream pins the second
+// half of the cluster-target rule: DirectUpstream must be true so the
+// stdlib transport branch in MultiTransport is taken. Without it the
+// embedded NetBird client would try to dial the cluster address through
+// the WG tunnel, which is the wrong network for a cluster upstream.
+func TestValidate_HTTPClusterTarget_RequiresDirectUpstream(t *testing.T) {
+ rp := validProxy()
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "direct upstream disabled", "cluster target must reject direct_upstream=false")
+}
+
+func TestValidate_L4ClusterTarget(t *testing.T) {
+ rp := validProxy()
+ rp.Mode = ModeTCP
+ rp.ListenPort = 9000
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "tcp",
+ Enabled: true,
+ }}
+ require.NoError(t, rp.Validate(), "L4 cluster target must validate without an explicit port")
+}
+
+func TestService_Copy_RoundtripsPrivate(t *testing.T) {
+ svc := validProxy()
+ svc.Private = true
+ svc.AccessGroups = []string{"grp-admins", "grp-ops"}
+ cp := svc.Copy()
+ require.NotNil(t, cp)
+ assert.True(t, cp.Private)
+ assert.Equal(t, []string{"grp-admins", "grp-ops"}, cp.AccessGroups)
+
+ cp.Private = false
+ assert.True(t, svc.Private)
+
+ cp.AccessGroups[0] = "grp-other"
+ assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
+}
+
+func TestService_APIRoundtrip_Private(t *testing.T) {
+ enabled := true
+ private := true
+ accessGroups := []string{"grp-admins"}
+ targets := []api.ServiceTarget{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: api.ServiceTargetTargetType("cluster"),
+ Protocol: "http",
+ Port: 80,
+ Enabled: true,
+ }}
+ req := &api.ServiceRequest{
+ Name: "svc-private",
+ Domain: "myapp.eu.proxy.netbird.io",
+ Enabled: enabled,
+ Private: &private,
+ AccessGroups: &accessGroups,
+ Targets: &targets,
+ }
+
+ svc := &Service{}
+ require.NoError(t, svc.FromAPIRequest(req, "acc-1"))
+ assert.True(t, svc.Private)
+ assert.Equal(t, []string{"grp-admins"}, svc.AccessGroups)
+
+ resp := svc.ToAPIResponse()
+ require.NotNil(t, resp.Private)
+ assert.True(t, *resp.Private)
+ require.NotNil(t, resp.AccessGroups)
+ assert.Equal(t, []string{"grp-admins"}, *resp.AccessGroups)
+}
+
+func TestValidate_Private_RequiresAccessGroups(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "access group")
+}
+
+func TestValidate_Private_RejectsBearerAuth(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.AccessGroups = []string{"grp-admins"}
+ rp.Auth.BearerAuth = &BearerAuthConfig{
+ Enabled: true,
+ DistributionGroups: []string{"grp-sso"},
+ }
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "mutually exclusive")
+}
+
+func TestValidate_Private_AcceptsNonClusterTargets(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.AccessGroups = []string{"grp-admins"}
+ require.NoError(t, rp.Validate())
+}
+
+func TestValidate_Private_AcceptsClusterTargetWithAccessGroups(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.AccessGroups = []string{"grp-admins"}
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ require.NoError(t, rp.Validate())
+}
+
+func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.AccessGroups = []string{"grp-admins"}
+ rp.Mode = ModeTCP
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "tcp",
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "HTTP")
+}
diff --git a/management/internals/modules/reverseproxy/sessionkey/sessionkey.go b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go
index aacbe5dca..1fb6a323d 100644
--- a/management/internals/modules/reverseproxy/sessionkey/sessionkey.go
+++ b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go
@@ -20,6 +20,20 @@ type KeyPair struct {
type Claims struct {
jwt.RegisteredClaims
Method auth.Method `json:"method"`
+ // Email is the calling user's email address. Carried so the
+ // proxy can stamp identity on upstream requests (e.g.
+ // x-litellm-end-user-id) without an extra management
+ // round-trip on every cookie-bearing request.
+ Email string `json:"email,omitempty"`
+ // Groups carries the user's group IDs so the proxy can stamp them
+ // onto upstream requests (X-NetBird-Groups) from the cookie path
+ // without an extra management round-trip.
+ Groups []string `json:"groups,omitempty"`
+ // GroupNames carries the human-readable display names for the ids
+ // in Groups, ordered identically (positional pairing). Slice may be
+ // shorter than Groups for tokens minted before names were
+ // resolvable; the consumer falls back to ids for missing positions.
+ GroupNames []string `json:"group_names,omitempty"`
}
func GenerateKeyPair() (*KeyPair, error) {
@@ -34,7 +48,13 @@ func GenerateKeyPair() (*KeyPair, error) {
}, nil
}
-func SignToken(privKeyB64, userID, domain string, method auth.Method, expiration time.Duration) (string, error) {
+// SignToken mints a session JWT for the given user and domain. email,
+// groups, and groupNames, when non-empty, are embedded so the proxy can
+// authorise and stamp identity for policy-aware middlewares without a
+// management round-trip on every cookie-bearing request. groupNames
+// pairs positionally with groups; pass nil when names couldn't be
+// resolved.
+func SignToken(privKeyB64, userID, email, domain string, method auth.Method, groups, groupNames []string, expiration time.Duration) (string, error) {
privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyB64)
if err != nil {
return "", fmt.Errorf("decode private key: %w", err)
@@ -56,7 +76,10 @@ func SignToken(privKeyB64, userID, domain string, method auth.Method, expiration
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
- Method: method,
+ Method: method,
+ Email: email,
+ Groups: append([]string(nil), groups...),
+ GroupNames: append([]string(nil), groupNames...),
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go
index 4abeb8e7c..e7155ae09 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -351,6 +351,7 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdsec,
+ Private: c.Private,
}
}
@@ -754,6 +755,11 @@ func (s *ProxyServiceServer) SendServiceUpdate(update *proto.GetMappingUpdateRes
InitialSyncComplete: update.InitialSyncComplete,
}
}
+ // Drop mappings the proxy lacks capability for (e.g. private without SupportsPrivateService).
+ connUpdate = filterMappingsForProxy(conn, connUpdate)
+ if connUpdate == nil || len(connUpdate.Mapping) == 0 {
+ return true
+ }
resp := s.perProxyMessage(connUpdate, conn.proxyID)
if resp == nil {
log.Warnf("Token generation failed for proxy %s, disconnecting to force resync", conn.proxyID)
@@ -882,16 +888,20 @@ func (s *ProxyServiceServer) SendServiceUpdateToCluster(ctx context.Context, upd
}
}
-// proxyAcceptsMapping returns whether the proxy should receive this mapping.
-// Old proxies that never reported capabilities are skipped for non-TLS L4
-// mappings with a custom listen port, since they don't understand the
-// protocol. Proxies that report capabilities (even SupportsCustomPorts=false)
-// are new enough to handle the mapping. TLS uses SNI routing and works on
-// any proxy. Delete operations are always sent so proxies can clean up.
+// proxyAcceptsMapping returns whether the proxy can receive this mapping.
+// Private mappings require SupportsPrivateService; custom-port L4 mappings
+// require SupportsCustomPorts. Remove operations always pass so proxies can
+// clean up.
func proxyAcceptsMapping(conn *proxyConnection, mapping *proto.ProxyMapping) bool {
if mapping.Type == proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED {
return true
}
+ if mapping.GetPrivate() {
+ caps := conn.capabilities
+ if caps == nil || caps.SupportsPrivateService == nil || !*caps.SupportsPrivateService {
+ return false
+ }
+ }
if mapping.ListenPort == 0 || mapping.Mode == "tls" {
return true
}
@@ -900,6 +910,29 @@ func proxyAcceptsMapping(conn *proxyConnection, mapping *proto.ProxyMapping) boo
return conn.capabilities != nil && conn.capabilities.SupportsCustomPorts != nil
}
+// filterMappingsForProxy drops mappings the proxy cannot safely receive
+// (e.g. private mappings to a proxy without SupportsPrivateService).
+// Returns the input unchanged when no filtering is needed.
+func filterMappingsForProxy(conn *proxyConnection, update *proto.GetMappingUpdateResponse) *proto.GetMappingUpdateResponse {
+ if update == nil || len(update.Mapping) == 0 {
+ return update
+ }
+ kept := make([]*proto.ProxyMapping, 0, len(update.Mapping))
+ for _, m := range update.Mapping {
+ if !proxyAcceptsMapping(conn, m) {
+ continue
+ }
+ kept = append(kept, m)
+ }
+ if len(kept) == len(update.Mapping) {
+ return update
+ }
+ return &proto.GetMappingUpdateResponse{
+ Mapping: kept,
+ InitialSyncComplete: update.InitialSyncComplete,
+ }
+}
+
// perProxyMessage returns a copy of update with a fresh one-time token for
// create/update operations. For delete operations the original mapping is
// used unchanged because proxies do not need to authenticate for removal.
@@ -961,7 +994,10 @@ func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.Authen
authenticated, userId, method := s.authenticateRequest(ctx, req, service)
- token, err := s.generateSessionToken(ctx, authenticated, service, userId, method)
+ // Non-OIDC schemes (PIN/Password/Header) authenticate against per-service
+ // secrets and have no user-level group context, so groups stay nil. Email
+ // is also empty โ these schemes don't resolve a user record at sign time.
+ token, err := s.generateSessionToken(ctx, authenticated, service, userId, "", method, nil, nil)
if err != nil {
return nil, err
}
@@ -1050,7 +1086,7 @@ func (s *ProxyServiceServer) logAuthenticationError(ctx context.Context, err err
}
}
-func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authenticated bool, service *rpservice.Service, userId string, method proxyauth.Method) (string, error) {
+func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authenticated bool, service *rpservice.Service, userId, userEmail string, method proxyauth.Method, groupIDs, groupNames []string) (string, error) {
if !authenticated || service.SessionPrivateKey == "" {
return "", nil
}
@@ -1058,8 +1094,11 @@ func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authentic
token, err := sessionkey.SignToken(
service.SessionPrivateKey,
userId,
+ userEmail,
service.Domain,
method,
+ groupIDs,
+ groupNames,
proxyauth.DefaultSessionExpiry,
)
if err != nil {
@@ -1070,6 +1109,26 @@ func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authentic
return token, nil
}
+// pairGroupIDsAndNames splits a slice of resolved *types.Group records
+// into parallel id and name slices. ids[i] and names[i] always pair to
+// the same group. nil entries (orphan ids the manager couldn't resolve)
+// are skipped so the consumer can rely on positional pairing.
+func pairGroupIDsAndNames(groups []*types.Group) (ids, names []string) {
+ if len(groups) == 0 {
+ return nil, nil
+ }
+ ids = make([]string, 0, len(groups))
+ names = make([]string, 0, len(groups))
+ for _, g := range groups {
+ if g == nil {
+ continue
+ }
+ ids = append(ids, g.ID)
+ names = append(names, g.Name)
+ }
+ return ids, names
+}
+
// SendStatusUpdate handles status updates from proxy clients.
func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.SendStatusUpdateRequest) (*proto.SendStatusUpdateResponse, error) {
if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil {
@@ -1334,7 +1393,9 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL
return verifier, redirectURL, nil
}
-// GenerateSessionToken creates a signed session JWT for the given domain and user.
+// GenerateSessionToken creates a signed session JWT for the given domain and
+// user. The user's group memberships are embedded in the token so policy-aware
+// middlewares on the proxy can authorise without an extra management round-trip.
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
service, err := s.getServiceByDomain(ctx, domain)
if err != nil {
@@ -1345,11 +1406,29 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
return "", fmt.Errorf("no session key configured for domain: %s", domain)
}
+ var (
+ email string
+ groupIDs []string
+ groupNames []string
+ )
+ if s.usersManager != nil {
+ user, userGroups, uerr := s.usersManager.GetUserWithGroups(ctx, userID)
+ if uerr != nil {
+ log.WithContext(ctx).Debugf("session token mint: lookup user %s: %v", userID, uerr)
+ } else if user != nil {
+ email = user.Email
+ groupIDs, groupNames = pairGroupIDsAndNames(userGroups)
+ }
+ }
+
return sessionkey.SignToken(
service.SessionPrivateKey,
userID,
+ email,
domain,
method,
+ groupIDs,
+ groupNames,
proxyauth.DefaultSessionExpiry,
)
}
@@ -1453,7 +1532,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
}, nil
}
- userID, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
+ userID, _, _, _, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
@@ -1466,7 +1545,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
}, nil
}
- user, err := s.usersManager.GetUser(ctx, userID)
+ user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
@@ -1500,12 +1579,15 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
"user_id": userID,
"error": err.Error(),
}).Debug("ValidateSession: access denied")
+ groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
//nolint:nilerr
return &proto.ValidateSessionResponse{
- Valid: false,
- UserId: user.Id,
- UserEmail: user.Email,
- DeniedReason: "not_in_group",
+ Valid: false,
+ UserId: user.Id,
+ UserEmail: user.Email,
+ DeniedReason: "not_in_group",
+ PeerGroupIds: groupIDs,
+ PeerGroupNames: groupNames,
}, nil
}
@@ -1515,10 +1597,13 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
"email": user.Email,
}).Debug("ValidateSession: access granted")
+ groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
return &proto.ValidateSessionResponse{
- Valid: true,
- UserId: user.Id,
- UserEmail: user.Email,
+ Valid: true,
+ UserId: user.Id,
+ UserEmail: user.Email,
+ PeerGroupIds: groupIDs,
+ PeerGroupNames: groupNames,
}, nil
}
@@ -1551,3 +1636,154 @@ func (s *ProxyServiceServer) checkGroupAccess(service *rpservice.Service, user *
}
func ptr[T any](v T) *T { return &v }
+
+// ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
+// checks the peer's group membership against the service's access groups.
+// Peers without a user (machine agents, automation workloads) are first-class
+// callers; authorisation runs off peer-group memberships rather than the
+// optional owning user's auto-groups. On success a session JWT is minted so
+// the proxy can install a cookie and skip subsequent management round-trips.
+func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ domain := req.GetDomain()
+ tunnelIPStr := req.GetTunnelIp()
+
+ if domain == "" || tunnelIPStr == "" {
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "missing domain or tunnel_ip",
+ }, nil
+ }
+
+ tunnelIP := net.ParseIP(tunnelIPStr)
+ if tunnelIP == nil {
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "invalid_tunnel_ip",
+ }, nil
+ }
+
+ service, err := s.getServiceByDomain(ctx, domain)
+ if err != nil {
+ log.WithFields(log.Fields{"domain": domain, "error": err.Error()}).Debug("ValidateTunnelPeer: service not found")
+ //nolint:nilerr
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "service_not_found",
+ }, nil
+ }
+
+ // Mirror ValidateSession: account-scoped (BYOP) proxy tokens may only
+ // validate and mint session cookies for their own account's domains.
+ if err := enforceAccountScope(ctx, service.AccountID); err != nil {
+ return nil, err
+ }
+
+ peer, err := s.peersManager.GetPeerByTunnelIP(ctx, service.AccountID, tunnelIP)
+ if err != nil || peer == nil {
+ log.WithFields(log.Fields{"domain": domain, "tunnel_ip": tunnelIPStr}).Debug("ValidateTunnelPeer: peer not found")
+ //nolint:nilerr
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "peer_not_found",
+ }, nil
+ }
+
+ _, peerGroups, err := s.peersManager.GetPeerWithGroups(ctx, service.AccountID, peer.ID)
+ if err != nil {
+ log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: peer groups lookup failed")
+ //nolint:nilerr
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "peer_not_found",
+ }, nil
+ }
+
+ groupIDs, groupNames := pairGroupIDsAndNames(peerGroups)
+
+ // Resolve the principal: when the peer is linked to a user, the human
+ // is the principal so multiple peers owned by the same user share a
+ // single identity. Unlinked peers (machine agents) are their own
+ // principal keyed on peer.ID. displayIdentity is what upstream gateways
+ // tag spend with โ user.Email when linked, peer.Name when not.
+ principalID := peer.ID
+ displayIdentity := peer.Name
+ if peer.UserID != "" {
+ if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil {
+ principalID = user.Id
+ if user.Email != "" {
+ displayIdentity = user.Email
+ }
+ }
+ }
+
+ if err := checkPeerGroupAccess(service, groupIDs); err != nil {
+ log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied")
+ //nolint:nilerr
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ UserId: principalID,
+ UserEmail: displayIdentity,
+ DeniedReason: "not_in_group",
+ PeerGroupIds: groupIDs,
+ PeerGroupNames: groupNames,
+ }, nil
+ }
+
+ token, err := s.generateSessionToken(ctx, true, service, principalID, displayIdentity, proxyauth.MethodOIDC, groupIDs, groupNames)
+ if err != nil {
+ return nil, err
+ }
+
+ log.WithFields(log.Fields{
+ "domain": domain,
+ "tunnel_ip": tunnelIPStr,
+ "peer_id": peer.ID,
+ "principal_id": principalID,
+ }).Debug("ValidateTunnelPeer: access granted")
+
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ UserId: principalID,
+ UserEmail: displayIdentity,
+ SessionToken: token,
+ PeerGroupIds: groupIDs,
+ PeerGroupNames: groupNames,
+ }, nil
+}
+
+// checkPeerGroupAccess gates ValidateTunnelPeer by the service's required
+// groups. Private services authorise against AccessGroups (empty list fails
+// closed โ Validate() rejects that at save time but the RPC is the security
+// boundary and must not trust upstream state). Bearer-auth services authorise
+// against DistributionGroups when populated. Non-private non-bearer services
+// are open.
+func checkPeerGroupAccess(service *rpservice.Service, peerGroupIDs []string) error {
+ if service.Private {
+ if len(service.AccessGroups) == 0 {
+ return fmt.Errorf("private service has no access groups")
+ }
+ return matchAnyGroup(service.AccessGroups, peerGroupIDs)
+ }
+ if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled && len(service.Auth.BearerAuth.DistributionGroups) > 0 {
+ return matchAnyGroup(service.Auth.BearerAuth.DistributionGroups, peerGroupIDs)
+ }
+ return nil
+}
+
+// matchAnyGroup returns nil when peerGroupIDs intersects allowedGroups,
+// else a non-nil error.
+func matchAnyGroup(allowedGroups, peerGroupIDs []string) error {
+ if len(allowedGroups) == 0 {
+ return fmt.Errorf("no allowed groups configured")
+ }
+ allowed := make(map[string]struct{}, len(allowedGroups))
+ for _, g := range allowedGroups {
+ allowed[g] = struct{}{}
+ }
+ for _, g := range peerGroupIDs {
+ if _, ok := allowed[g]; ok {
+ return nil
+ }
+ }
+ return fmt.Errorf("peer not in allowed groups")
+}
diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go
index 5980f8a30..76da7ddbc 100644
--- a/management/internals/shared/grpc/proxy_group_access_test.go
+++ b/management/internals/shared/grpc/proxy_group_access_test.go
@@ -129,6 +129,14 @@ func (m *mockUsersManager) GetUser(ctx context.Context, userID string) (*types.U
return user, nil
}
+func (m *mockUsersManager) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
+ user, err := m.GetUser(ctx, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return user, nil, nil
+}
+
func TestValidateUserGroupAccess(t *testing.T) {
tests := []struct {
name string
@@ -420,3 +428,46 @@ func TestGetAccountProxyByDomain(t *testing.T) {
})
}
}
+
+func TestCheckPeerGroupAccess(t *testing.T) {
+ t.Run("private with empty AccessGroups denies", func(t *testing.T) {
+ svc := &service.Service{Private: true, AccessGroups: nil}
+ err := checkPeerGroupAccess(svc, []string{"grp-admins"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no access groups")
+ })
+
+ t.Run("private with peer in AccessGroups allows", func(t *testing.T) {
+ svc := &service.Service{Private: true, AccessGroups: []string{"grp-admins", "grp-ops"}}
+ assert.NoError(t, checkPeerGroupAccess(svc, []string{"grp-other", "grp-ops"}))
+ })
+
+ t.Run("private with peer outside AccessGroups denies", func(t *testing.T) {
+ svc := &service.Service{Private: true, AccessGroups: []string{"grp-admins"}}
+ assert.Error(t, checkPeerGroupAccess(svc, []string{"grp-other"}))
+ })
+
+ t.Run("bearer enabled with empty DistributionGroups allows", func(t *testing.T) {
+ svc := &service.Service{
+ Auth: service.AuthConfig{BearerAuth: &service.BearerAuthConfig{Enabled: true}},
+ }
+ assert.NoError(t, checkPeerGroupAccess(svc, []string{"grp-anyone"}))
+ })
+
+ t.Run("bearer enabled gates on DistributionGroups", func(t *testing.T) {
+ svc := &service.Service{
+ Auth: service.AuthConfig{
+ BearerAuth: &service.BearerAuthConfig{
+ Enabled: true,
+ DistributionGroups: []string{"grp-allowed"},
+ },
+ },
+ }
+ assert.NoError(t, checkPeerGroupAccess(svc, []string{"grp-allowed"}))
+ assert.Error(t, checkPeerGroupAccess(svc, []string{"grp-other"}))
+ })
+
+ t.Run("non-private non-bearer is open", func(t *testing.T) {
+ assert.NoError(t, checkPeerGroupAccess(&service.Service{}, nil))
+ })
+}
diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go
index 774c5d1d3..1dc2dac28 100644
--- a/management/internals/shared/grpc/validate_session_test.go
+++ b/management/internals/shared/grpc/validate_session_test.go
@@ -102,7 +102,7 @@ func generateSessionKeyPair(t *testing.T) (string, string) {
func createSessionToken(t *testing.T, privKeyB64, userID, domain string) string {
t.Helper()
- token, err := sessionkey.SignToken(privKeyB64, userID, domain, auth.MethodOIDC, time.Hour)
+ token, err := sessionkey.SignToken(privKeyB64, userID, domain, auth.MethodOIDC, nil, time.Hour)
require.NoError(t, err)
return token
}
@@ -125,6 +125,7 @@ func TestValidateSession_UserAllowed(t *testing.T) {
assert.True(t, resp.Valid, "User should be allowed access")
assert.Equal(t, "allowedUserId", resp.UserId)
assert.Empty(t, resp.DeniedReason)
+ assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships")
}
func TestValidateSession_UserNotInAllowedGroup(t *testing.T) {
@@ -145,6 +146,7 @@ func TestValidateSession_UserNotInAllowedGroup(t *testing.T) {
assert.False(t, resp.Valid, "User not in group should be denied")
assert.Equal(t, "not_in_group", resp.DeniedReason)
assert.Equal(t, "nonGroupUserId", resp.UserId)
+ assert.Empty(t, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's actual (empty) memberships on denial")
}
func TestValidateSession_UserInDifferentAccount(t *testing.T) {
diff --git a/management/server/metrics/selfhosted.go b/management/server/metrics/selfhosted.go
index 8732cf89f..efe50c88f 100644
--- a/management/server/metrics/selfhosted.go
+++ b/management/server/metrics/selfhosted.go
@@ -17,6 +17,7 @@ import (
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
log "github.com/sirupsen/logrus"
+ "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
nbversion "github.com/netbirdio/netbird/version"
)
@@ -53,6 +54,7 @@ type DataSource interface {
GetAllAccounts(ctx context.Context) []*types.Account
GetStoreEngine() types.Engine
GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
+ GetProxyMetrics(ctx context.Context) (store.ProxyMetrics, error)
}
// ConnManager peer connection manager that holds state for current active connections
@@ -223,6 +225,12 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
servicesAuthPassword int
servicesAuthPin int
servicesAuthOIDC int
+ // Private-service signals โ track adoption of NetBird-only mode
+ // (services backed by an embedded proxy peer + access groups).
+ servicesPrivate int
+ servicesPrivateWithGroups int
+ servicesPrivateAccessGroupsSum int
+ servicesWithDirectUpstream int
)
start := time.Now()
metricsProperties := make(properties)
@@ -380,9 +388,31 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled {
servicesAuthOIDC++
}
+
+ if service.Private {
+ servicesPrivate++
+ if len(service.AccessGroups) > 0 {
+ servicesPrivateWithGroups++
+ }
+ servicesPrivateAccessGroupsSum += len(service.AccessGroups)
+ }
+
+ for _, target := range service.Targets {
+ if target.Options.DirectUpstream {
+ servicesWithDirectUpstream++
+ break
+ }
+ }
}
}
+ // Proxy / BYOP cluster signals come from the proxies table aggregated
+ // across all accounts in a single store query; nil on FileStore.
+ proxyMetrics, err := w.dataSource.GetProxyMetrics(ctx)
+ if err != nil {
+ log.WithContext(ctx).Debugf("collect proxy metrics: %v", err)
+ }
+
minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions)
metricsProperties["uptime"] = uptime
metricsProperties["accounts"] = accounts
@@ -430,6 +460,15 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
metricsProperties["services_auth_password"] = servicesAuthPassword
metricsProperties["services_auth_pin"] = servicesAuthPin
metricsProperties["services_auth_oidc"] = servicesAuthOIDC
+ metricsProperties["services_private"] = servicesPrivate
+ metricsProperties["services_private_with_access_groups"] = servicesPrivateWithGroups
+ metricsProperties["services_private_access_groups_sum"] = servicesPrivateAccessGroupsSum
+ metricsProperties["services_with_direct_upstream"] = servicesWithDirectUpstream
+ metricsProperties["proxy_clusters"] = proxyMetrics.Clusters
+ metricsProperties["proxy_clusters_byop"] = proxyMetrics.ClustersBYOP
+ metricsProperties["proxy_clusters_private"] = proxyMetrics.ClustersPrivate
+ metricsProperties["proxies"] = proxyMetrics.Proxies
+ metricsProperties["proxies_connected"] = proxyMetrics.ProxiesConnected
metricsProperties["custom_domains"] = customDomains
metricsProperties["custom_domains_validated"] = customDomainsValidated
diff --git a/management/server/metrics/selfhosted_test.go b/management/server/metrics/selfhosted_test.go
index 78f5c53be..ca9e10262 100644
--- a/management/server/metrics/selfhosted_test.go
+++ b/management/server/metrics/selfhosted_test.go
@@ -12,6 +12,7 @@ import (
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/posture"
+ "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/route"
)
@@ -123,7 +124,7 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account {
Enabled: true,
Targets: []*rpservice.Target{
{TargetType: "peer"},
- {TargetType: "host"},
+ {TargetType: "host", Options: rpservice.TargetOptions{DirectUpstream: true}},
},
Auth: rpservice.AuthConfig{
PasswordAuth: &rpservice.PasswordAuthConfig{Enabled: true},
@@ -141,6 +142,16 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account {
},
Meta: rpservice.Meta{Status: string(rpservice.StatusPending)},
},
+ {
+ ID: "svc3-private",
+ Enabled: true,
+ Private: true,
+ AccessGroups: []string{"grp-eng", "grp-ops"},
+ Targets: []*rpservice.Target{
+ {TargetType: "cluster", Options: rpservice.TargetOptions{DirectUpstream: true}},
+ },
+ Meta: rpservice.Meta{Status: string(rpservice.StatusActive)},
+ },
},
},
{
@@ -254,6 +265,18 @@ func (mockDatasource) GetCustomDomainsCounts(_ context.Context) (int64, int64, e
return 3, 2, nil
}
+// GetProxyMetrics returns canned proxy/cluster counts so the
+// generateProperties test can assert the BYOP signals end-to-end.
+func (mockDatasource) GetProxyMetrics(_ context.Context) (store.ProxyMetrics, error) {
+ return store.ProxyMetrics{
+ Clusters: 3,
+ ClustersBYOP: 1,
+ ClustersPrivate: 1,
+ Proxies: 4,
+ ProxiesConnected: 2,
+ }, nil
+}
+
// TestGenerateProperties tests and validate the properties generation by using the mockDatasource for the Worker.generateProperties
func TestGenerateProperties(t *testing.T) {
ds := mockDatasource{}
@@ -393,17 +416,17 @@ func TestGenerateProperties(t *testing.T) {
t.Errorf("expected 3 embedded_idp_count, got %v", properties["embedded_idp_count"])
}
- if properties["services"] != 2 {
- t.Errorf("expected 2 services, got %v", properties["services"])
+ if properties["services"] != 3 {
+ t.Errorf("expected 3 services, got %v", properties["services"])
}
- if properties["services_enabled"] != 1 {
- t.Errorf("expected 1 services_enabled, got %v", properties["services_enabled"])
+ if properties["services_enabled"] != 2 {
+ t.Errorf("expected 2 services_enabled, got %v", properties["services_enabled"])
}
- if properties["services_targets"] != 3 {
- t.Errorf("expected 3 services_targets, got %v", properties["services_targets"])
+ if properties["services_targets"] != 4 {
+ t.Errorf("expected 4 services_targets, got %v", properties["services_targets"])
}
- if properties["services_status_active"] != 1 {
- t.Errorf("expected 1 services_status_active, got %v", properties["services_status_active"])
+ if properties["services_status_active"] != 2 {
+ t.Errorf("expected 2 services_status_active, got %v", properties["services_status_active"])
}
if properties["services_status_pending"] != 1 {
t.Errorf("expected 1 services_status_pending, got %v", properties["services_status_pending"])
@@ -420,6 +443,9 @@ func TestGenerateProperties(t *testing.T) {
if properties["services_target_type_domain"] != 1 {
t.Errorf("expected 1 services_target_type_domain, got %v", properties["services_target_type_domain"])
}
+ if properties["services_target_type_cluster"] != 1 {
+ t.Errorf("expected 1 services_target_type_cluster, got %v", properties["services_target_type_cluster"])
+ }
if properties["services_auth_password"] != 1 {
t.Errorf("expected 1 services_auth_password, got %v", properties["services_auth_password"])
}
@@ -429,6 +455,33 @@ func TestGenerateProperties(t *testing.T) {
if properties["services_auth_pin"] != 0 {
t.Errorf("expected 0 services_auth_pin, got %v", properties["services_auth_pin"])
}
+ if properties["services_private"] != 1 {
+ t.Errorf("expected 1 services_private, got %v", properties["services_private"])
+ }
+ if properties["services_private_with_access_groups"] != 1 {
+ t.Errorf("expected 1 services_private_with_access_groups, got %v", properties["services_private_with_access_groups"])
+ }
+ if properties["services_private_access_groups_sum"] != 2 {
+ t.Errorf("expected 2 services_private_access_groups_sum, got %v", properties["services_private_access_groups_sum"])
+ }
+ if properties["services_with_direct_upstream"] != 2 {
+ t.Errorf("expected 2 services_with_direct_upstream, got %v", properties["services_with_direct_upstream"])
+ }
+ if properties["proxy_clusters"] != int64(3) {
+ t.Errorf("expected 3 proxy_clusters, got %v", properties["proxy_clusters"])
+ }
+ if properties["proxy_clusters_byop"] != int64(1) {
+ t.Errorf("expected 1 proxy_clusters_byop, got %v", properties["proxy_clusters_byop"])
+ }
+ if properties["proxy_clusters_private"] != int64(1) {
+ t.Errorf("expected 1 proxy_clusters_private, got %v", properties["proxy_clusters_private"])
+ }
+ if properties["proxies"] != int64(4) {
+ t.Errorf("expected 4 proxies, got %v", properties["proxies"])
+ }
+ if properties["proxies_connected"] != int64(2) {
+ t.Errorf("expected 2 proxies_connected, got %v", properties["proxies_connected"])
+ }
if properties["custom_domains"] != int64(3) {
t.Errorf("expected 3 custom_domains, got %v", properties["custom_domains"])
}
diff --git a/management/server/peer.go b/management/server/peer.go
index 34b681f51..37cacee41 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -125,6 +125,18 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
}
}
+ // An embedded proxy peer flipping to connected is the trigger for
+ // SynthesizePrivateServiceZones to emit DNS A records pointing at its
+ // tunnel IP. Without an account-wide netmap recompute, user peers keep
+ // the stale synth (or no synth at all on first connect) until some
+ // other change pokes the controller. Fire OnPeersUpdated so the
+ // buffered recompute fans the new state out to every peer.
+ if peer.ProxyMeta.Embedded {
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s connect: %v", peer.ID, err)
+ }
+ }
+
return nil
}
@@ -160,6 +172,17 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP
return nil
}
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied)
+
+ // Symmetric with MarkPeerConnected: when an embedded proxy peer goes
+ // offline, drive an account-wide netmap recompute so the synthesized
+ // DNS records that pointed at it are pulled. Without this the records
+ // linger client-side at TTL until something else triggers a refresh.
+ if peer.ProxyMeta.Embedded {
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s disconnect: %v", peer.ID, err)
+ }
+ }
+
return nil
}
diff --git a/management/server/store/file_store.go b/management/server/store/file_store.go
index 81185b020..bcf563cd0 100644
--- a/management/server/store/file_store.go
+++ b/management/server/store/file_store.go
@@ -274,3 +274,9 @@ func (s *FileStore) SetFieldEncrypt(_ *crypt.FieldEncrypt) {
func (s *FileStore) GetCustomDomainsCounts(_ context.Context) (int64, int64, error) {
return 0, 0, nil
}
+
+// GetProxyMetrics is a no-op for FileStore โ proxy/cluster state isn't
+// persisted in the JSON file format.
+func (s *FileStore) GetProxyMetrics(_ context.Context) (ProxyMetrics, error) {
+ return ProxyMetrics{}, nil
+}
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index 279c0e21f..d8c27fb5c 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -1090,6 +1090,38 @@ func (s *SqlStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, er
return total, validated, nil
}
+// GetProxyMetrics aggregates per-cluster + per-proxy counts for the
+// self-hosted telemetry payload. Single round-trip via conditional
+// aggregations so a large proxies table doesn't fan out into multiple
+// queries.
+func (s *SqlStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) {
+ var m ProxyMetrics
+ activeCutoff := time.Now().Add(-proxyActiveThreshold)
+
+ // COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite/postgres
+ // (MySQL too) and keeps the round-trip to one. proxy.StatusConnected
+ // is the same string the cluster-capability queries use; the active
+ // window matches the cluster-capability semantics (only proxies
+ // heartbeating within ~2 * heartbeat interval count as connected).
+ row := s.db.WithContext(ctx).
+ Model(&proxy.Proxy{}).
+ Select(
+ "COUNT(DISTINCT cluster_address) AS clusters, "+
+ "COUNT(DISTINCT CASE WHEN account_id IS NOT NULL THEN cluster_address END) AS clusters_byop, "+
+ "COUNT(DISTINCT CASE WHEN private = ? THEN cluster_address END) AS clusters_private, "+
+ "COUNT(*) AS proxies, "+
+ "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS proxies_connected",
+ true,
+ proxy.StatusConnected,
+ activeCutoff,
+ ).
+ Row()
+ if err := row.Scan(&m.Clusters, &m.ClustersBYOP, &m.ClustersPrivate, &m.Proxies, &m.ProxiesConnected); err != nil {
+ return ProxyMetrics{}, fmt.Errorf("scan proxy metrics: %w", err)
+ }
+ return m, nil
+}
+
func (s *SqlStore) GetAllAccounts(ctx context.Context) (all []*types.Account) {
var accounts []types.Account
result := s.db.Find(&accounts)
@@ -2178,7 +2210,8 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth,
meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster,
pass_host_header, rewrite_redirects, session_private_key, session_public_key,
- mode, listen_port, port_auto_assigned, source, source_peer, terminated
+ mode, listen_port, port_auto_assigned, source, source_peer, terminated,
+ private, access_groups
FROM services WHERE account_id = $1`
const targetsQuery = `SELECT id, account_id, service_id, path, host, port, protocol,
@@ -2193,10 +2226,11 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) {
var s rpservice.Service
var auth []byte
+ var accessGroups []byte
var createdAt, certIssuedAt sql.NullTime
var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString
var mode, source, sourcePeer sql.NullString
- var terminated, portAutoAssigned sql.NullBool
+ var terminated, portAutoAssigned, private sql.NullBool
var listenPort sql.NullInt64
err := row.Scan(
&s.ID,
@@ -2219,6 +2253,8 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
&source,
&sourcePeer,
&terminated,
+ &private,
+ &accessGroups,
)
if err != nil {
return nil, err
@@ -2230,6 +2266,16 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
}
}
+ if len(accessGroups) > 0 {
+ if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil {
+ return nil, fmt.Errorf("unmarshal access_groups: %w", err)
+ }
+ }
+
+ if private.Valid {
+ s.Private = private.Bool
+ }
+
s.Meta = rpservice.Meta{}
if createdAt.Valid {
s.Meta.CreatedAt = createdAt.Time
@@ -5826,6 +5872,7 @@ var validCapabilityColumns = map[string]struct{}{
"supports_custom_ports": {},
"require_subdomain": {},
"supports_crowdsec": {},
+ "private": {},
}
// GetClusterSupportsCustomPorts returns whether any active proxy in the cluster
@@ -5840,6 +5887,12 @@ func (s *SqlStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr s
return s.getClusterCapability(ctx, clusterAddr, "require_subdomain")
}
+// GetClusterSupportsPrivate reports whether any active proxy in the cluster
+// has the private capability (nil = unreported).
+func (s *SqlStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
+ return s.getClusterCapability(ctx, clusterAddr, "private")
+}
+
// GetClusterSupportsCrowdSec returns whether all active proxies in the cluster
// have CrowdSec configured. Returns nil when no proxy reported the capability.
// Unlike other capabilities that use ANY-true (for rolling upgrades), CrowdSec
diff --git a/management/server/store/sql_store_service_test.go b/management/server/store/sql_store_service_test.go
new file mode 100644
index 000000000..0978440c6
--- /dev/null
+++ b/management/server/store/sql_store_service_test.go
@@ -0,0 +1,46 @@
+package store
+
+import (
+ "context"
+ "os"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+)
+
+func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) {
+ if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
+ t.Skip("skip CI tests on darwin and windows")
+ }
+
+ runTestForAllEngines(t, "", func(t *testing.T, store Store) {
+ ctx := context.Background()
+ account := newAccountWithId(ctx, "account_private_svc", "testuser", "")
+ require.NoError(t, store.SaveAccount(ctx, account))
+
+ svc := &rpservice.Service{
+ ID: "svc-private",
+ AccountID: account.Id,
+ Name: "private-svc",
+ Domain: "private.example",
+ ProxyCluster: "cluster.example",
+ Enabled: true,
+ Mode: rpservice.ModeHTTP,
+ Private: true,
+ AccessGroups: []string{"grp-admins", "grp-ops"},
+ }
+ require.NoError(t, store.CreateService(ctx, svc))
+
+ loaded, err := store.GetAccount(ctx, account.Id)
+ require.NoError(t, err)
+ require.Len(t, loaded.Services, 1)
+
+ got := loaded.Services[0]
+ assert.True(t, got.Private)
+ assert.Equal(t, []string{"grp-admins", "grp-ops"}, got.AccessGroups)
+ })
+}
diff --git a/management/server/store/store.go b/management/server/store/store.go
index 39b1c0ed3..746207f27 100644
--- a/management/server/store/store.go
+++ b/management/server/store/store.go
@@ -312,6 +312,7 @@ type Store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
@@ -320,9 +321,38 @@ type Store interface {
GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
+ // GetProxyMetrics returns aggregated proxy / cluster counts for the
+ // self-hosted metrics worker. Self-hosted only โ file-based stores
+ // return a zero-valued struct.
+ GetProxyMetrics(ctx context.Context) (ProxyMetrics, error)
+
GetRoutingPeerNetworks(ctx context.Context, accountID, peerID string) ([]string, error)
}
+// ProxyMetrics aggregates self-hosted proxy + cluster usage signals
+// surfaced to the telemetry payload. Each field is best-effort: when a
+// store cannot answer (e.g. FileStore) all fields are zero.
+type ProxyMetrics struct {
+ // Clusters counts distinct cluster_address values across the proxies
+ // table โ every cluster the management server has heard from, online or not.
+ Clusters int64
+ // ClustersBYOP counts distinct cluster_address values that are owned
+ // by an account (account_id IS NOT NULL). These are bring-your-own-proxy
+ // installations as opposed to NetBird-operated shared clusters.
+ ClustersBYOP int64
+ // ClustersPrivate counts distinct cluster_address values where at
+ // least one proxy reported the private capability (embedded
+ // `netbird proxy` running inside a client).
+ ClustersPrivate int64
+ // Proxies is the total number of proxy rows currently persisted.
+ Proxies int64
+ // ProxiesConnected is the subset of proxies whose status is
+ // "connected" AND last_seen falls within the active heartbeat window
+ // (~2 * heartbeat interval). Proxies the controller hasn't pruned
+ // yet but that are visibly stale don't count.
+ ProxiesConnected int64
+}
+
const (
postgresDsnEnv = "NB_STORE_ENGINE_POSTGRES_DSN"
postgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN"
diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go
index c7e86c2db..dfd5af78d 100644
--- a/management/server/store/store_mock.go
+++ b/management/server/store/store_mock.go
@@ -1461,6 +1461,20 @@ func (mr *MockStoreMockRecorder) GetClusterSupportsCustomPorts(ctx, clusterAddr
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCustomPorts", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCustomPorts), ctx, clusterAddr)
}
+// GetClusterSupportsPrivate mocks base method.
+func (m *MockStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetClusterSupportsPrivate", ctx, clusterAddr)
+ ret0, _ := ret[0].(*bool)
+ return ret0
+}
+
+// GetClusterSupportsPrivate indicates an expected call of GetClusterSupportsPrivate.
+func (mr *MockStoreMockRecorder) GetClusterSupportsPrivate(ctx, clusterAddr interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsPrivate", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsPrivate), ctx, clusterAddr)
+}
+
// GetCustomDomain mocks base method.
func (m *MockStore) GetCustomDomain(ctx context.Context, accountID, domainID string) (*domain.Domain, error) {
m.ctrl.T.Helper()
@@ -2076,6 +2090,21 @@ func (mr *MockStoreMockRecorder) GetProxyClusters(ctx, accountID interface{}) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyClusters", reflect.TypeOf((*MockStore)(nil).GetProxyClusters), ctx, accountID)
}
+// GetProxyMetrics mocks base method.
+func (m *MockStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetProxyMetrics", ctx)
+ ret0, _ := ret[0].(ProxyMetrics)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetProxyMetrics indicates an expected call of GetProxyMetrics.
+func (mr *MockStoreMockRecorder) GetProxyMetrics(ctx interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyMetrics", reflect.TypeOf((*MockStore)(nil).GetProxyMetrics), ctx)
+}
+
// GetResourceGroups mocks base method.
func (m *MockStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types2.Group, error) {
m.ctrl.T.Helper()
diff --git a/management/server/types/account.go b/management/server/types/account.go
index 870333a60..dc0c5a685 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -32,7 +32,9 @@ import (
)
const (
- defaultTTL = 300
+ defaultTTL = 300
+ // privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly to clients.
+ privateServiceDNSRecordTTL = 5
DefaultPeerLoginExpiration = 24 * time.Hour
DefaultPeerInactivityExpiration = 10 * time.Minute
@@ -254,6 +256,117 @@ func getUniqueHostLabel(name string, peerLabels LookupMap) string {
return ""
}
+// SynthesizePrivateServiceZones returns in-memory CustomZones with A records pointing each enabled private service the peer can reach at the cluster's proxy-peer IPs. One zone per cluster (multiple services share); records gated by AccessGroups.
+func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZone {
+ peer, ok := a.Peers[peerID]
+ if !ok || peer == nil {
+ return nil
+ }
+ if len(a.Services) == 0 {
+ return nil
+ }
+
+ proxyPeersByCluster := a.GetProxyPeers()
+ if len(proxyPeersByCluster) == 0 {
+ return nil
+ }
+
+ peerGroups := a.GetPeerGroups(peerID)
+ zonesByCluster := map[string]*nbdns.CustomZone{}
+
+ for _, svc := range a.Services {
+ if svc == nil || !svc.Enabled || !svc.Private {
+ continue
+ }
+ if len(svc.AccessGroups) == 0 {
+ continue
+ }
+ if !peerInDistributionGroups(peerGroups, svc.AccessGroups) {
+ continue
+ }
+ proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+ if len(proxyPeers) == 0 {
+ continue
+ }
+
+ zone, exists := zonesByCluster[svc.ProxyCluster]
+ if !exists {
+ // NonAuthoritative makes this a match-only zone: queries for
+ // names without an explicit record fall through to the
+ // upstream resolver instead of returning NXDOMAIN. Without
+ // it, adding a single private service would black-hole every
+ // other name under the cluster apex.
+ zone = &nbdns.CustomZone{
+ Domain: dns.Fqdn(svc.ProxyCluster),
+ Records: []nbdns.SimpleRecord{},
+ NonAuthoritative: true,
+ }
+ zonesByCluster[svc.ProxyCluster] = zone
+ }
+
+ emitted := 0
+ skippedDisconnected := 0
+ for _, p := range proxyPeers {
+ if p == nil || !p.IP.IsValid() {
+ continue
+ }
+ // Only emit a record when the proxy peer is actually
+ // connected. A disconnected proxy peer's tunnel IP won't
+ // answer; pointing DNS at it would produce a black hole
+ // for as long as the record is cached client-side.
+ if p.Status == nil || !p.Status.Connected {
+ skippedDisconnected++
+ continue
+ }
+ zone.Records = append(zone.Records, nbdns.SimpleRecord{
+ Name: dns.Fqdn(svc.Domain),
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: privateServiceDNSRecordTTL,
+ RData: p.IP.String(),
+ })
+ emitted++
+ }
+ // Disagreement with the firewall path is the typical
+ // "domain doesn't reach client but firewall rules do"
+ // symptom: the synth service is otherwise fine, only the
+ // proxy peer's persisted Connected flag is wrong (most
+ // likely the connection reaper marked it disconnected even
+ // though the gRPC stream is alive).
+ if emitted == 0 && skippedDisconnected > 0 {
+ log.Debugf("private-zone synth: svc %s domain=%s cluster=%s emitted_zero proxy_peers=%d all_disconnected=%d (firewall would still fire)",
+ svc.ID, svc.Domain, svc.ProxyCluster, len(proxyPeers), skippedDisconnected)
+ }
+ }
+
+ out := make([]nbdns.CustomZone, 0, len(zonesByCluster))
+ for _, zone := range zonesByCluster {
+ if len(zone.Records) == 0 {
+ continue
+ }
+ out = append(out, *zone)
+ }
+ if len(out) == 0 && len(a.Services) > 0 {
+ // Targeted diagnostic for the "firewall yes, DNS no" divergence โ
+ // fires only when services exist but synth returns zero zones,
+ // so accounts without private services produce no noise.
+ log.Debugf("private-zone synth: peer %s account %s returned 0 zones from %d candidate service(s)",
+ peerID, a.Id, len(a.Services))
+ }
+ return out
+}
+
+// peerInDistributionGroups reports whether any of the peer's groups
+// matches the service's bearer-auth distribution_groups.
+func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string) bool {
+ for _, gid := range distributionGroups {
+ if _, ok := peerGroups[gid]; ok {
+ return true
+ }
+ }
+ return false
+}
+
func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdns.CustomZone {
var merr *multierror.Error
@@ -1498,6 +1611,53 @@ func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *servi
a.injectTargetProxyPolicies(ctx, service, target, proxyPeers)
}
+ a.injectPrivateServicePolicies(service, proxyPeers)
+}
+
+// injectPrivateServicePolicies synthesises an in-memory ACL: AccessGroups โ cluster proxy peers on TCP 80/443.
+func (a *Account) injectPrivateServicePolicies(svc *service.Service, proxyPeers []*nbpeer.Peer) {
+ if !svc.Private {
+ return
+ }
+ if len(svc.AccessGroups) == 0 {
+ return
+ }
+ if len(proxyPeers) == 0 {
+ return
+ }
+ for _, proxyPeer := range proxyPeers {
+ a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer))
+ }
+}
+
+func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer) *Policy {
+ policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
+ sources := append([]string(nil), svc.AccessGroups...)
+ return &Policy{
+ ID: policyID,
+ Name: fmt.Sprintf("Private Access to %s", svc.Name),
+ Enabled: true,
+ Rules: []*PolicyRule{
+ {
+ ID: policyID,
+ PolicyID: policyID,
+ Name: fmt.Sprintf("Allow access groups to reach %s", svc.Name),
+ Enabled: true,
+ Sources: sources,
+ DestinationResource: Resource{
+ ID: proxyPeer.ID,
+ Type: ResourceTypePeer,
+ },
+ Bidirectional: false,
+ Protocol: PolicyRuleProtocolTCP,
+ Action: PolicyTrafficActionAccept,
+ PortRanges: []RulePortRange{
+ {Start: 80, End: 80},
+ {Start: 443, End: 443},
+ },
+ },
+ },
+ }
}
func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) {
diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go
index 2b4f7e051..a42028351 100644
--- a/management/server/types/account_components.go
+++ b/management/server/types/account_components.go
@@ -119,6 +119,7 @@ func (a *Account) GetPeerNetworkMapComponents(
peerGroups := a.GetPeerGroups(peerID)
components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups)
+ components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...)
for _, nsGroup := range a.NameServerGroups {
if nsGroup.Enabled {
diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go
new file mode 100644
index 000000000..dc097ce26
--- /dev/null
+++ b/management/server/types/account_private_netmap_test.go
@@ -0,0 +1,85 @@
+package types
+
+import (
+ "context"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ nbpeer "github.com/netbirdio/netbird/management/server/peer"
+)
+
+func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["user-peer"].Meta.WtVersion = "0.50.0"
+ account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0"
+
+ ctx := context.Background()
+ account.InjectProxyPolicies(ctx)
+
+ validated := map[string]struct{}{
+ "user-peer": {},
+ "proxy-peer": {},
+ }
+
+ t.Run("user-peer update", func(t *testing.T) {
+ nm := account.GetPeerNetworkMapFromComponents(ctx, "user-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
+ require.NotNil(t, nm)
+
+ zone, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io")
+ require.True(t, ok)
+ require.Len(t, zone.Records, 1)
+ assert.Equal(t, "myapp.eu.proxy.netbird.io.", zone.Records[0].Name)
+ assert.Equal(t, int(dns.TypeA), zone.Records[0].Type)
+ assert.Equal(t, "100.64.0.99", zone.Records[0].RData)
+
+ assert.Contains(t, netmapPeerIDs(nm.Peers), "proxy-peer")
+ assertPrivateServiceFirewallRules(t, nm.FirewallRules, "100.64.0.99", FirewallRuleDirectionOUT)
+ })
+
+ t.Run("proxy-peer update", func(t *testing.T) {
+ nm := account.GetPeerNetworkMapFromComponents(ctx, "proxy-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
+ require.NotNil(t, nm)
+
+ assert.Contains(t, netmapPeerIDs(nm.Peers), "user-peer")
+ assertPrivateServiceFirewallRules(t, nm.FirewallRules, "100.64.0.10", FirewallRuleDirectionIN)
+ })
+}
+
+func netmapPeerIDs(peers []*nbpeer.Peer) []string {
+ ids := make([]string, 0, len(peers))
+ for _, p := range peers {
+ if p == nil {
+ continue
+ }
+ ids = append(ids, p.ID)
+ }
+ return ids
+}
+
+func assertPrivateServiceFirewallRules(t *testing.T, rules []*FirewallRule, peerIP string, direction int) {
+ t.Helper()
+ wantPorts := map[uint16]bool{80: false, 443: false}
+ for _, r := range rules {
+ if r == nil || r.PeerIP != peerIP || r.Direction != direction {
+ continue
+ }
+ if r.Protocol != string(PolicyRuleProtocolTCP) || r.Action != string(PolicyTrafficActionAccept) {
+ continue
+ }
+ switch {
+ case r.PortRange.Start == r.PortRange.End && r.PortRange.Start != 0:
+ wantPorts[r.PortRange.Start] = true
+ case r.Port == "80":
+ wantPorts[80] = true
+ case r.Port == "443":
+ wantPorts[443] = true
+ }
+ }
+ for port, found := range wantPorts {
+ assert.Truef(t, found, "missing TCP accept rule on port %d for peer %s direction %d", port, peerIP, direction)
+ }
+}
diff --git a/management/server/types/account_private_zones_test.go b/management/server/types/account_private_zones_test.go
new file mode 100644
index 000000000..1d4f720b7
--- /dev/null
+++ b/management/server/types/account_private_zones_test.go
@@ -0,0 +1,256 @@
+package types
+
+import (
+ "context"
+ "net"
+ "net/netip"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ nbpeer "github.com/netbirdio/netbird/management/server/peer"
+)
+
+func privateZoneTestAccount(t *testing.T) *Account {
+ t.Helper()
+ return &Account{
+ Id: "acct-1",
+ Settings: &Settings{},
+ Network: &Network{
+ Identifier: "net-1",
+ Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
+ },
+ Peers: map[string]*nbpeer.Peer{
+ "user-peer": {
+ ID: "user-peer",
+ AccountID: "acct-1",
+ Key: "user-peer-key",
+ IP: netip.MustParseAddr("100.64.0.10"),
+ Status: &nbpeer.PeerStatus{Connected: true},
+ },
+ "proxy-peer": {
+ ID: "proxy-peer",
+ AccountID: "acct-1",
+ Key: "proxy-peer-key",
+ IP: netip.MustParseAddr("100.64.0.99"),
+ Status: &nbpeer.PeerStatus{Connected: true},
+ ProxyMeta: nbpeer.ProxyMeta{
+ Embedded: true,
+ Cluster: "eu.proxy.netbird.io",
+ },
+ },
+ },
+ Groups: map[string]*Group{
+ "grp-admins": {
+ ID: "grp-admins",
+ Name: "admins",
+ Peers: []string{"user-peer"},
+ },
+ },
+ Services: []*service.Service{
+ {
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Name: "myapp",
+ Domain: "myapp.eu.proxy.netbird.io",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ },
+ },
+ }
+}
+
+func TestSynthesizePrivateServiceZones_PeerInGroup_GetsRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "one cluster should produce one zone")
+ zone := zones[0]
+ assert.Equal(t, "eu.proxy.netbird.io.", zone.Domain, "zone apex must be the cluster FQDN")
+ assert.True(t, zone.NonAuthoritative, "synth zone must be match-only so unrelated sibling names fall through to the upstream resolver")
+ require.Len(t, zone.Records, 1, "one private service yields one A record")
+ rec := zone.Records[0]
+ assert.Equal(t, "myapp.eu.proxy.netbird.io.", rec.Name, "record name is the service FQDN")
+ assert.Equal(t, int(dns.TypeA), rec.Type, "record type must be A")
+ assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
+ assert.Equal(t, privateServiceDNSRecordTTL, rec.TTL, "TTL must match the synth-records constant")
+ assert.Equal(t, nbdns.DefaultClass, rec.Class, "record class must be the package default")
+}
+
+func TestSynthesizePrivateServiceZones_PeerNotInGroup_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Groups["grp-admins"].Peers = nil
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "peer outside distribution_groups must not see private-service records")
+}
+
+func TestSynthesizePrivateServiceZones_NotPrivate_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Services[0].Private = false
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "non-private service must not produce DNS records")
+}
+
+func TestSynthesizePrivateServiceZones_NoAccessGroups_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Services[0].AccessGroups = nil
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "private service without bearer auth must not produce DNS records")
+}
+
+func TestSynthesizePrivateServiceZones_NoProxyPeers_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ delete(account.Peers, "proxy-peer")
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "no embedded proxy peer in cluster means no record to emit")
+}
+
+func TestSynthesizePrivateServiceZones_DisabledService_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Services[0].Enabled = false
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "disabled service must not produce DNS records")
+}
+
+func TestSynthesizePrivateServiceZones_DisconnectedProxyPeer_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["proxy-peer"].Status = &nbpeer.PeerStatus{Connected: false}
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "disconnected proxy peer must not produce a DNS record (would be a black hole)")
+}
+
+func TestSynthesizePrivateServiceZones_PartiallyDisconnectedProxyPeers_OnlyConnectedSurface(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["proxy-peer-2"] = &nbpeer.Peer{
+ ID: "proxy-peer-2",
+ AccountID: "acct-1",
+ Key: "proxy-peer-2-key",
+ IP: netip.MustParseAddr("100.64.0.100"),
+ Status: &nbpeer.PeerStatus{Connected: false},
+ ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: "eu.proxy.netbird.io"},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1)
+ require.Len(t, zones[0].Records, 1, "only the connected proxy peer must surface")
+ assert.Equal(t, "100.64.0.99", zones[0].Records[0].RData)
+}
+
+func TestSynthesizePrivateServiceZones_MultipleProxyPeers_RoundRobin(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["proxy-peer-2"] = &nbpeer.Peer{
+ ID: "proxy-peer-2",
+ AccountID: "acct-1",
+ Key: "proxy-peer-2-key",
+ IP: netip.MustParseAddr("100.64.0.100"),
+ Status: &nbpeer.PeerStatus{Connected: true},
+ ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: "eu.proxy.netbird.io"},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "still one cluster yields one zone")
+ require.Len(t, zones[0].Records, 2, "two proxy peers must produce two A records on the same name")
+ rdata := []string{zones[0].Records[0].RData, zones[0].Records[1].RData}
+ assert.ElementsMatch(t, []string{"100.64.0.99", "100.64.0.100"}, rdata, "both proxy peer IPs must surface")
+}
+
+// findCustomZone returns the CustomZone whose Domain equals the FQDN
+// of want, or a zero value when not found. Tests use it to assert
+// that the synth zone reaches dnsUpdate.CustomZones end-to-end.
+func findCustomZone(zones []nbdns.CustomZone, want string) (nbdns.CustomZone, bool) {
+ wantFqdn := dns.Fqdn(want)
+ for _, z := range zones {
+ if z.Domain == wantFqdn {
+ return z, true
+ }
+ }
+ return nbdns.CustomZone{}, false
+}
+
+// TestPrivateZone_GetPeerNetworkMapFromComponents_ShipsSynthZone
+// covers the components-based builder path. The components builder
+// appends SynthesizePrivateServiceZones to AccountZones; the
+// CalculateNetworkMapFromComponents step then merges AccountZones
+// into dnsUpdate.CustomZones.
+func TestPrivateZone_GetPeerNetworkMapFromComponents_ShipsSynthZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ ctx := context.Background()
+ validated := map[string]struct{}{
+ "user-peer": {},
+ "proxy-peer": {},
+ }
+
+ nm := account.GetPeerNetworkMapFromComponents(ctx, "user-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
+ require.NotNil(t, nm, "network map must be produced for an in-account peer")
+
+ zone, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io")
+ require.True(t, ok, "shipped CustomZones must include the synth zone for the cluster")
+ require.Len(t, zone.Records, 1, "exactly one record per private service per connected proxy peer")
+ rec := zone.Records[0]
+ assert.Equal(t, "myapp.eu.proxy.netbird.io.", rec.Name, "record name is the service FQDN")
+ assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
+}
+
+// TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone
+// confirms the negative case the user encountered: a peer whose
+// groups don't overlap the policy's distribution_groups gets a
+// network map with no synth zone (and the wildcard / peer zones still
+// flow through). This is the test mirror of the runtime confusion
+// where the user looked at a non-distribution-group peer and assumed
+// the synth path was broken.
+func TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["outsider"] = &nbpeer.Peer{
+ ID: "outsider",
+ AccountID: "acct-1",
+ Key: "outsider-key",
+ IP: netip.MustParseAddr("100.64.0.20"),
+ Status: &nbpeer.PeerStatus{Connected: true},
+ }
+ ctx := context.Background()
+ validated := map[string]struct{}{
+ "user-peer": {},
+ "proxy-peer": {},
+ "outsider": {},
+ }
+
+ nm := account.GetPeerNetworkMapFromComponents(ctx, "outsider", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
+ require.NotNil(t, nm)
+
+ _, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io")
+ assert.False(t, ok, "peer outside the distribution_groups must not see the synth zone")
+}
+
+func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Services = append(account.Services, &service.Service{
+ ID: "svc-2",
+ AccountID: "acct-1",
+ Name: "anotherapp",
+ Domain: "anotherapp.eu.proxy.netbird.io",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ })
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "two services on the same cluster must collapse into one zone")
+ require.Len(t, zones[0].Records, 2, "two services yield two A records")
+ names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
+ assert.ElementsMatch(t, []string{"myapp.eu.proxy.netbird.io.", "anotherapp.eu.proxy.netbird.io."}, names, "both service domains must surface")
+}
diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go
index a1a616882..b55b41638 100644
--- a/management/server/types/account_test.go
+++ b/management/server/types/account_test.go
@@ -3,6 +3,7 @@ package types
import (
"context"
"fmt"
+ "net"
"net/netip"
"testing"
@@ -11,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"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"
@@ -82,9 +84,9 @@ func setupTestAccount() *Account {
},
Groups: map[string]*Group{
"groupAll": {
- ID: "groupAll",
- Name: "All",
- Peers: []string{"peer1", "peer2", "peer3", "peer11", "peer12", "peer21", "peer31", "peer32", "peer41", "peer51", "peer61"},
+ ID: "groupAll",
+ Name: "All",
+ Peers: []string{"peer1", "peer2", "peer3", "peer11", "peer12", "peer21", "peer31", "peer32", "peer41", "peer51", "peer61"},
Issued: GroupIssuedAPI,
},
"group1": {
@@ -1583,3 +1585,203 @@ func Test_filterPeerAppliedZones(t *testing.T) {
})
}
}
+
+func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
+ ctx := context.Background()
+
+ userPeerIP := netip.MustParseAddr("100.64.0.10")
+ proxyPeerIP := netip.MustParseAddr("100.64.0.99")
+
+ account := &Account{
+ Id: "acct-1",
+ Network: &Network{
+ Identifier: "net-1",
+ Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
+ },
+ Peers: map[string]*nbpeer.Peer{
+ "user-peer": {
+ ID: "user-peer",
+ AccountID: "acct-1",
+ Key: "user-peer-key",
+ IP: userPeerIP,
+ },
+ "proxy-peer": {
+ ID: "proxy-peer",
+ AccountID: "acct-1",
+ Key: "proxy-peer-key",
+ IP: proxyPeerIP,
+ ProxyMeta: nbpeer.ProxyMeta{
+ Embedded: true,
+ Cluster: "eu.proxy.netbird.io",
+ },
+ },
+ },
+ Groups: map[string]*Group{
+ "grp-admins": {
+ ID: "grp-admins",
+ Name: "admins",
+ Peers: []string{"user-peer"},
+ },
+ },
+ Services: []*service.Service{
+ {
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Name: "myapp",
+ Domain: "myapp.eu.proxy.netbird.io",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ Targets: []*service.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: service.TargetTypeCluster,
+ Protocol: "http",
+ Host: "127.0.0.1",
+ Port: 8080,
+ Enabled: true,
+ },
+ },
+ },
+ },
+ }
+
+ account.InjectProxyPolicies(ctx)
+
+ var found *Policy
+ for _, p := range account.Policies {
+ if p != nil && p.ID == "private-access-svc-1-proxy-peer" {
+ found = p
+ break
+ }
+ }
+ require.NotNil(t, found, "expected synthesised private-access policy in account.Policies")
+ require.Len(t, found.Rules, 1, "policy should have exactly one rule")
+ rule := found.Rules[0]
+ assert.Equal(t, []string{"grp-admins"}, rule.Sources, "sources should be group IDs verbatim")
+ assert.Equal(t, "proxy-peer", rule.DestinationResource.ID, "destination resource should be the proxy peer ID")
+ assert.Equal(t, ResourceTypePeer, rule.DestinationResource.Type, "destination resource type should be peer")
+
+ validatedPeersMap := map[string]struct{}{
+ "user-peer": {},
+ "proxy-peer": {},
+ }
+
+ proxyPeer := account.Peers["proxy-peer"]
+ aclPeers, firewallRules, _, _ := account.GetPeerConnectionResources(ctx, proxyPeer, validatedPeersMap, nil)
+
+ var sawUserAsAclPeer bool
+ for _, p := range aclPeers {
+ if p.ID == "user-peer" {
+ sawUserAsAclPeer = true
+ break
+ }
+ }
+ assert.True(t, sawUserAsAclPeer, "proxy peer should see the user peer as an ACL peer")
+
+ var inboundRules []*FirewallRule
+ for _, r := range firewallRules {
+ if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() {
+ inboundRules = append(inboundRules, r)
+ }
+ }
+ assert.NotEmpty(t, inboundRules, "proxy peer should have inbound firewall rules from the user peer")
+}
+
+func TestInjectPrivateServicePolicies_NotPrivate_NoPolicy(t *testing.T) {
+ ctx := context.Background()
+ account := privateServiceTestAccount(t)
+ account.Services[0].Private = false
+
+ account.InjectProxyPolicies(ctx)
+ assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "non-private service must not synthesise an access policy")
+}
+
+func TestInjectPrivateServicePolicies_EmptyAccessGroups_NoPolicy(t *testing.T) {
+ ctx := context.Background()
+ account := privateServiceTestAccount(t)
+ account.Services[0].AccessGroups = nil
+
+ account.InjectProxyPolicies(ctx)
+ assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "private service with no access groups must not synthesise a policy")
+}
+
+func TestInjectPrivateServicePolicies_NoProxyPeers_NoPolicy(t *testing.T) {
+ ctx := context.Background()
+ account := privateServiceTestAccount(t)
+ delete(account.Peers, "proxy-peer")
+
+ account.InjectProxyPolicies(ctx)
+ assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "policy must not synthesise when the cluster has no proxy peers")
+}
+
+func privateServiceTestAccount(t *testing.T) *Account {
+ t.Helper()
+ return &Account{
+ Id: "acct-1",
+ Network: &Network{
+ Identifier: "net-1",
+ Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
+ },
+ Peers: map[string]*nbpeer.Peer{
+ "user-peer": {
+ ID: "user-peer",
+ AccountID: "acct-1",
+ Key: "user-peer-key",
+ IP: netip.MustParseAddr("100.64.0.10"),
+ },
+ "proxy-peer": {
+ ID: "proxy-peer",
+ AccountID: "acct-1",
+ Key: "proxy-peer-key",
+ IP: netip.MustParseAddr("100.64.0.99"),
+ ProxyMeta: nbpeer.ProxyMeta{
+ Embedded: true,
+ Cluster: "eu.proxy.netbird.io",
+ },
+ },
+ },
+ Groups: map[string]*Group{
+ "grp-admins": {
+ ID: "grp-admins",
+ Name: "admins",
+ Peers: []string{"user-peer"},
+ },
+ },
+ Services: []*service.Service{
+ {
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Name: "myapp",
+ Domain: "myapp.eu.proxy.netbird.io",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ Targets: []*service.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: service.TargetTypeCluster,
+ Protocol: "http",
+ Host: "127.0.0.1",
+ Port: 8080,
+ Enabled: true,
+ },
+ },
+ },
+ },
+ }
+}
+
+func hasPrivateAccessPolicy(account *Account, serviceID string) bool {
+ prefix := "private-access-" + serviceID + "-"
+ for _, p := range account.Policies {
+ if p != nil && len(p.ID) > len(prefix) && p.ID[:len(prefix)] == prefix {
+ return true
+ }
+ }
+ return false
+}
diff --git a/management/server/users/manager.go b/management/server/users/manager.go
index e07f28706..1a05b1a7c 100644
--- a/management/server/users/manager.go
+++ b/management/server/users/manager.go
@@ -10,6 +10,7 @@ import (
type Manager interface {
GetUser(ctx context.Context, userID string) (*types.User, error)
+ GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error)
}
type managerImpl struct {
@@ -29,6 +30,31 @@ func (m *managerImpl) GetUser(ctx context.Context, userID string) (*types.User,
return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
}
+// GetUserWithGroups returns the user and the *types.Group records for the user's AutoGroups, in the same order as
+// AutoGroups. Group ids that don't resolve to a stored group are skipped from the returned slice (the parallel id list is
+// derivable from the returned User). Wraps two store calls today; can be optimised to a single JOIN later if needed.
+// Any store error returns (nil, nil, err) so callers never receive a valid user alongside a non-nil error.
+func (m *managerImpl) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
+ user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(user.AutoGroups) == 0 {
+ return user, nil, nil
+ }
+ groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups)
+ if err != nil {
+ return nil, nil, err
+ }
+ groups := make([]*types.Group, 0, len(user.AutoGroups))
+ for _, id := range user.AutoGroups {
+ if g, ok := groupsMap[id]; ok && g != nil {
+ groups = append(groups, g)
+ }
+ }
+ return user, groups, nil
+}
+
func NewManagerMock() Manager {
return &managerMock{}
}
@@ -47,3 +73,11 @@ func (m *managerMock) GetUser(ctx context.Context, userID string) (*types.User,
return nil, errors.New("user not found")
}
}
+
+func (m *managerMock) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
+ user, err := m.GetUser(ctx, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return user, nil, nil
+}
diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go
index ca9c260b7..78f0097d5 100644
--- a/proxy/auth/auth.go
+++ b/proxy/auth/auth.go
@@ -45,10 +45,14 @@ func ResolveProto(forwardedProto string, conn *tls.ConnectionState) string {
}
}
-// ValidateSessionJWT validates a session JWT and returns the user ID and method.
-func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) (userID, method string, err error) {
+// ValidateSessionJWT validates a session JWT and returns the user ID, the
+// user's email (when carried), the authentication method, any embedded
+// group memberships, and the parallel group display names. email,
+// groups, and groupNames may be empty for tokens minted before those
+// claims were introduced. groupNames pairs positionally with groups.
+func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) (userID, email, method string, groups, groupNames []string, err error) {
if publicKey == nil {
- return "", "", fmt.Errorf("no public key configured for domain")
+ return "", "", "", nil, nil, fmt.Errorf("no public key configured for domain")
}
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
@@ -58,20 +62,46 @@ func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey)
return publicKey, nil
}, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer))
if err != nil {
- return "", "", fmt.Errorf("parse token: %w", err)
+ return "", "", "", nil, nil, fmt.Errorf("parse token: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
- return "", "", fmt.Errorf("invalid token claims")
+ return "", "", "", nil, nil, fmt.Errorf("invalid token claims")
}
sub, _ := claims.GetSubject()
if sub == "" {
- return "", "", fmt.Errorf("missing subject claim")
+ return "", "", "", nil, nil, fmt.Errorf("missing subject claim")
}
methodClaim, _ := claims["method"].(string)
+ emailClaim, _ := claims["email"].(string)
+ groups = extractGroupsClaim(claims["groups"])
+ groupNames = extractGroupsClaim(claims["group_names"])
- return sub, methodClaim, nil
+ return sub, emailClaim, methodClaim, groups, groupNames, nil
+}
+
+// extractGroupsClaim decodes the "groups" claim into a string slice. The JWT
+// library decodes JSON arrays as []interface{}, so we coerce element-wise
+// and skip non-string entries silently.
+func extractGroupsClaim(claim interface{}) []string {
+ raw, ok := claim.([]interface{})
+ if !ok {
+ return nil
+ }
+ if len(raw) == 0 {
+ return nil
+ }
+ groups := make([]string, 0, len(raw))
+ for _, v := range raw {
+ if s, ok := v.(string); ok && s != "" {
+ groups = append(groups, s)
+ }
+ }
+ if len(groups) == 0 {
+ return nil
+ }
+ return groups
}
diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go
index ec8980ad9..5970886da 100644
--- a/proxy/cmd/proxy/cmd/root.go
+++ b/proxy/cmd/proxy/cmd/root.go
@@ -63,6 +63,7 @@ var (
preSharedKey string
supportsCustomPorts bool
requireSubdomain bool
+ private bool
geoDataDir string
crowdsecAPIURL string
crowdsecAPIKey string
@@ -105,6 +106,8 @@ func init() {
rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers")
rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough")
rootCmd.Flags().BoolVar(&requireSubdomain, "require-subdomain", envBoolOrDefault("NB_PROXY_REQUIRE_SUBDOMAIN", false), "Require a subdomain label in front of the cluster domain")
+ rootCmd.Flags().BoolVar(&private, "private", envBoolOrDefault("NB_PROXY_PRIVATE", false), "Enable private services accessible with NetBird-Only authentication mode.")
+ _ = rootCmd.Flags().MarkHidden("private")
rootCmd.Flags().DurationVar(&maxDialTimeout, "max-dial-timeout", envDurationOrDefault("NB_PROXY_MAX_DIAL_TIMEOUT", 0), "Cap per-service backend dial timeout (0 = no cap)")
rootCmd.Flags().DurationVar(&maxSessionIdleTimeout, "max-session-idle-timeout", envDurationOrDefault("NB_PROXY_MAX_SESSION_IDLE_TIMEOUT", 0), "Cap per-service session idle timeout (0 = no cap)")
rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)")
@@ -161,7 +164,8 @@ func runServer(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid --trusted-proxies: %w", err)
}
- srv := proxy.Server{
+ srv := proxy.New(proxy.Config{
+ ListenAddr: addr,
Logger: logger,
Version: Version,
ManagementAddress: mgmtAddr,
@@ -178,7 +182,7 @@ func runServer(cmd *cobra.Command, args []string) error {
ACMEChallengeType: acmeChallengeType,
DebugEndpointEnabled: debugEndpoint,
DebugEndpointAddress: debugEndpointAddr,
- HealthAddress: healthAddr,
+ HealthAddr: healthAddr,
ForwardedProto: forwardedProto,
TrustedProxies: parsedTrustedProxies,
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
@@ -188,12 +192,13 @@ func runServer(cmd *cobra.Command, args []string) error {
PreSharedKey: preSharedKey,
SupportsCustomPorts: supportsCustomPorts,
RequireSubdomain: requireSubdomain,
+ Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
- }
+ })
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
diff --git a/proxy/inbound.go b/proxy/inbound.go
new file mode 100644
index 000000000..8165b331f
--- /dev/null
+++ b/proxy/inbound.go
@@ -0,0 +1,547 @@
+package proxy
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ stdlog "log"
+ "net"
+ "net/http"
+ "net/netip"
+ "strconv"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/embed"
+ "github.com/netbirdio/netbird/proxy/internal/auth"
+ "github.com/netbirdio/netbird/proxy/internal/debug"
+ nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+)
+
+// httpInboundReadHeaderTimeout matches the host-listener read header timeout
+// so per-account http.Servers don't leak idle connections.
+const httpInboundReadHeaderTimeout = 30 * time.Second
+
+// httpInboundIdleTimeout caps idle keep-alive on per-account inbound HTTP
+// servers; matches the host listener.
+const httpInboundIdleTimeout = 90 * time.Second
+
+// inboundShutdownTimeout caps how long a per-account http.Server gets to
+// drain in-flight requests during teardown.
+const inboundShutdownTimeout = 5 * time.Second
+
+// privateInboundPortHTTPS is the WG-side TLS port. Each account's
+// embedded netstack binds independently, so a fixed port is fine.
+const privateInboundPortHTTPS = 443
+
+// privateInboundPortHTTP is the WG-side plain-HTTP port.
+const privateInboundPortHTTP = 80
+
+// inboundManager wires per-account inbound listeners into the proxy
+// pipeline when --private-inbound is enabled. When disabled the manager
+// is nil and every method on *Server that touches it short-circuits.
+type inboundManager struct {
+ logger *log.Logger
+ handler http.Handler
+ tlsConfig *tls.Config
+ // muxLock guards entries and pendingRoutes.
+ muxLock sync.Mutex
+ entries map[types.AccountID]*inboundEntry
+ pendingRoutes map[types.AccountID][]pendingInboundRoute
+}
+
+// inboundEntry owns the listeners, router and HTTP servers for a single
+// account's embedded netstack.
+type inboundEntry struct {
+ router *nbtcp.Router
+ tlsListener net.Listener
+ plainListener net.Listener
+ httpsServer *http.Server
+ httpServer *http.Server
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+}
+
+// pendingInboundRoute holds a route that arrived before the account's
+// listener finished starting.
+type pendingInboundRoute struct {
+ host nbtcp.SNIHost
+ route nbtcp.Route
+}
+
+// newInboundManager constructs a manager bound to the proxy's HTTP
+// handler chain and TLS config.
+func newInboundManager(logger *log.Logger, handler http.Handler, tlsConfig *tls.Config) *inboundManager {
+ return &inboundManager{
+ logger: logger,
+ handler: handler,
+ tlsConfig: tlsConfig,
+ entries: make(map[types.AccountID]*inboundEntry),
+ pendingRoutes: make(map[types.AccountID][]pendingInboundRoute),
+ }
+}
+
+// onClientReady is registered with NetBird.SetClientLifecycle so the
+// listener pair comes up exactly when the embedded client reports ready.
+// The returned value is opaque to the roundtrip package; it is handed
+// back verbatim to onClientStop on teardown.
+func (m *inboundManager) onClientReady(ctx context.Context, accountID types.AccountID, client *embed.Client) any {
+ if m == nil {
+ return nil
+ }
+ entry, err := m.bringUp(ctx, accountID, client)
+ if err != nil {
+ m.logger.WithField("account_id", accountID).WithError(err).Warn("failed to start per-account inbound listener; continuing without inbound")
+ return nil
+ }
+
+ m.flushPending(accountID, entry)
+
+ m.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "https": entry.tlsListener.Addr().String(),
+ "http": entry.plainListener.Addr().String(),
+ }).Info("per-account inbound listeners up")
+ return entry
+}
+
+// onClientStop tears down a per-account listener bundle. State is the
+// opaque value previously returned by onClientReady.
+func (m *inboundManager) onClientStop(accountID types.AccountID, state any) {
+ if m == nil {
+ return
+ }
+ entry, ok := state.(*inboundEntry)
+ if !ok || entry == nil {
+ return
+ }
+ m.tearDown(accountID, entry)
+}
+
+// bringUp opens both listeners on the account's netstack, builds the
+// router, and starts the parallel HTTP servers.
+func (m *inboundManager) bringUp(ctx context.Context, accountID types.AccountID, client *embed.Client) (*inboundEntry, error) {
+ tlsListener, err := client.ListenTCP(fmt.Sprintf(":%d", privateInboundPortHTTPS))
+ if err != nil {
+ return nil, fmt.Errorf("listen tls on netstack: %w", err)
+ }
+ plainListener, err := client.ListenTCP(fmt.Sprintf(":%d", privateInboundPortHTTP))
+ if err != nil {
+ _ = tlsListener.Close()
+ return nil, fmt.Errorf("listen plain on netstack: %w", err)
+ }
+
+ router := nbtcp.NewRouter(m.logger, accountDialResolver(accountID, client), tlsListener.Addr(), nbtcp.WithPlainHTTP(plainListener.Addr()))
+
+ scopedHandler := withTunnelLookup(m.handler, accountTunnelLookup(client))
+
+ // markOverlayOrigin stamps every connection accepted by an inbound
+ // listener with a context value middlewares can read to skip
+ // geo/CrowdSec checks (the source address is always inside the
+ // NetBird CGNAT range and won't match either dataset).
+ markOverlayOrigin := func(ctx context.Context, _ net.Conn) context.Context {
+ return types.WithOverlayOrigin(ctx)
+ }
+
+ httpsServer := &http.Server{
+ Handler: scopedHandler,
+ TLSConfig: m.tlsConfig,
+ ReadHeaderTimeout: httpInboundReadHeaderTimeout,
+ IdleTimeout: httpInboundIdleTimeout,
+ ErrorLog: newInboundErrorLog(m.logger, "https", accountID),
+ ConnContext: markOverlayOrigin,
+ }
+ httpServer := &http.Server{
+ Handler: scopedHandler,
+ ReadHeaderTimeout: httpInboundReadHeaderTimeout,
+ IdleTimeout: httpInboundIdleTimeout,
+ ErrorLog: newInboundErrorLog(m.logger, "http", accountID),
+ ConnContext: markOverlayOrigin,
+ }
+
+ runCtx, cancel := context.WithCancel(ctx)
+ entry := &inboundEntry{
+ router: router,
+ tlsListener: tlsListener,
+ plainListener: plainListener,
+ httpsServer: httpsServer,
+ httpServer: httpServer,
+ cancel: cancel,
+ }
+
+ entry.wg.Add(1)
+ go func() {
+ defer entry.wg.Done()
+ if err := router.Serve(runCtx, tlsListener); err != nil {
+ m.logger.WithField("account_id", accountID).Debugf("per-account router stopped: %v", err)
+ }
+ }()
+
+ entry.wg.Add(1)
+ go func() {
+ defer entry.wg.Done()
+ if err := httpsServer.ServeTLS(router.HTTPListener(), "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ m.logger.WithField("account_id", accountID).Debugf("per-account https server stopped: %v", err)
+ }
+ }()
+
+ entry.wg.Add(1)
+ go func() {
+ defer entry.wg.Done()
+ if err := httpServer.Serve(router.HTTPListenerPlain()); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ m.logger.WithField("account_id", accountID).Debugf("per-account http server stopped: %v", err)
+ }
+ }()
+
+ entry.wg.Add(1)
+ go func() {
+ defer entry.wg.Done()
+ feedRouterFromListener(runCtx, plainListener, router, m.logger, accountID)
+ }()
+
+ m.muxLock.Lock()
+ m.entries[accountID] = entry
+ m.muxLock.Unlock()
+
+ return entry, nil
+}
+
+// tearDown shuts every goroutine down and closes the netstack listeners.
+func (m *inboundManager) tearDown(accountID types.AccountID, entry *inboundEntry) {
+ m.muxLock.Lock()
+ if m.entries[accountID] == entry {
+ delete(m.entries, accountID)
+ delete(m.pendingRoutes, accountID)
+ }
+ m.muxLock.Unlock()
+
+ entry.cancel()
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), inboundShutdownTimeout)
+ defer cancel()
+
+ if err := entry.httpsServer.Shutdown(shutdownCtx); err != nil {
+ m.logger.Debugf("per-account https shutdown: %v", err)
+ }
+ if err := entry.httpServer.Shutdown(shutdownCtx); err != nil {
+ m.logger.Debugf("per-account http shutdown: %v", err)
+ }
+ if err := entry.tlsListener.Close(); err != nil {
+ m.logger.Debugf("close per-account tls listener: %v", err)
+ }
+ if err := entry.plainListener.Close(); err != nil {
+ m.logger.Debugf("close per-account plain listener: %v", err)
+ }
+ entry.wg.Wait()
+}
+
+// AddRoute records an SNI/host route on the account's per-account router.
+// Routes registered before the listener is up are queued and replayed
+// once startup completes.
+func (m *inboundManager) AddRoute(accountID types.AccountID, host nbtcp.SNIHost, route nbtcp.Route) {
+ if m == nil {
+ return
+ }
+ m.muxLock.Lock()
+ entry, ok := m.entries[accountID]
+ if !ok {
+ m.queuePendingLocked(accountID, host, route)
+ m.muxLock.Unlock()
+ return
+ }
+ router := entry.router
+ m.muxLock.Unlock()
+
+ router.AddRoute(host, route)
+}
+
+// RemoveRoute drops a previously registered route. Safe to call when the
+// listener is not yet up; queued copies are pruned in that case.
+func (m *inboundManager) RemoveRoute(accountID types.AccountID, host nbtcp.SNIHost, svcID types.ServiceID) {
+ if m == nil {
+ return
+ }
+ m.muxLock.Lock()
+ m.dropPendingLocked(accountID, host, svcID)
+ entry, ok := m.entries[accountID]
+ if !ok {
+ m.muxLock.Unlock()
+ return
+ }
+ router := entry.router
+ m.muxLock.Unlock()
+
+ router.RemoveRoute(host, svcID)
+}
+
+// queuePendingLocked stores or upserts a pending route. Caller holds muxLock.
+func (m *inboundManager) queuePendingLocked(accountID types.AccountID, host nbtcp.SNIHost, route nbtcp.Route) {
+ queued := m.pendingRoutes[accountID]
+ for i, pr := range queued {
+ if pr.host == host && pr.route.ServiceID == route.ServiceID {
+ queued[i] = pendingInboundRoute{host: host, route: route}
+ m.pendingRoutes[accountID] = queued
+ return
+ }
+ }
+ m.pendingRoutes[accountID] = append(queued, pendingInboundRoute{host: host, route: route})
+}
+
+// dropPendingLocked removes any queued route matching host/svcID.
+// Caller holds muxLock.
+func (m *inboundManager) dropPendingLocked(accountID types.AccountID, host nbtcp.SNIHost, svcID types.ServiceID) {
+ queued, ok := m.pendingRoutes[accountID]
+ if !ok {
+ return
+ }
+ filtered := queued[:0]
+ for _, pr := range queued {
+ if pr.host == host && pr.route.ServiceID == svcID {
+ continue
+ }
+ filtered = append(filtered, pr)
+ }
+ if len(filtered) == 0 {
+ delete(m.pendingRoutes, accountID)
+ return
+ }
+ m.pendingRoutes[accountID] = filtered
+}
+
+// flushPending applies all queued routes to a freshly-up router.
+func (m *inboundManager) flushPending(accountID types.AccountID, entry *inboundEntry) {
+ m.muxLock.Lock()
+ queued := m.pendingRoutes[accountID]
+ delete(m.pendingRoutes, accountID)
+ m.muxLock.Unlock()
+
+ for _, pr := range queued {
+ entry.router.AddRoute(pr.host, pr.route)
+ }
+}
+
+// HasInbound reports whether the manager has a live listener for the account.
+// Used by tests.
+func (m *inboundManager) HasInbound(accountID types.AccountID) bool {
+ if m == nil {
+ return false
+ }
+ m.muxLock.Lock()
+ defer m.muxLock.Unlock()
+ _, ok := m.entries[accountID]
+ return ok
+}
+
+// PendingRouteCount reports the number of queued routes for the account.
+// Used by tests.
+func (m *inboundManager) PendingRouteCount(accountID types.AccountID) int {
+ if m == nil {
+ return 0
+ }
+ m.muxLock.Lock()
+ defer m.muxLock.Unlock()
+ return len(m.pendingRoutes[accountID])
+}
+
+// InboundListenerInfo describes the bound addresses of a single
+// per-account inbound listener. Both addresses live on the embedded
+// netstack of the account's WireGuard client and share the same tunnel IP.
+type InboundListenerInfo struct {
+ TunnelIP string
+ HTTPSPort uint16
+ HTTPPort uint16
+}
+
+// ListenerInfo returns the inbound listener addresses for the given
+// account, or ok=false when the account has no live listener. Used by
+// the status-update RPC and the debug HTTP handler to surface inbound
+// reachability to operators.
+func (m *inboundManager) ListenerInfo(accountID types.AccountID) (InboundListenerInfo, bool) {
+ if m == nil {
+ return InboundListenerInfo{}, false
+ }
+ m.muxLock.Lock()
+ defer m.muxLock.Unlock()
+ entry, ok := m.entries[accountID]
+ if !ok || entry == nil {
+ return InboundListenerInfo{}, false
+ }
+ return listenerInfoFromEntry(entry), true
+}
+
+// Snapshot returns the inbound listener state for every account that has
+// a live listener at call time. Empty when --private-inbound is off or
+// no accounts have come up yet.
+func (m *inboundManager) Snapshot() map[types.AccountID]InboundListenerInfo {
+ if m == nil {
+ return nil
+ }
+ m.muxLock.Lock()
+ defer m.muxLock.Unlock()
+ if len(m.entries) == 0 {
+ return nil
+ }
+ out := make(map[types.AccountID]InboundListenerInfo, len(m.entries))
+ for id, entry := range m.entries {
+ if entry == nil {
+ continue
+ }
+ out[id] = listenerInfoFromEntry(entry)
+ }
+ return out
+}
+
+// listenerInfoFromEntry extracts the tunnel IP and ports from a live
+// per-account entry. Both listeners are bound on the same netstack so
+// their host components match; we still pull the TLS host as the
+// authoritative source.
+func listenerInfoFromEntry(entry *inboundEntry) InboundListenerInfo {
+ info := InboundListenerInfo{HTTPSPort: privateInboundPortHTTPS, HTTPPort: privateInboundPortHTTP}
+ if entry.tlsListener != nil {
+ host, port := splitHostPort(entry.tlsListener.Addr())
+ info.TunnelIP = host
+ if port != 0 {
+ info.HTTPSPort = port
+ }
+ }
+ if entry.plainListener != nil {
+ host, port := splitHostPort(entry.plainListener.Addr())
+ if info.TunnelIP == "" {
+ info.TunnelIP = host
+ }
+ if port != 0 {
+ info.HTTPPort = port
+ }
+ }
+ return info
+}
+
+// splitHostPort extracts host and port from a net.Addr, returning the
+// zero values when the address is missing or malformed.
+func splitHostPort(addr net.Addr) (string, uint16) {
+ if addr == nil {
+ return "", 0
+ }
+ host, portStr, err := net.SplitHostPort(addr.String())
+ if err != nil {
+ return "", 0
+ }
+ if portStr == "" {
+ return host, 0
+ }
+ port, err := strconv.ParseUint(portStr, 10, 16)
+ if err != nil {
+ return host, 0
+ }
+ return host, uint16(port)
+}
+
+// feedRouterFromListener accepts on the plain-HTTP netstack listener and
+// hands every connection to the account's router. The router peeks the
+// first byte and dispatches to the plain-HTTP channel for non-TLS
+// streams or the TLS channel for ClientHellos that arrive on :80.
+func feedRouterFromListener(ctx context.Context, ln net.Listener, router *nbtcp.Router, logger *log.Logger, accountID types.AccountID) {
+ go func() {
+ <-ctx.Done()
+ _ = ln.Close()
+ }()
+
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
+ return
+ }
+ logger.WithField("account_id", accountID).Debugf("plain inbound accept: %v", err)
+ continue
+ }
+ router.HandleConn(ctx, conn)
+ }
+}
+
+// accountDialResolver returns a DialResolver bound to a single account's
+// embedded client. The router only ever serves traffic for that account
+// so the supplied accountID is ignored at dial time.
+func accountDialResolver(_ types.AccountID, client *embed.Client) nbtcp.DialResolver {
+ return func(_ types.AccountID) (types.DialContextFunc, error) {
+ return client.DialContext, nil
+ }
+}
+
+// accountTunnelLookup returns a TunnelLookupFunc backed by the embedded
+// client's peerstore for a single account. Phase 3 uses the result to
+// short-circuit ValidateTunnelPeer when the source IP is not in the
+// account's roster and to seed the cached identity for known peers.
+func accountTunnelLookup(client *embed.Client) auth.TunnelLookupFunc {
+ if client == nil {
+ return nil
+ }
+ return func(ip netip.Addr) (auth.PeerIdentity, bool) {
+ pubKey, fqdn, ok := client.IdentityForIP(ip)
+ if !ok {
+ return auth.PeerIdentity{}, false
+ }
+ return auth.PeerIdentity{
+ PubKey: pubKey,
+ TunnelIP: ip,
+ FQDN: fqdn,
+ }, true
+ }
+}
+
+// withTunnelLookup returns an http.Handler that attaches the per-account
+// peerstore lookup to every request's context before delegating to next.
+// Calling on the host-level listener is a no-op because that path never
+// installs this wrapper, so the existing behaviour stays byte-for-byte
+// identical when --private-inbound is off or the request didn't arrive
+// on a per-account listener.
+func withTunnelLookup(next http.Handler, lookup auth.TunnelLookupFunc) http.Handler {
+ if lookup == nil {
+ return next
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := auth.WithTunnelLookup(r.Context(), lookup)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// inboundDebugAdapter adapts *inboundManager to the debug.InboundProvider
+// interface so the debug HTTP handler can render per-account inbound
+// listener state without importing the proxy package.
+type inboundDebugAdapter struct {
+ mgr *inboundManager
+}
+
+// InboundListeners returns a snapshot of the live per-account inbound
+// listeners formatted for the debug surface.
+func (a inboundDebugAdapter) InboundListeners() map[types.AccountID]debug.InboundListenerInfo {
+ if a.mgr == nil {
+ return nil
+ }
+ snap := a.mgr.Snapshot()
+ if len(snap) == 0 {
+ return nil
+ }
+ out := make(map[types.AccountID]debug.InboundListenerInfo, len(snap))
+ for id, info := range snap {
+ out[id] = debug.InboundListenerInfo{
+ TunnelIP: info.TunnelIP,
+ HTTPSPort: info.HTTPSPort,
+ HTTPPort: info.HTTPPort,
+ }
+ }
+ return out
+}
+
+// newInboundErrorLog routes a per-account http.Server's stdlib error
+// stream through logrus at warn level.
+func newInboundErrorLog(logger *log.Logger, scheme string, accountID types.AccountID) *stdlog.Logger {
+ return stdlog.New(logger.WithFields(log.Fields{
+ "inbound-http": scheme,
+ "account_id": accountID,
+ }).WriterLevel(log.WarnLevel), "", 0)
+}
diff --git a/proxy/inbound_test.go b/proxy/inbound_test.go
new file mode 100644
index 000000000..a868f1c12
--- /dev/null
+++ b/proxy/inbound_test.go
@@ -0,0 +1,502 @@
+package proxy
+
+import (
+ "bufio"
+ "context"
+ "crypto/tls"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+
+ "github.com/netbirdio/netbird/proxy/internal/auth"
+ "github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// bufioReader wraps the connection in a buffered reader so http.ReadResponse
+// can parse the response line + headers off the wire.
+func bufioReader(conn net.Conn) *bufio.Reader {
+ return bufio.NewReader(conn)
+}
+
+// quietLogger returns a logger that emits nothing โ keeps test output tidy.
+func quietLogger() *log.Logger {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+ return logger
+}
+
+func TestInboundManager_RouteScopedToAccount(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+
+ accountA := types.AccountID("acct-a")
+ accountB := types.AccountID("acct-b")
+
+ mgr.AddRoute(accountA, "shared.example", nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountA, ServiceID: "svc-a", Domain: "shared.example"})
+ mgr.AddRoute(accountB, "other.example", nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountB, ServiceID: "svc-b", Domain: "other.example"})
+
+ require.Equal(t, 1, mgr.PendingRouteCount(accountA), "account A should have one queued route")
+ require.Equal(t, 1, mgr.PendingRouteCount(accountB), "account B should have one queued route")
+
+ mgr.RemoveRoute(accountA, "shared.example", "svc-a")
+ mgr.RemoveRoute(accountB, "other.example", "svc-b")
+
+ assert.Equal(t, 0, mgr.PendingRouteCount(accountA), "queue should drain on remove")
+ assert.Equal(t, 0, mgr.PendingRouteCount(accountB), "queue should drain on remove")
+}
+
+func TestInboundManager_PendingThenFlush(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+
+ accountID := types.AccountID("acct-1")
+ host := nbtcp.SNIHost("example.test")
+ route := nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountID, ServiceID: "svc-1", Domain: "example.test"}
+
+ mgr.AddRoute(accountID, host, route)
+ require.Equal(t, 1, mgr.PendingRouteCount(accountID), "pending count before listener is up")
+
+ // Simulate listener up by registering a fake entry, then flushing.
+ router := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"})
+ entry := &inboundEntry{router: router}
+ mgr.muxLock.Lock()
+ mgr.entries[accountID] = entry
+ mgr.muxLock.Unlock()
+
+ mgr.flushPending(accountID, entry)
+ assert.Equal(t, 0, mgr.PendingRouteCount(accountID), "queue should be empty after flush")
+}
+
+// fakeAddr is a stub net.Addr for tests that don't actually bind sockets.
+type fakeAddr struct {
+ addr string
+}
+
+func (a *fakeAddr) Network() string { return "tcp" }
+func (a *fakeAddr) String() string { return a.addr }
+
+// fakeMgmtClient implements roundtrip.managementClient for tests.
+type fakeMgmtClient struct{}
+
+func (fakeMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ return &proto.CreateProxyPeerResponse{Success: true}, nil
+}
+
+// TestServer_PrivateInbound_NotEnabled_NoManager confirms that with
+// --private off the inbound manager is nil and the standalone proxy
+// keeps its zero-overhead default path.
+func TestServer_PrivateInbound_NotEnabled_NoManager(t *testing.T) {
+ s := &Server{Logger: quietLogger(), Private: false}
+ s.initPrivateInbound(http.NotFoundHandler(), nil)
+ assert.Nil(t, s.inbound, "manager should remain nil when --private is off")
+}
+
+// TestServer_PrivateInbound_Enabled_WiresLifecycle confirms that
+// --private alone wires the manager into the NetBird transport, so
+// AddPeer / RemovePeer drive the lifecycle.
+func TestServer_PrivateInbound_Enabled_WiresLifecycle(t *testing.T) {
+ s := &Server{Logger: quietLogger(), Private: true}
+ // Construct a NetBird transport. We can't actually start the embedded
+ // client here (that needs a real management server), but we can
+ // confirm that the lifecycle callbacks are registered.
+ s.netbird = roundtrip.NewNetBird("test", "test", roundtrip.ClientConfig{
+ MgmtAddr: "http://invalid.test",
+ }, quietLogger(), nil, fakeMgmtClient{})
+
+ s.initPrivateInbound(http.NotFoundHandler(), &tls.Config{}) //nolint:gosec
+ require.NotNil(t, s.inbound, "manager should be set when --private is on")
+ assert.NotNil(t, s.inbound.handler, "handler should be set on manager")
+ assert.NotNil(t, s.inbound.tlsConfig, "tls config should be set on manager")
+}
+
+// TestInboundManager_AddRouteAfterReady_RegistersDirectly verifies that
+// when the listener is already up, AddRoute writes straight to the
+// router without queueing.
+func TestInboundManager_AddRouteAfterReady_RegistersDirectly(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+ accountID := types.AccountID("acct-1")
+ router := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"})
+
+ mgr.muxLock.Lock()
+ mgr.entries[accountID] = &inboundEntry{router: router}
+ mgr.muxLock.Unlock()
+
+ host := nbtcp.SNIHost("ready.example")
+ mgr.AddRoute(accountID, host, nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountID, ServiceID: "svc-ready", Domain: string(host)})
+ assert.Equal(t, 0, mgr.PendingRouteCount(accountID), "no pending entries when listener is up")
+}
+
+// TestPrivateCapability_DerivedFromPrivateOnly tests that the capability
+// bit reported upstream tracks --private exclusively. The previous
+// --private-inbound flag has been folded into --private.
+func TestPrivateCapability_DerivedFromPrivateOnly(t *testing.T) {
+ tests := []struct {
+ name string
+ private bool
+ expected bool
+ }{
+ {"off", false, false},
+ {"on", true, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := &Server{Private: tt.private}
+ assert.Equal(t, tt.expected, s.Private, "private capability bit should match --private")
+ })
+ }
+}
+
+// TestInboundManager_RouteScopedToAccountB_DoesNotMatchA verifies that a
+// service registered for account B is invisible to a router serving
+// account A. We exercise the path through real per-account routers.
+func TestInboundManager_RouteScopedToAccountB_DoesNotMatchA(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+
+ accountA := types.AccountID("acct-a")
+ accountB := types.AccountID("acct-b")
+ routerA := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"})
+ routerB := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"})
+
+ mgr.muxLock.Lock()
+ mgr.entries[accountA] = &inboundEntry{router: routerA}
+ mgr.entries[accountB] = &inboundEntry{router: routerB}
+ mgr.muxLock.Unlock()
+
+ host := nbtcp.SNIHost("shared.example")
+ mgr.AddRoute(accountB, host, nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountB, ServiceID: "svc-b", Domain: string(host)})
+
+ // Account A's router should have no routes; account B's should have one.
+ // We check via IsEmpty โ true means no routes and no fallback.
+ assert.True(t, routerA.IsEmpty(), "account A router must not see account B's mappings")
+ assert.False(t, routerB.IsEmpty(), "account B router should hold its own mapping")
+}
+
+// TestInboundEntry_ShutdownIdempotent ensures that tearDown can run twice
+// without panicking โ callers may invoke it from RemovePeer + StopAll.
+func TestInboundEntry_ShutdownIdempotent(t *testing.T) {
+ t.Skip("teardown requires real netstack listeners; covered by integration tests")
+}
+
+// TestRouter_PlainHTTP_ForwardedProtoIsHTTP exercises the full per-account
+// router pipeline against a loopback listener (proxy of a netstack
+// listener for test purposes): a plain HTTP request lands on the plain
+// http.Server and the inner handler observes a nil r.TLS, which is what
+// auth.ResolveProto translates to "http" in the real pipeline.
+func TestRouter_PlainHTTP_ForwardedProtoIsHTTP(t *testing.T) {
+ logger := quietLogger()
+
+ var captured atomic.Value
+ captured.Store("")
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.TLS == nil {
+ captured.Store("http")
+ } else {
+ captured.Store("https")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+ })
+
+ hostListener, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "loopback listener bind must succeed")
+ defer hostListener.Close()
+
+ router := nbtcp.NewRouter(logger, nil, hostListener.Addr(), nbtcp.WithPlainHTTP(hostListener.Addr()))
+ httpServer := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second}
+ defer func() { _ = httpServer.Close() }()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go func() { _ = httpServer.Serve(router.HTTPListenerPlain()) }()
+ go func() { _ = router.Serve(ctx, hostListener) }()
+
+ conn, err := net.DialTimeout("tcp", hostListener.Addr().String(), 2*time.Second)
+ require.NoError(t, err, "plain HTTP dial must succeed")
+ defer conn.Close()
+
+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"))
+ require.NoError(t, err, "write must succeed")
+
+ resp, err := http.ReadResponse(bufioReader(conn), nil)
+ require.NoError(t, err, "must read response")
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, "http", captured.Load(), "ForwardedProto must be http on plain path")
+}
+
+// TestWithTunnelLookup_AttachesLookupToContext verifies that requests
+// flowing through the per-account handler wrapper carry the peerstore
+// lookup function. Phase 3's local-first deny path depends on this.
+func TestWithTunnelLookup_AttachesLookupToContext(t *testing.T) {
+ expected := auth.PeerIdentity{TunnelIP: netip.MustParseAddr("100.64.0.10"), FQDN: "peer.netbird"}
+ lookup := auth.TunnelLookupFunc(func(_ netip.Addr) (auth.PeerIdentity, bool) {
+ return expected, true
+ })
+
+ var observed auth.TunnelLookupFunc
+ inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ observed = auth.TunnelLookupFromContext(r.Context())
+ })
+
+ handler := withTunnelLookup(inner, lookup)
+ r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil)
+ handler.ServeHTTP(httptest.NewRecorder(), r)
+
+ require.NotNil(t, observed, "wrapper must inject the lookup into the request context")
+ got, ok := observed(netip.MustParseAddr("100.64.0.10"))
+ assert.True(t, ok, "lookup must round-trip through context")
+ assert.Equal(t, expected.FQDN, got.FQDN, "lookup must return the same identity it was constructed with")
+}
+
+// TestWithTunnelLookup_NilLookupIsNoop confirms the wrapper is a pure
+// pass-through when no lookup is provided. Required for the host-level
+// listener path to keep its byte-for-byte previous behaviour.
+func TestWithTunnelLookup_NilLookupIsNoop(t *testing.T) {
+ var called bool
+ inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ called = true
+ assert.Nil(t, auth.TunnelLookupFromContext(r.Context()), "host-level path must not see a lookup function")
+ })
+
+ handler := withTunnelLookup(inner, nil)
+ r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil)
+ handler.ServeHTTP(httptest.NewRecorder(), r)
+ assert.True(t, called, "wrapper without lookup must still invoke next")
+}
+
+// fakeListener satisfies net.Listener for snapshot tests without binding
+// a real socket on the netstack.
+type fakeListener struct {
+ addr net.Addr
+}
+
+func (f *fakeListener) Accept() (net.Conn, error) { return nil, net.ErrClosed }
+func (f *fakeListener) Close() error { return nil }
+func (f *fakeListener) Addr() net.Addr { return f.addr }
+
+// TestInboundManager_ListenerInfo confirms ListenerInfo and Snapshot
+// surface the bound tunnel-IP and ports for live entries.
+func TestInboundManager_ListenerInfo(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+ accountID := types.AccountID("acct-info")
+
+ tlsAddr := &net.TCPAddr{IP: net.ParseIP("100.64.0.5"), Port: privateInboundPortHTTPS}
+ plainAddr := &net.TCPAddr{IP: net.ParseIP("100.64.0.5"), Port: privateInboundPortHTTP}
+ mgr.muxLock.Lock()
+ mgr.entries[accountID] = &inboundEntry{
+ tlsListener: &fakeListener{addr: tlsAddr},
+ plainListener: &fakeListener{addr: plainAddr},
+ }
+ mgr.muxLock.Unlock()
+
+ info, ok := mgr.ListenerInfo(accountID)
+ require.True(t, ok, "ListenerInfo must report ok for live entry")
+ assert.Equal(t, "100.64.0.5", info.TunnelIP, "tunnel IP must come from listener address")
+ assert.Equal(t, uint16(privateInboundPortHTTPS), info.HTTPSPort, "TLS port must match bound port")
+ assert.Equal(t, uint16(privateInboundPortHTTP), info.HTTPPort, "HTTP port must match bound port")
+
+ snap := mgr.Snapshot()
+ require.Len(t, snap, 1, "snapshot must contain exactly one entry")
+ assert.Equal(t, info, snap[accountID], "snapshot entry must equal direct lookup")
+
+ _, ok = mgr.ListenerInfo(types.AccountID("missing"))
+ assert.False(t, ok, "ListenerInfo must report ok=false for unknown accounts")
+}
+
+// TestInboundManager_NilManagerSafe ensures the observability accessors
+// are safe to call when --private-inbound is off (nil manager).
+func TestInboundManager_NilManagerSafe(t *testing.T) {
+ var mgr *inboundManager
+ _, ok := mgr.ListenerInfo("anything")
+ assert.False(t, ok, "nil manager must return ok=false")
+ assert.Nil(t, mgr.Snapshot(), "nil manager must return nil snapshot")
+}
+
+// TestInboundManager_ConcurrentAddRemove pounds AddRoute / RemoveRoute
+// from multiple goroutines to expose any locking gaps.
+func TestInboundManager_ConcurrentAddRemove(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+ accountID := types.AccountID("acct-1")
+ const workers = 32
+ const iterations = 50
+
+ var wg sync.WaitGroup
+ wg.Add(workers)
+ for i := 0; i < workers; i++ {
+ go func(idx int) {
+ defer wg.Done()
+ host := nbtcp.SNIHost("example.test")
+ svc := types.ServiceID("svc")
+ route := nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountID, ServiceID: svc, Domain: "example.test"}
+ for j := 0; j < iterations; j++ {
+ mgr.AddRoute(accountID, host, route)
+ mgr.RemoveRoute(accountID, host, svc)
+ }
+ }(i)
+ }
+ done := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ t.Fatal("concurrent add/remove timed out")
+ }
+}
+
+// TestFeedRouterFromListener_DeliversConnectionToHandler validates the
+// per-account inbound chain end-to-end with a loopback listener
+// substituted for the embedded netstack: a TCP connection arriving at
+// the plain listener flows through feedRouterFromListener, the router's
+// peek-and-dispatch, the wrapped HTTP server, and reaches the user
+// handler. If the embedded netstack is delivering connections at all,
+// this is the path they take. Failures localise to wiring bugs in the
+// proxy, not the netstack.
+func TestFeedRouterFromListener_DeliversConnectionToHandler(t *testing.T) {
+ logger := quietLogger()
+
+ hits := make(chan string, 1)
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits <- r.Host
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("served"))
+ })
+
+ plainLn, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "plain loopback bind must succeed")
+ t.Cleanup(func() { _ = plainLn.Close() })
+
+ router := nbtcp.NewRouter(logger, nil, &fakeAddr{addr: "127.0.0.1:0"}, nbtcp.WithPlainHTTP(plainLn.Addr()))
+
+ httpServer := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second}
+ t.Cleanup(func() { _ = httpServer.Close() })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ go func() { _ = httpServer.Serve(router.HTTPListenerPlain()) }()
+ go feedRouterFromListener(ctx, plainLn, router, logger, types.AccountID("acct-1"))
+
+ conn, err := net.DialTimeout("tcp", plainLn.Addr().String(), 2*time.Second)
+ require.NoError(t, err, "must connect to the plain listener")
+ t.Cleanup(func() { _ = conn.Close() })
+
+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example\r\nConnection: close\r\n\r\n"))
+ require.NoError(t, err, "request write must succeed")
+
+ resp, err := http.ReadResponse(bufioReader(conn), nil)
+ require.NoError(t, err, "must read response from server")
+ t.Cleanup(func() { _ = resp.Body.Close() })
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode, "handler must be reached")
+
+ select {
+ case host := <-hits:
+ assert.Equal(t, "app.example", host, "handler must observe the request Host")
+ case <-time.After(2 * time.Second):
+ t.Fatal("handler was not invoked โ connection did not flow through router โ http server")
+ }
+}
+
+// TestFeedRouterFromListener_DispatchesTLSToTLSChannel verifies that a
+// TLS ClientHello arriving on the plain listener is detected by the
+// router peek and re-dispatched to the TLS channel โ the cross-channel
+// fallback the inbound stack relies on for HTTPS-on-:80 testing.
+func TestFeedRouterFromListener_DispatchesTLSToTLSChannel(t *testing.T) {
+ logger := quietLogger()
+
+ hits := make(chan string, 1)
+ tlsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits <- r.Host
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("served-tls"))
+ })
+
+ plainLn, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "plain loopback bind must succeed")
+ t.Cleanup(func() { _ = plainLn.Close() })
+
+ tlsLn, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "tls loopback bind must succeed")
+ t.Cleanup(func() { _ = tlsLn.Close() })
+
+ router := nbtcp.NewRouter(logger, nil, tlsLn.Addr(), nbtcp.WithPlainHTTP(plainLn.Addr()))
+
+ tlsConfig := selfSignedTLSConfig(t)
+ httpsServer := &http.Server{
+ Handler: tlsHandler,
+ TLSConfig: tlsConfig,
+ ReadHeaderTimeout: time.Second,
+ }
+ t.Cleanup(func() { _ = httpsServer.Close() })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ go func() { _ = httpsServer.ServeTLS(router.HTTPListener(), "", "") }()
+ go feedRouterFromListener(ctx, plainLn, router, logger, types.AccountID("acct-tls"))
+
+ tlsConn, err := tls.Dial("tcp", plainLn.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec
+ require.NoError(t, err, "TLS dial against the plain listener must succeed (cross-channel)")
+ t.Cleanup(func() { _ = tlsConn.Close() })
+
+ req, err := http.NewRequest(http.MethodGet, "https://app.example/", nil)
+ require.NoError(t, err)
+ require.NoError(t, req.Write(tlsConn), "TLS request write must succeed")
+
+ resp, err := http.ReadResponse(bufioReader(tlsConn), req)
+ require.NoError(t, err, "must read TLS response")
+ t.Cleanup(func() { _ = resp.Body.Close() })
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode, "TLS handler must be reached")
+
+ select {
+ case host := <-hits:
+ assert.Equal(t, "app.example", host, "TLS handler must observe the request Host")
+ case <-time.After(2 * time.Second):
+ t.Fatal("TLS handler was not invoked โ peek/dispatch path is broken")
+ }
+}
+
+func selfSignedTLSConfig(t *testing.T) *tls.Config {
+ t.Helper()
+ cert, err := tls.X509KeyPair(testCertPEM, testKeyPEM)
+ require.NoError(t, err, "load static self-signed cert")
+ return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12} //nolint:gosec
+}
+
+// testCertPEM / testKeyPEM are a minimal RSA self-signed cert for
+// 127.0.0.1 โ only used by tests that need a working TLS handshake.
+var testCertPEM = []byte(`-----BEGIN CERTIFICATE-----
+MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
+DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
+EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
+7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
+5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
+BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
+NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
+Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
+6MF9+Yw1Yy0t
+-----END CERTIFICATE-----`)
+var testKeyPEM = []byte(`-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
+AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
+EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
+-----END EC PRIVATE KEY-----`)
diff --git a/proxy/internal/auth/identity.go b/proxy/internal/auth/identity.go
new file mode 100644
index 000000000..c49e0caa9
--- /dev/null
+++ b/proxy/internal/auth/identity.go
@@ -0,0 +1,47 @@
+package auth
+
+import (
+ "context"
+ "net/netip"
+)
+
+// PeerIdentity describes the locally-known facts about a peer reachable on
+// the proxy's per-account WireGuard listener. Phase 3 fills PubKey, TunnelIP
+// and FQDN from the embedded client's peerstore. UserID, Email and Groups
+// stay zero in V1 โ full identity still travels through ValidateTunnelPeer.
+// Phase V2 will populate them once RemotePeerConfig carries user identity.
+type PeerIdentity struct {
+ PubKey string
+ TunnelIP netip.Addr
+ FQDN string
+
+ // V2 fields (zero in V1).
+ UserID string
+ Email string
+ Groups []string
+}
+
+// TunnelLookupFunc resolves a tunnel IP to a peer identity using locally
+// available peerstore data. ok=false means the IP is not in the calling
+// account's roster.
+type TunnelLookupFunc func(ip netip.Addr) (PeerIdentity, bool)
+
+type tunnelLookupContextKey struct{}
+
+// WithTunnelLookup attaches a per-account peerstore lookup function to
+// the request context. The auth middleware calls this lookup before
+// hitting management's ValidateTunnelPeer to short-circuit unknown IPs
+// and to skip the RPC for already-cached identities.
+func WithTunnelLookup(ctx context.Context, lookup TunnelLookupFunc) context.Context {
+ if lookup == nil {
+ return ctx
+ }
+ return context.WithValue(ctx, tunnelLookupContextKey{}, lookup)
+}
+
+// TunnelLookupFromContext returns the peerstore lookup attached to ctx,
+// or nil when the request did not arrive on a per-account listener.
+func TunnelLookupFromContext(ctx context.Context) TunnelLookupFunc {
+ v, _ := ctx.Value(tunnelLookupContextKey{}).(TunnelLookupFunc)
+ return v
+}
diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go
index 3b383f8b4..a76427ca0 100644
--- a/proxy/internal/auth/middleware.go
+++ b/proxy/internal/auth/middleware.go
@@ -36,6 +36,7 @@ type authenticator interface {
// SessionValidator validates session tokens and checks user access permissions.
type SessionValidator interface {
ValidateSession(ctx context.Context, in *proto.ValidateSessionRequest, opts ...grpc.CallOption) (*proto.ValidateSessionResponse, error)
+ ValidateTunnelPeer(ctx context.Context, in *proto.ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error)
}
// Scheme defines an authentication mechanism for a domain.
@@ -56,12 +57,21 @@ type DomainConfig struct {
AccountID types.AccountID
ServiceID types.ServiceID
IPRestrictions *restrict.Filter
+ // Private routes the domain through ValidateTunnelPeer; failure โ 403.
+ Private bool
}
type validationResult struct {
UserID string
+ UserEmail string
Valid bool
DeniedReason string
+ Groups []string
+ // GroupNames carries the human-readable display names for Groups,
+ // ordered identically (positional pairing). May be shorter than
+ // Groups for tokens minted before names were embedded; the consumer
+ // falls back to ids for missing positions.
+ GroupNames []string
}
// Middleware applies per-domain authentication and IP restriction checks.
@@ -71,6 +81,7 @@ type Middleware struct {
logger *log.Logger
sessionValidator SessionValidator
geo restrict.GeoResolver
+ tunnelCache *tunnelValidationCache
}
// NewMiddleware creates a new authentication middleware. The sessionValidator is
@@ -84,6 +95,7 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
logger: logger,
sessionValidator: sessionValidator,
geo: geo,
+ tunnelCache: newTunnelValidationCache(),
}
}
@@ -111,6 +123,15 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
return
}
+ // Private services bypass operator schemes and gate on tunnel peer.
+ if config.Private {
+ if mw.forwardWithTunnelPeer(w, r, host, config, next) {
+ return
+ }
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+
// Domains with no authentication schemes pass through after IP checks.
if len(config.Schemes) == 0 {
next.ServeHTTP(w, r)
@@ -129,10 +150,54 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
return
}
+ if mw.forwardWithTunnelPeer(w, r, host, config, next) {
+ return
+ }
+
+ if mw.blockOIDCOnPlainHTTP(w, r, config) {
+ return
+ }
+
mw.authenticateWithSchemes(w, r, host, config)
})
}
+// requestIsPlainHTTP reports whether the request arrived without TLS.
+// Used to gate cookie-on-plain warnings and the OIDC plain-HTTP block.
+func requestIsPlainHTTP(r *http.Request) bool {
+ return r.TLS == nil
+}
+
+// hasOIDCScheme reports whether any of the configured schemes requires
+// TLS to round-trip safely with an external IdP.
+func hasOIDCScheme(schemes []Scheme) bool {
+ for _, s := range schemes {
+ if s.Type() == auth.MethodOIDC {
+ return true
+ }
+ }
+ return false
+}
+
+// blockOIDCOnPlainHTTP fails fast when an OIDC-configured domain is hit
+// over plain HTTP. Most IdPs reject http:// redirect URIs, so surfacing
+// the misconfiguration here yields a clearer error than the IdP's
+// "invalid redirect_uri" round-trip.
+func (mw *Middleware) blockOIDCOnPlainHTTP(w http.ResponseWriter, r *http.Request, config DomainConfig) bool {
+ if !requestIsPlainHTTP(r) {
+ return false
+ }
+ if !hasOIDCScheme(config.Schemes) {
+ return false
+ }
+ mw.logger.WithFields(log.Fields{
+ "host": r.Host,
+ "remote": r.RemoteAddr,
+ }).Warn("OIDC scheme reached on plain HTTP path; rejecting with 400 โ use port 443")
+ http.Error(w, "OIDC requires TLS โ use port 443", http.StatusBadRequest)
+ return true
+}
+
func (mw *Middleware) getDomainConfig(host string) (DomainConfig, bool) {
mw.domainsMux.RLock()
defer mw.domainsMux.RUnlock()
@@ -162,7 +227,17 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
return false
}
- verdict := config.IPRestrictions.Check(clientIP, mw.geo)
+ var verdict restrict.Verdict
+ if types.IsOverlayOrigin(r.Context()) {
+ // Geo/CrowdSec checks don't apply over the WireGuard overlay:
+ // the source address is always inside the NetBird CGNAT range,
+ // which is never in a GeoIP database or a CrowdSec decision
+ // list. Enforcing them here would either no-op (best case) or
+ // fail-closed when the geo database is missing.
+ verdict = config.IPRestrictions.CheckCIDR(clientIP)
+ } else {
+ verdict = config.IPRestrictions.Check(clientIP, mw.geo)
+ }
if verdict == restrict.Allow {
return true
}
@@ -246,18 +321,111 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re
if err != nil {
return false
}
- userID, method, err := auth.ValidateSessionJWT(cookie.Value, host, config.SessionPublicKey)
+ userID, email, method, groups, groupNames, err := auth.ValidateSessionJWT(cookie.Value, host, config.SessionPublicKey)
if err != nil {
return false
}
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetUserID(userID)
+ cd.SetUserEmail(email)
+ cd.SetUserGroups(groups)
+ cd.SetUserGroupNames(groupNames)
cd.SetAuthMethod(method)
}
next.ServeHTTP(w, r)
return true
}
+// forwardWithTunnelPeer is the OIDC fast-path for requests originating on the
+// netbird mesh. When the source IP belongs to a private/CGNAT range the proxy
+// asks management to resolve it to a peer/user and to gate by the service's
+// distribution_groups. On success the proxy installs the freshly minted JWT
+// as a session cookie, sets UserID + Method=oidc on the captured data, and
+// forwards directly โ operators see the same access-log shape as if the user
+// had completed an OIDC redirect. Any failure (private-range mismatch,
+// management unreachable, peer unknown, user not in group) returns false so
+// the caller falls back to the existing OIDC scheme dispatch.
+//
+// Phase 3 adds a local-first short-circuit: when the request arrived on a
+// per-account inbound listener the context carries a peerstore lookup
+// (TunnelLookupFromContext). If the lookup says the IP isn't in the account's
+// roster the proxy denies fast without calling management. If the lookup
+// confirms a known peer the RPC still runs for the user-identity tail
+// (UserID + group access), but its result is cached for tunnelCacheTTL so
+// repeat requests skip management entirely.
+func (mw *Middleware) forwardWithTunnelPeer(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
+ if mw.sessionValidator == nil {
+ return false
+ }
+ clientIP := mw.resolveClientIP(r)
+ if !clientIP.IsValid() {
+ return false
+ }
+ if !isTunnelSourceIP(clientIP) {
+ return false
+ }
+
+ if lookup := TunnelLookupFromContext(r.Context()); lookup != nil {
+ if _, ok := lookup(clientIP); !ok {
+ mw.logger.WithFields(log.Fields{
+ "host": host,
+ "remote": clientIP,
+ }).Debug("local peerstore: tunnel IP not in account roster; denying without RPC")
+ return false
+ }
+ }
+
+ resp, _, err := mw.tunnelCache.fetch(r.Context(), tunnelCacheKey{
+ accountID: config.AccountID,
+ tunnelIP: clientIP,
+ domain: host,
+ }, mw.validateTunnelPeer)
+ if err != nil {
+ mw.logger.WithError(err).Debug("ValidateTunnelPeer failed; falling back to OIDC")
+ return false
+ }
+ if !resp.GetValid() || resp.GetSessionToken() == "" {
+ return false
+ }
+
+ setSessionCookie(w, resp.GetSessionToken(), config.SessionExpiration)
+ if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
+ cd.SetOrigin(proxy.OriginAuth)
+ cd.SetUserID(resp.GetUserId())
+ cd.SetUserEmail(resp.GetUserEmail())
+ cd.SetUserGroups(resp.GetPeerGroupIds())
+ cd.SetUserGroupNames(resp.GetPeerGroupNames())
+ cd.SetAuthMethod(auth.MethodOIDC.String())
+ }
+ next.ServeHTTP(w, r)
+ return true
+}
+
+// validateTunnelPeer adapts the SessionValidator interface to the cache's
+// validateTunnelPeerFn signature.
+func (mw *Middleware) validateTunnelPeer(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ return mw.sessionValidator.ValidateTunnelPeer(ctx, req)
+}
+
+// cgnatPrefix covers RFC 6598 100.64.0.0/10, the CGNAT block NetBird
+// allocates tunnel addresses from by default. IsPrivate() doesn't include
+// it, so we check it explicitly.
+var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
+
+// isTunnelSourceIP reports whether ip falls within an address range typical
+// of NetBird tunnels: RFC1918 private space, IPv6 ULA, or CGNAT 100.64/10
+// (NetBird's default range). Loopback and link-local are excluded โ the
+// fast-path is meant for peer-to-peer mesh traffic, not localhost.
+func isTunnelSourceIP(ip netip.Addr) bool {
+ if !ip.IsValid() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
+ return false
+ }
+ if ip.IsPrivate() {
+ return true
+ }
+ return cgnatPrefix.Contains(ip)
+}
+
// forwardWithHeaderAuth checks for a Header auth scheme. If the header validates,
// the request is forwarded directly (no redirect), which is important for API clients.
func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
@@ -286,7 +454,7 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader)
if err != nil {
- setHeaderCapturedData(r.Context(), "")
+ setHeaderCapturedData(r.Context(), "", "", nil, nil)
status := http.StatusBadRequest
msg := "invalid session token"
if errors.Is(err, errValidationUnavailable) {
@@ -298,7 +466,7 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
}
if !result.Valid {
- setHeaderCapturedData(r.Context(), result.UserID)
+ setHeaderCapturedData(r.Context(), result.UserID, result.UserEmail, result.Groups, result.GroupNames)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return true
}
@@ -306,6 +474,9 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
setSessionCookie(w, token, config.SessionExpiration)
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetUserID(result.UserID)
+ cd.SetUserEmail(result.UserEmail)
+ cd.SetUserGroups(result.Groups)
+ cd.SetUserGroupNames(result.GroupNames)
cd.SetAuthMethod(auth.MethodHeader.String())
}
@@ -315,7 +486,7 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Request, err error) bool {
if errors.Is(err, ErrHeaderAuthFailed) {
- setHeaderCapturedData(r.Context(), "")
+ setHeaderCapturedData(r.Context(), "", "", nil, nil)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return true
}
@@ -327,7 +498,7 @@ func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Reque
return true
}
-func setHeaderCapturedData(ctx context.Context, userID string) {
+func setHeaderCapturedData(ctx context.Context, userID, userEmail string, groups, groupNames []string) {
cd := proxy.CapturedDataFromContext(ctx)
if cd == nil {
return
@@ -335,6 +506,9 @@ func setHeaderCapturedData(ctx context.Context, userID string) {
cd.SetOrigin(proxy.OriginAuth)
cd.SetAuthMethod(auth.MethodHeader.String())
cd.SetUserID(userID)
+ cd.SetUserEmail(userEmail)
+ cd.SetUserGroups(groups)
+ cd.SetUserGroupNames(groupNames)
}
// authenticateWithSchemes tries each configured auth scheme in order.
@@ -405,6 +579,9 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
cd.SetUserID(result.UserID)
+ cd.SetUserEmail(result.UserEmail)
+ cd.SetUserGroups(result.Groups)
+ cd.SetUserGroupNames(result.GroupNames)
cd.SetAuthMethod(scheme.Type().String())
requestID = cd.GetRequestID()
}
@@ -419,6 +596,9 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
cd.SetUserID(result.UserID)
+ cd.SetUserEmail(result.UserEmail)
+ cd.SetUserGroups(result.Groups)
+ cd.SetUserGroupNames(result.GroupNames)
cd.SetAuthMethod(scheme.Type().String())
}
redirectURL := stripSessionTokenParam(r.URL)
@@ -454,12 +634,9 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
return false
}
-// AddDomain registers authentication schemes for the given domain.
-// If schemes are provided, a valid session public key is required to sign/verify
-// session JWTs. Returns an error if the key is missing or invalid.
-// Callers must not serve the domain if this returns an error, to avoid
-// exposing an unauthenticated service.
-func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter) error {
+// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
+// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
+func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error {
if len(schemes) == 0 {
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
@@ -467,6 +644,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
+ Private: private,
}
return nil
}
@@ -488,6 +666,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
+ Private: private,
}
return nil
}
@@ -518,18 +697,25 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri
}).Debug("Session validation denied")
return &validationResult{
UserID: resp.UserId,
+ UserEmail: resp.GetUserEmail(),
Valid: false,
DeniedReason: resp.DeniedReason,
}, nil
}
- return &validationResult{UserID: resp.UserId, Valid: true}, nil
+ return &validationResult{
+ UserID: resp.UserId,
+ UserEmail: resp.GetUserEmail(),
+ Valid: true,
+ Groups: resp.GetPeerGroupIds(),
+ GroupNames: resp.GetPeerGroupNames(),
+ }, nil
}
- userID, _, err := auth.ValidateSessionJWT(token, host, publicKey)
+ userID, email, _, groups, groupNames, err := auth.ValidateSessionJWT(token, host, publicKey)
if err != nil {
return nil, err
}
- return &validationResult{UserID: userID, Valid: true}, nil
+ return &validationResult{UserID: userID, UserEmail: email, Valid: true, Groups: groups, GroupNames: groupNames}, nil
}
// stripSessionTokenParam returns the request URI with the session_token query
diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go
index 2c93d7912..84c319446 100644
--- a/proxy/internal/auth/middleware_test.go
+++ b/proxy/internal/auth/middleware_test.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
+ "crypto/tls"
"encoding/base64"
"errors"
"net/http"
@@ -23,6 +24,7 @@ import (
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
+ "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -62,7 +64,7 @@ func TestAddDomain_ValidKey(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)
require.NoError(t, err)
mw.domainsMux.RLock()
@@ -79,7 +81,7 @@ func TestAddDomain_EmptyKey(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -93,7 +95,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "decode session public key")
@@ -108,7 +110,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -121,7 +123,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
- err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)
require.NoError(t, err, "domains with no auth schemes should not require a key")
mw.domainsMux.RLock()
@@ -137,8 +139,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil))
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false))
mw.domainsMux.RLock()
config := mw.domains["example.com"]
@@ -154,7 +156,7 @@ func TestRemoveDomain(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
mw.RemoveDomain("example.com")
@@ -178,7 +180,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) {
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
- require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -195,7 +197,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -216,7 +218,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -237,9 +239,9 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
- token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
capturedData := proxy.NewCapturedData("")
@@ -262,15 +264,48 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
assert.Equal(t, "authenticated", rec.Body.String())
}
+// TestProtect_SessionCookieGroupsPropagate verifies the cookie path lifts the
+// JWT's groups claim into CapturedData so policy-aware middlewares can
+// authorise without an extra management round-trip.
+func TestProtect_SessionCookieGroupsPropagate(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ groups := []string{"engineering", "sre"}
+ token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
+ require.NoError(t, err)
+
+ capturedData := proxy.NewCapturedData("")
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cd := proxy.CapturedDataFromContext(r.Context())
+ require.NotNil(t, cd, "captured data must be present in request context")
+ assert.Equal(t, "test-user", cd.GetUserID())
+ assert.Equal(t, groups, cd.GetUserGroups(), "JWT groups claim must propagate to CapturedData")
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData))
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusOK, rec.Code, "request with valid groups-bearing cookie must succeed")
+ assert.Equal(t, groups, capturedData.GetUserGroups(), "CapturedData groups must be retained after handler completes")
+}
+
func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Sign a token that expired 1 second ago.
- token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, -time.Second)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
require.NoError(t, err)
var backendCalled bool
@@ -293,10 +328,10 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Token signed for a different domain audience.
- token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "other.com", auth.MethodPIN, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
var backendCalled bool
@@ -320,10 +355,10 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) {
kp2 := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
// Token signed with a different private key.
- token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour)
+ token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
var backendCalled bool
@@ -345,7 +380,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
- token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "example.com", auth.MethodPIN, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
scheme := &stubScheme{
@@ -357,7 +392,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -410,7 +445,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -427,7 +462,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
- token, err := sessionkey.SignToken(kp.PrivateKey, "password-user", "example.com", auth.MethodPassword, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "password-user", "", "example.com", auth.MethodPassword, nil, nil, time.Hour)
require.NoError(t, err)
// First scheme (PIN) always fails, second scheme (password) succeeds.
@@ -446,7 +481,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
return "", "password", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -476,7 +511,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
return "invalid-jwt-token", "", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -500,7 +535,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) {
key := base64.StdEncoding.EncodeToString(randomBytes)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil)
+ err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false)
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
}
@@ -509,10 +544,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Attempt to overwrite with an invalid key.
- err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false)
require.Error(t, err)
// The original valid config should still be intact.
@@ -536,7 +571,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -563,7 +598,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
return "", "password", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -590,7 +625,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -678,7 +713,7 @@ func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
- restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}))
+ restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -714,7 +749,7 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
- restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}))
+ restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -755,7 +790,7 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
- restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}))
+ restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -770,6 +805,69 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
assert.Equal(t, http.StatusForbidden, rr.Code, "country restrictions with nil geo must deny")
}
+// TestCheckIPRestrictions_OverlayOriginSkipsCountryRules covers the
+// inbound (WG) listener path: requests stamped with WithOverlayOrigin
+// must skip country lookups, even when no geo database is configured.
+// Without this short-circuit the inbound flow would fail-closed for
+// every overlay request whenever country rules are configured.
+func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+
+ err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
+ restrict.ParseFilter(restrict.FilterConfig{
+ AllowedCIDRs: []string{"100.64.0.0/10"},
+ AllowedCountries: []string{"US"},
+ }), false)
+ require.NoError(t, err)
+
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.64.5.6:5000"
+ req.Host = "example.com"
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code,
+ "overlay-origin requests must not be denied by country rules they would fail without geo data")
+
+ // Sanity check: the same filter without the overlay flag denies (no geo,
+ // country allowlist active โ DenyGeoUnavailable).
+ req2 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req2.RemoteAddr = "100.64.5.6:5000"
+ req2.Host = "example.com"
+ rr2 := httptest.NewRecorder()
+ handler.ServeHTTP(rr2, req2)
+ assert.Equal(t, http.StatusForbidden, rr2.Code,
+ "WAN-origin requests must still hit the full Check path and be denied without geo data")
+}
+
+// TestCheckIPRestrictions_OverlayOriginRespectsCIDR confirms CIDR
+// rules still apply on the overlay path so operators retain a way to
+// scope private services to specific peer subnets.
+func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+
+ err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
+ restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false)
+ require.NoError(t, err)
+
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.65.5.6:5000" // outside 100.64.0.0/16
+ req.Host = "example.com"
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusForbidden, rr.Code,
+ "CIDR rules must still apply on the overlay path")
+}
+
func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
@@ -781,11 +879,12 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
return "", oidcURL, nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
- req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
+ req.TLS = &tls.ConnectionState{}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -809,11 +908,12 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
- req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
+ req.TLS = &tls.ConnectionState{}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -834,7 +934,7 @@ func (m *mockAuthenticator) Authenticate(ctx context.Context, in *proto.Authenti
// returns a signed session token when the expected header value is provided.
func newHeaderSchemeWithToken(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string) Header {
t.Helper()
- token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "example.com", auth.MethodHeader, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
require.NoError(t, err)
mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
@@ -852,7 +952,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
var backendCalled bool
capturedData := proxy.NewCapturedData("")
@@ -895,7 +995,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
// Also add a PIN scheme so we can verify fallthrough behavior.
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -915,7 +1015,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
return &proto.AuthenticateResponse{Success: false}, nil
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -938,7 +1038,7 @@ func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
return nil, errors.New("gRPC unavailable")
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -955,7 +1055,7 @@ func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -1006,7 +1106,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
ha := req.GetHeaderAuth()
if ha != nil && accepted[ha.GetHeaderValue()] {
- token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "example.com", auth.MethodHeader, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
require.NoError(t, err)
return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil
}
@@ -1015,7 +1115,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
// Single Header scheme (as if one entry existed), but the mock checks both values.
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
var backendCalled bool
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -1059,3 +1159,71 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
assert.False(t, backendCalled, "unknown token should be rejected")
})
}
+
+// TestProtect_OIDCOnPlainHTTP_BlockedWith400 verifies that when an OIDC
+// scheme is configured and the request arrived without TLS, the middleware
+// short-circuits with a 400 instead of dispatching to the IdP redirect.
+func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{
+ method: auth.MethodOIDC,
+ authFn: func(_ *http.Request) (string, string, error) {
+ return "", "https://idp.example.com/authorize", nil
+ },
+ }
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "OIDC over plain HTTP should be rejected")
+ assert.Contains(t, rec.Body.String(), "OIDC requires TLS", "response body should explain the rejection")
+}
+
+// TestProtect_OIDCOverTLS_NotBlocked confirms the same configuration works
+// over TLS โ the block only fires on plain HTTP.
+func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{
+ method: auth.MethodOIDC,
+ authFn: func(_ *http.Request) (string, string, error) {
+ return "", "https://idp.example.com/authorize", nil
+ },
+ }
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
+ req.TLS = &tls.ConnectionState{}
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusFound, rec.Code, "OIDC over TLS should redirect to IdP")
+}
+
+// TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked confirms that the OIDC
+// block only fires when an OIDC scheme is configured. PIN-only domains
+// pass through normally on plain HTTP.
+func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "PIN-only domain should serve the login page on plain HTTP")
+}
diff --git a/proxy/internal/auth/tunnel_cache.go b/proxy/internal/auth/tunnel_cache.go
new file mode 100644
index 000000000..10b671d82
--- /dev/null
+++ b/proxy/internal/auth/tunnel_cache.go
@@ -0,0 +1,171 @@
+package auth
+
+import (
+ "context"
+ "net/netip"
+ "sync"
+ "time"
+
+ "golang.org/x/sync/singleflight"
+
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// tunnelCacheTTL caps how long a positive ValidateTunnelPeer result is
+// reused before re-fetching from management. 5 minutes balances freshness
+// against management load on busy mesh networks.
+const tunnelCacheTTL = 300 * time.Second
+
+// tunnelCachePerAccount caps the number of cached identities per account.
+// Bounded eviction avoids memory growth in pathological cases (huge peer
+// roster, brief request bursts) while staying generous for normal use.
+const tunnelCachePerAccount = 1024
+
+// tunnelCacheKey identifies a cached entry by tunnel IP and originating
+// account. Domain is part of the value, not the key, because the
+// management response is per (account, IP) โ domain only gates whether a
+// re-fetch is needed if the operator is accessing a different service.
+type tunnelCacheKey struct {
+ accountID types.AccountID
+ tunnelIP netip.Addr
+ domain string
+}
+
+// tunnelCacheEntry stores a positive validation response with the time it
+// was minted. Entries past tunnelCacheTTL are treated as misses.
+type tunnelCacheEntry struct {
+ resp *proto.ValidateTunnelPeerResponse
+ cachedAt time.Time
+}
+
+// tunnelValidationCache memoizes ValidateTunnelPeer responses keyed by
+// (accountID, tunnelIP, domain). Only successful, valid responses are
+// cached โ denials skip the cache so policy changes apply immediately.
+// Single-flight de-duplicates concurrent fetches for the same key so a
+// burst of cold requests collapses into a single RPC.
+type tunnelValidationCache struct {
+ mu sync.Mutex
+ entries map[types.AccountID]*accountBucket
+ flight singleflight.Group
+ ttl time.Duration
+ maxSize int
+ now func() time.Time
+}
+
+// accountBucket holds the cached entries for a single account, with a
+// FIFO eviction queue used when the bucket exceeds maxSize.
+type accountBucket struct {
+ items map[tunnelCacheKey]tunnelCacheEntry
+ order []tunnelCacheKey
+}
+
+// newTunnelValidationCache constructs a cache with default TTL and bounds.
+func newTunnelValidationCache() *tunnelValidationCache {
+ return &tunnelValidationCache{
+ entries: make(map[types.AccountID]*accountBucket),
+ ttl: tunnelCacheTTL,
+ maxSize: tunnelCachePerAccount,
+ now: time.Now,
+ }
+}
+
+// get returns a cached response for the key, or nil when missing or
+// expired. Expired entries are evicted lazily on read.
+func (c *tunnelValidationCache) get(key tunnelCacheKey) *proto.ValidateTunnelPeerResponse {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ bucket, ok := c.entries[key.accountID]
+ if !ok {
+ return nil
+ }
+ entry, ok := bucket.items[key]
+ if !ok {
+ return nil
+ }
+ if c.now().Sub(entry.cachedAt) > c.ttl {
+ delete(bucket.items, key)
+ bucket.order = removeKey(bucket.order, key)
+ return nil
+ }
+ return entry.resp
+}
+
+// put records a positive response under the key. Evicts the oldest entry
+// in the account's bucket when the bound is exceeded.
+func (c *tunnelValidationCache) put(key tunnelCacheKey, resp *proto.ValidateTunnelPeerResponse) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ bucket, ok := c.entries[key.accountID]
+ if !ok {
+ bucket = &accountBucket{items: make(map[tunnelCacheKey]tunnelCacheEntry)}
+ c.entries[key.accountID] = bucket
+ }
+ if _, exists := bucket.items[key]; !exists {
+ bucket.order = append(bucket.order, key)
+ }
+ bucket.items[key] = tunnelCacheEntry{resp: resp, cachedAt: c.now()}
+
+ for len(bucket.order) > c.maxSize {
+ oldest := bucket.order[0]
+ bucket.order = bucket.order[1:]
+ delete(bucket.items, oldest)
+ }
+}
+
+// removeKey drops the first occurrence of needle from order. The cache
+// uses small slices so a linear scan is cheaper than a map+slice combo.
+func removeKey(order []tunnelCacheKey, needle tunnelCacheKey) []tunnelCacheKey {
+ for i, k := range order {
+ if k == needle {
+ return append(order[:i], order[i+1:]...)
+ }
+ }
+ return order
+}
+
+// flightKey turns a cache key into a single-flight string. AccountID and
+// IP isolation by themselves are insufficient because different domains
+// for the same peer/account may have different group access.
+func flightKey(key tunnelCacheKey) string {
+ return string(key.accountID) + "|" + key.tunnelIP.String() + "|" + key.domain
+}
+
+// validateTunnelPeerFn is the RPC entry point the cache wraps. It matches
+// the SessionValidator.ValidateTunnelPeer signature without exposing the
+// gRPC option variadic, since callers don't need it on the cache hot path.
+type validateTunnelPeerFn func(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error)
+
+// fetch returns a cached response when present, otherwise calls validate
+// under single-flight and caches the result. Denied responses pass
+// through but are not cached so policy changes apply immediately.
+func (c *tunnelValidationCache) fetch(ctx context.Context, key tunnelCacheKey, validate validateTunnelPeerFn) (*proto.ValidateTunnelPeerResponse, bool, error) {
+ if resp := c.get(key); resp != nil {
+ return resp, true, nil
+ }
+
+ flight := flightKey(key)
+ res, err, _ := c.flight.Do(flight, func() (any, error) {
+ if cached := c.get(key); cached != nil {
+ return cached, nil
+ }
+ resp, err := validate(ctx, &proto.ValidateTunnelPeerRequest{
+ TunnelIp: key.tunnelIP.String(),
+ Domain: key.domain,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if resp.GetValid() && resp.GetSessionToken() != "" {
+ c.put(key, resp)
+ }
+ return resp, nil
+ })
+ if err != nil {
+ return nil, false, err
+ }
+ resp, _ := res.(*proto.ValidateTunnelPeerResponse)
+ return resp, false, nil
+}
diff --git a/proxy/internal/auth/tunnel_cache_test.go b/proxy/internal/auth/tunnel_cache_test.go
new file mode 100644
index 000000000..1a63dc107
--- /dev/null
+++ b/proxy/internal/auth/tunnel_cache_test.go
@@ -0,0 +1,171 @@
+package auth
+
+import (
+ "context"
+ "net/netip"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+func newTestKey(account types.AccountID, ip string, domain string) tunnelCacheKey {
+ return tunnelCacheKey{
+ accountID: account,
+ tunnelIP: netip.MustParseAddr(ip),
+ domain: domain,
+ }
+}
+
+func TestTunnelCache_HitSkipsRPC(t *testing.T) {
+ cache := newTunnelValidationCache()
+ key := newTestKey("acct-1", "100.64.0.10", "svc.example")
+
+ var calls int32
+ validate := func(_ context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&calls, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}, nil
+ }
+
+ resp, fromCache, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err)
+ require.NotNil(t, resp, "first fetch returns RPC response")
+ assert.False(t, fromCache, "first fetch must not be cached")
+
+ resp2, fromCache2, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err)
+ require.NotNil(t, resp2, "second fetch returns cached response")
+ assert.True(t, fromCache2, "second fetch must be served from cache")
+ assert.Equal(t, "user-1", resp2.GetUserId(), "cached response should preserve user identity")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "validate should run exactly once with one cache hit")
+}
+
+func TestTunnelCache_ExpiredEntryRefetches(t *testing.T) {
+ cache := newTunnelValidationCache()
+ clock := time.Now()
+ cache.now = func() time.Time { return clock }
+
+ key := newTestKey("acct-1", "100.64.0.10", "svc.example")
+ var calls int32
+ validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&calls, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}, nil
+ }
+
+ _, _, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err)
+ assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "first fetch issues one RPC")
+
+ clock = clock.Add(tunnelCacheTTL + time.Second)
+
+ _, fromCache, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err)
+ assert.False(t, fromCache, "expired entry must miss the cache")
+ assert.Equal(t, int32(2), atomic.LoadInt32(&calls), "expired entry forces a re-fetch")
+}
+
+func TestTunnelCache_DeniedResponseNotCached(t *testing.T) {
+ cache := newTunnelValidationCache()
+ key := newTestKey("acct-1", "100.64.0.10", "svc.example")
+
+ var calls int32
+ validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&calls, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: false, DeniedReason: "not_in_group"}, nil
+ }
+
+ for i := 0; i < 3; i++ {
+ _, _, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err, "fetch must not error on denied response")
+ }
+ assert.Equal(t, int32(3), atomic.LoadInt32(&calls), "denied responses bypass the cache so policy changes apply immediately")
+}
+
+func TestTunnelCache_ConcurrentColdHitsCoalesce(t *testing.T) {
+ cache := newTunnelValidationCache()
+ key := newTestKey("acct-1", "100.64.0.10", "svc.example")
+
+ gate := make(chan struct{})
+ var calls int32
+ validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&calls, 1)
+ <-gate
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}, nil
+ }
+
+ const workers = 16
+ var wg sync.WaitGroup
+ wg.Add(workers)
+ results := make([]bool, workers)
+ for i := 0; i < workers; i++ {
+ go func(idx int) {
+ defer wg.Done()
+ resp, _, err := cache.fetch(context.Background(), key, validate)
+ results[idx] = err == nil && resp.GetValid()
+ }(i)
+ }
+
+ time.Sleep(20 * time.Millisecond)
+ close(gate)
+ wg.Wait()
+
+ for i, ok := range results {
+ assert.Truef(t, ok, "worker %d should observe a successful response", i)
+ }
+ assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "single-flight must collapse concurrent cold fetches into one RPC")
+}
+
+func TestTunnelCache_PerAccountIsolation(t *testing.T) {
+ cache := newTunnelValidationCache()
+ keyA := newTestKey("acct-a", "100.64.0.10", "svc.example")
+ keyB := newTestKey("acct-b", "100.64.0.10", "svc.example")
+
+ var callsA, callsB int32
+ validateA := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&callsA, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-a", UserId: "user-a"}, nil
+ }
+ validateB := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&callsB, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-b", UserId: "user-b"}, nil
+ }
+
+ respA, _, err := cache.fetch(context.Background(), keyA, validateA)
+ require.NoError(t, err)
+ respB, _, err := cache.fetch(context.Background(), keyB, validateB)
+ require.NoError(t, err)
+
+ assert.Equal(t, "user-a", respA.GetUserId(), "account A response should belong to user-a")
+ assert.Equal(t, "user-b", respB.GetUserId(), "account B response must not be served from account A's cache")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&callsA), "validateA called exactly once")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&callsB), "validateB called exactly once")
+}
+
+func TestTunnelCache_BoundedSizeEvictsOldest(t *testing.T) {
+ cache := newTunnelValidationCache()
+ cache.maxSize = 2
+
+ validate := func(_ context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-" + req.GetTunnelIp()}, nil
+ }
+
+ keys := []tunnelCacheKey{
+ newTestKey("acct-1", "100.64.0.10", "svc"),
+ newTestKey("acct-1", "100.64.0.11", "svc"),
+ newTestKey("acct-1", "100.64.0.12", "svc"),
+ }
+ for _, k := range keys {
+ _, _, err := cache.fetch(context.Background(), k, validate)
+ require.NoError(t, err)
+ }
+
+ assert.Nil(t, cache.get(keys[0]), "oldest key should be evicted past maxSize")
+ assert.NotNil(t, cache.get(keys[1]), "second-newest must remain cached")
+ assert.NotNil(t, cache.get(keys[2]), "newest must remain cached")
+}
diff --git a/proxy/internal/auth/tunnel_lookup_test.go b/proxy/internal/auth/tunnel_lookup_test.go
new file mode 100644
index 000000000..cc8081af2
--- /dev/null
+++ b/proxy/internal/auth/tunnel_lookup_test.go
@@ -0,0 +1,325 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "sync/atomic"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+
+ "github.com/netbirdio/netbird/proxy/internal/proxy"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// stubSessionValidator records ValidateTunnelPeer calls and returns the
+// pre-canned response. Counts let tests assert RPC traffic.
+type stubSessionValidator struct {
+ respFn func(req *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse
+ respErr error
+ tunnelCalls atomic.Int32
+}
+
+func (s *stubSessionValidator) ValidateSession(_ context.Context, _ *proto.ValidateSessionRequest, _ ...grpc.CallOption) (*proto.ValidateSessionResponse, error) {
+ return &proto.ValidateSessionResponse{Valid: false}, nil
+}
+
+func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.ValidateTunnelPeerRequest, _ ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) {
+ s.tunnelCalls.Add(1)
+ if s.respErr != nil {
+ return nil, s.respErr
+ }
+ if s.respFn != nil {
+ return s.respFn(in), nil
+ }
+ return &proto.ValidateTunnelPeerResponse{Valid: false}, nil
+}
+
+func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
+ t.Helper()
+ mw := NewMiddleware(log.New(), validator, nil)
+ require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false))
+ return mw
+}
+
+func newTunnelRequest(remoteAddr string) (*httptest.ResponseRecorder, *http.Request) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil)
+ r.Host = "svc.example"
+ r.RemoteAddr = remoteAddr
+ return w, r
+}
+
+// TestForwardWithTunnelPeer_LocalLookupUnknownIPDeniesFast verifies the
+// short-circuit: a tunnel IP not in the account's roster never reaches
+// management's ValidateTunnelPeer.
+func TestForwardWithTunnelPeer_LocalLookupUnknownIPDeniesFast(t *testing.T) {
+ validator := &stubSessionValidator{}
+ mw := newTunnelMiddleware(t, validator)
+
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, false
+ })
+
+ w, r := newTunnelRequest("100.64.0.99:55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+
+ called := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+
+ assert.False(t, handled, "unknown peer must fall through, not forward")
+ assert.False(t, called, "next handler must not run for unknown peer")
+ assert.Equal(t, int32(0), validator.tunnelCalls.Load(), "ValidateTunnelPeer must be skipped on local-lookup miss")
+}
+
+// TestForwardWithTunnelPeer_GroupsPropagateToCapturedData verifies the proxy
+// surfaces the calling peer's group memberships from ValidateTunnelPeerResponse
+// onto CapturedData so policy-aware middlewares can authorise without an
+// extra management round-trip.
+func TestForwardWithTunnelPeer_GroupsPropagateToCapturedData(t *testing.T) {
+ groups := []string{"engineering", "sre"}
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "tok",
+ UserId: "user-1",
+ PeerGroupIds: groups,
+ }
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ w, r := newTunnelRequest("100.64.0.10:55555")
+ cd := proxy.NewCapturedData("")
+ r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
+
+ called := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+
+ require.True(t, handled, "valid tunnel-peer response must forward")
+ require.True(t, called, "next handler must run")
+ assert.Equal(t, "user-1", cd.GetUserID(), "user id must propagate from tunnel-peer response")
+ assert.Equal(t, groups, cd.GetUserGroups(), "peer group IDs must propagate from tunnel-peer response")
+}
+
+// TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs verifies that a
+// known tunnel IP still triggers ValidateTunnelPeer for the user-identity
+// tail (UserID + group access). Phase 3 only short-circuits the deny path.
+func TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ knownIP := netip.MustParseAddr("100.64.0.10")
+ lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) {
+ if ip == knownIP {
+ return PeerIdentity{PubKey: "pk", TunnelIP: ip, FQDN: "peer.netbird.cloud"}, true
+ }
+ return PeerIdentity{}, false
+ })
+
+ w, r := newTunnelRequest(knownIP.String() + ":55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+
+ called := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+
+ assert.True(t, handled, "known peer with valid RPC response must forward")
+ assert.True(t, called, "next handler must run on success")
+ assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC must run for the user-identity tail when local lookup confirms the peer")
+}
+
+// TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath ensures the existing
+// behaviour stays intact on the host-level listener (no lookup attached).
+func TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ w, r := newTunnelRequest("100.64.0.10:55555")
+ called := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+
+ assert.True(t, handled, "host-level path forwards on positive RPC result")
+ assert.True(t, called, "next handler runs on host-level success")
+ assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "host-level path always RPCs (Phase 3 unchanged)")
+}
+
+// TestForwardWithTunnelPeer_RPCErrorFallsThrough validates that an RPC
+// failure still falls through to the next scheme (no false positive).
+func TestForwardWithTunnelPeer_RPCErrorFallsThrough(t *testing.T) {
+ validator := &stubSessionValidator{respErr: errors.New("management down")}
+ mw := newTunnelMiddleware(t, validator)
+
+ knownIP := netip.MustParseAddr("100.64.0.10")
+ lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{TunnelIP: ip}, true
+ })
+
+ w, r := newTunnelRequest(knownIP.String() + ":55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+
+ assert.False(t, handled, "RPC error must let the caller try other schemes")
+ assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC was attempted exactly once")
+}
+
+// TestForwardWithTunnelPeer_CacheReusesPositiveResponse confirms the
+// (account, IP, domain) cache prevents repeated RPCs for the same peer.
+func TestForwardWithTunnelPeer_CacheReusesPositiveResponse(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ for i := 0; i < 4; i++ {
+ w, r := newTunnelRequest("100.64.0.10:55555")
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+ require.True(t, handled, "iteration %d should forward", i)
+ }
+
+ assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "subsequent forwards must hit the cache, not management")
+}
+
+// TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey ensures cache keys
+// honour account scoping โ same tunnel IP on different accounts must not
+// collide.
+func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(req *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user"}
+ },
+ }
+ mw := NewMiddleware(log.New(), validator, nil)
+
+ require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
+ require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
+
+ for _, host := range []string{"svc-a.example", "svc-b.example"} {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://"+host+"/", nil)
+ r.Host = host
+ r.RemoteAddr = "100.64.0.10:55555"
+ config, _ := mw.getDomainConfig(host)
+ handled := mw.forwardWithTunnelPeer(w, r, host, config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ require.True(t, handled, "host %s should forward", host)
+ }
+
+ assert.Equal(t, int32(2), validator.tunnelCalls.Load(), "cache must not collide across accounts even when tunnel IPs match")
+}
+
+// TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache
+// guarantees that the deny-fast path leaves the cache untouched, so a
+// subsequent request from the same IP after the peerstore catches up
+// goes through the normal RPC flow.
+func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ knownIP := netip.MustParseAddr("100.64.0.10")
+ known := false
+ lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) {
+ if known && ip == knownIP {
+ return PeerIdentity{TunnelIP: ip}, true
+ }
+ return PeerIdentity{}, false
+ })
+
+ doRequest := func() bool {
+ w, r := newTunnelRequest(knownIP.String() + ":55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+ config, _ := mw.getDomainConfig("svc.example")
+ return mw.forwardWithTunnelPeer(w, r, "svc.example", config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ }
+
+ require.False(t, doRequest(), "first request must short-circuit")
+ require.Equal(t, int32(0), validator.tunnelCalls.Load(), "short-circuit must not populate the cache")
+
+ known = true
+ require.True(t, doRequest(), "second request with peer in roster must forward via RPC")
+ assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC runs once after peerstore catches up")
+}
+
+func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
+ mw := NewMiddleware(log.New(), nil, nil)
+ require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
+
+ called := false
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.10:55555"
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.False(t, called)
+}
+
+func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "tok",
+ UserId: "user-1",
+ }
+ },
+ }
+ mw := NewMiddleware(log.New(), validator, nil)
+ require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
+
+ called := false
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.10:55555"
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.True(t, called)
+}
diff --git a/proxy/internal/debug/client.go b/proxy/internal/debug/client.go
index 09c25afb2..736781652 100644
--- a/proxy/internal/debug/client.go
+++ b/proxy/internal/debug/client.go
@@ -11,7 +11,6 @@ import (
"net/url"
"strings"
"time"
-
)
// StatusFilters contains filter options for status queries.
@@ -160,6 +159,49 @@ func (c *Client) printClients(data map[string]any) {
for _, item := range clients {
c.printClientRow(item)
}
+
+ c.printInboundListeners(clients)
+}
+
+func (c *Client) printInboundListeners(clients []any) {
+ type row struct {
+ accountID string
+ tunnelIP string
+ httpsPort int
+ httpPort int
+ }
+ var rows []row
+ for _, item := range clients {
+ client, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ inbound, ok := client["inbound_listener"].(map[string]any)
+ if !ok {
+ continue
+ }
+ tunnelIP, _ := inbound["tunnel_ip"].(string)
+ httpsPort, _ := inbound["https_port"].(float64)
+ httpPort, _ := inbound["http_port"].(float64)
+ accountID, _ := client["account_id"].(string)
+ rows = append(rows, row{
+ accountID: accountID,
+ tunnelIP: tunnelIP,
+ httpsPort: int(httpsPort),
+ httpPort: int(httpPort),
+ })
+ }
+ if len(rows) == 0 {
+ return
+ }
+
+ _, _ = fmt.Fprintln(c.out)
+ _, _ = fmt.Fprintln(c.out, "Inbound listeners (per-account):")
+ _, _ = fmt.Fprintf(c.out, " %-38s %-20s %-7s %s\n", "ACCOUNT ID", "TUNNEL IP", "HTTPS", "HTTP")
+ _, _ = fmt.Fprintln(c.out, " "+strings.Repeat("-", 78))
+ for _, r := range rows {
+ _, _ = fmt.Fprintf(c.out, " %-38s %-20s %-7d %d\n", r.accountID, r.tunnelIP, r.httpsPort, r.httpPort)
+ }
}
func (c *Client) printClientRow(item any) {
@@ -219,7 +261,14 @@ func (c *Client) ClientStatus(ctx context.Context, accountID string, filters Sta
}
func (c *Client) printClientStatus(data map[string]any) {
- _, _ = fmt.Fprintf(c.out, "Account: %v\n\n", data["account_id"])
+ _, _ = fmt.Fprintf(c.out, "Account: %v\n", data["account_id"])
+ if inbound, ok := data["inbound_listener"].(map[string]any); ok {
+ tunnelIP, _ := inbound["tunnel_ip"].(string)
+ httpsPort, _ := inbound["https_port"].(float64)
+ httpPort, _ := inbound["http_port"].(float64)
+ _, _ = fmt.Fprintf(c.out, "Inbound listener: %s (https=%d, http=%d)\n", tunnelIP, int(httpsPort), int(httpPort))
+ }
+ _, _ = fmt.Fprintln(c.out)
if status, ok := data["status"].(string); ok {
_, _ = fmt.Fprint(c.out, status)
}
diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go
index 23ca4adbb..1dbfe1522 100644
--- a/proxy/internal/debug/handler.go
+++ b/proxy/internal/debug/handler.go
@@ -61,6 +61,23 @@ type clientProvider interface {
ListClientsForDebug() map[types.AccountID]roundtrip.ClientDebugInfo
}
+// InboundListenerInfo describes a per-account inbound listener as
+// surfaced through the debug HTTP handler. Mirrors the proto sub-message
+// emitted with SendStatusUpdate so dashboards and CLI tooling see the
+// same shape.
+type InboundListenerInfo struct {
+ TunnelIP string `json:"tunnel_ip"`
+ HTTPSPort uint16 `json:"https_port"`
+ HTTPPort uint16 `json:"http_port"`
+}
+
+// InboundProvider exposes per-account inbound listener state. Optional;
+// when nil the debug endpoint omits the inbound section entirely so the
+// existing JSON shape stays additive.
+type InboundProvider interface {
+ InboundListeners() map[types.AccountID]InboundListenerInfo
+}
+
// healthChecker provides health probe state.
type healthChecker interface {
ReadinessProbe() bool
@@ -80,6 +97,7 @@ type Handler struct {
provider clientProvider
health healthChecker
certStatus certStatus
+ inbound InboundProvider
logger *log.Logger
startTime time.Time
templates *template.Template
@@ -108,6 +126,13 @@ func (h *Handler) SetCertStatus(cs certStatus) {
h.certStatus = cs
}
+// SetInboundProvider wires per-account inbound listener observability.
+// Pass nil (or skip the call) to keep the inbound section out of debug
+// responses on proxies that don't run --private-inbound.
+func (h *Handler) SetInboundProvider(p InboundProvider) {
+ h.inbound = p
+}
+
func (h *Handler) loadTemplates() error {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
@@ -323,23 +348,35 @@ func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, want
sortedIDs := sortedAccountIDs(clients)
if wantJSON {
+ var inboundAll map[types.AccountID]InboundListenerInfo
+ if h.inbound != nil {
+ inboundAll = h.inbound.InboundListeners()
+ }
clientsJSON := make([]map[string]interface{}, 0, len(clients))
for _, id := range sortedIDs {
info := clients[id]
- clientsJSON = append(clientsJSON, map[string]interface{}{
+ row := map[string]interface{}{
"account_id": info.AccountID,
"service_count": info.ServiceCount,
"service_keys": info.ServiceKeys,
"has_client": info.HasClient,
"created_at": info.CreatedAt,
"age": time.Since(info.CreatedAt).Round(time.Second).String(),
- })
+ }
+ if inb, ok := inboundAll[id]; ok {
+ row["inbound_listener"] = inb
+ }
+ clientsJSON = append(clientsJSON, row)
}
- h.writeJSON(w, map[string]interface{}{
+ resp := map[string]interface{}{
"uptime": time.Since(h.startTime).Round(time.Second).String(),
"client_count": len(clients),
"clients": clientsJSON,
- })
+ }
+ if len(inboundAll) > 0 {
+ resp["inbound_listener_count"] = len(inboundAll)
+ }
+ h.writeJSON(w, resp)
return
}
@@ -421,10 +458,14 @@ func (h *Handler) handleClientStatus(w http.ResponseWriter, r *http.Request, acc
})
if wantJSON {
- h.writeJSON(w, map[string]interface{}{
+ resp := map[string]interface{}{
"account_id": accountID,
"status": overview.FullDetailSummary(),
- })
+ }
+ if info, ok := h.inboundInfoFor(accountID); ok {
+ resp["inbound_listener"] = info
+ }
+ h.writeJSON(w, resp)
return
}
@@ -437,6 +478,18 @@ func (h *Handler) handleClientStatus(w http.ResponseWriter, r *http.Request, acc
h.renderTemplate(w, "clientDetail", data)
}
+// inboundInfoFor returns the inbound listener info for an account, or
+// ok=false when no inbound provider is wired or the account has no live
+// listener.
+func (h *Handler) inboundInfoFor(accountID types.AccountID) (InboundListenerInfo, bool) {
+ if h.inbound == nil {
+ return InboundListenerInfo{}, false
+ }
+ all := h.inbound.InboundListeners()
+ info, ok := all[accountID]
+ return info, ok
+}
+
func (h *Handler) handleClientSyncResponse(w http.ResponseWriter, _ *http.Request, accountID types.AccountID, wantJSON bool) {
client, ok := h.provider.GetClient(accountID)
if !ok {
diff --git a/proxy/internal/proxy/context.go b/proxy/internal/proxy/context.go
index a888ad9ed..e05ec78aa 100644
--- a/proxy/internal/proxy/context.go
+++ b/proxy/internal/proxy/context.go
@@ -52,8 +52,15 @@ type CapturedData struct {
origin ResponseOrigin
clientIP netip.Addr
userID string
- authMethod string
- metadata map[string]string
+ userEmail string
+ userGroups []string
+ // userGroupNames pairs positionally with userGroups; populated from
+ // the JWT's group_names claim or from ValidateSession/Tunnel
+ // responses. Slice may be shorter than userGroups for tokens minted
+ // before names were resolvable.
+ userGroupNames []string
+ authMethod string
+ metadata map[string]string
}
// NewCapturedData creates a CapturedData with the given request ID.
@@ -138,6 +145,81 @@ func (c *CapturedData) GetUserID() string {
return c.userID
}
+// SetUserEmail records the authenticated user's email address. Used by
+// policy-aware middlewares to stamp identity onto upstream requests
+// (e.g. x-litellm-end-user-id) without a management round-trip.
+func (c *CapturedData) SetUserEmail(email string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.userEmail = email
+}
+
+// GetUserEmail returns the authenticated user's email address. Returns
+// the empty string when the auth path didn't carry an email (e.g.
+// non-OIDC schemes or legacy JWTs minted before the email claim).
+func (c *CapturedData) GetUserEmail() string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.userEmail
+}
+
+// SetUserGroups records the authenticated user's group memberships so
+// downstream policy-aware middlewares can authorise the request without
+// an additional management round-trip. The auth middleware populates this
+// from ValidateSessionResponse / ValidateTunnelPeerResponse and from the
+// session JWT's groups claim on cookie-bearing requests.
+func (c *CapturedData) SetUserGroups(groups []string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if len(groups) == 0 {
+ c.userGroups = nil
+ return
+ }
+ c.userGroups = append(c.userGroups[:0], groups...)
+}
+
+// GetUserGroups returns a copy of the authenticated user's group
+// memberships.
+func (c *CapturedData) GetUserGroups() []string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if len(c.userGroups) == 0 {
+ return nil
+ }
+ out := make([]string, len(c.userGroups))
+ copy(out, c.userGroups)
+ return out
+}
+
+// SetUserGroupNames records the human-readable display names for the
+// user's groups, ordered identically to UserGroups (positional
+// pairing). Stamped onto upstream requests as X-NetBird-Groups so
+// downstream services can read names rather than opaque ids.
+func (c *CapturedData) SetUserGroupNames(names []string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if len(names) == 0 {
+ c.userGroupNames = nil
+ return
+ }
+ c.userGroupNames = append(c.userGroupNames[:0], names...)
+}
+
+// GetUserGroupNames returns a copy of the authenticated user's group
+// display names. Position i pairs with UserGroups[i]. May be shorter
+// than UserGroups for tokens minted before names were resolvable; the
+// consumer should fall back to ids for missing positions.
+func (c *CapturedData) GetUserGroupNames() []string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if len(c.userGroupNames) == 0 {
+ return nil
+ }
+ out := make([]string, len(c.userGroupNames))
+ copy(out, c.userGroupNames)
+ return out
+}
+
// SetAuthMethod sets the authentication method used.
func (c *CapturedData) SetAuthMethod(method string) {
c.mu.Lock()
diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go
index 246851d24..e437e78a7 100644
--- a/proxy/internal/proxy/reverseproxy.go
+++ b/proxy/internal/proxy/reverseproxy.go
@@ -86,6 +86,9 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if pt.RequestTimeout > 0 {
ctx = types.WithDialTimeout(ctx, pt.RequestTimeout)
}
+ if pt.DirectUpstream {
+ ctx = roundtrip.WithDirectUpstream(ctx)
+ }
rewriteMatchedPath := result.matchedPath
if pt.PathRewrite == PathRewritePreserve {
@@ -142,6 +145,8 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost
r.Out.Header.Set(k, v)
}
+ stampNetBirdIdentity(r)
+
clientIP := extractHostIP(r.In.RemoteAddr)
if isTrustedAddr(clientIP, p.trustedProxies) {
@@ -426,3 +431,70 @@ func opErrorContains(err error, substr string) bool {
}
return false
}
+
+const (
+ // headerNetBirdUser carries the authenticated user's display identity
+ // (email when the peer is attached to a user, else peer name) onto
+ // upstream requests. Stripped from inbound requests before stamping
+ // so a client can't spoof identity by setting the header themselves.
+ headerNetBirdUser = "X-NetBird-User"
+ // headerNetBirdGroups carries the user's group display names as a
+ // comma-separated list. Falls back to group IDs at positions where a
+ // name wasn't available at session-mint time. Labels containing a
+ // comma or any non-printable byte are dropped at stamp time so the
+ // list is unambiguously splittable by consumers.
+ headerNetBirdGroups = "X-NetBird-Groups"
+)
+
+// isHeaderValueSafe reports whether v is a valid RFC 7230 field-value:
+// VCHAR (0x21-0x7E), SP (0x20), or HTAB (0x09). Empty values are
+// rejected; the caller decides whether to omit the header entirely.
+func isHeaderValueSafe(v string) bool {
+ if v == "" {
+ return false
+ }
+ for i := 0; i < len(v); i++ {
+ c := v[i]
+ if c == '\t' || (c >= 0x20 && c <= 0x7E) {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+// stampNetBirdIdentity injects authenticated identity onto outbound
+// requests as X-NetBird-User and X-NetBird-Groups. Always strips any
+// client-sent values first (anti-spoof). Skips when the request didn't
+// carry CapturedData (early-path errors, internal endpoints).
+func stampNetBirdIdentity(r *httputil.ProxyRequest) {
+ r.Out.Header.Del(headerNetBirdUser)
+ r.Out.Header.Del(headerNetBirdGroups)
+
+ cd := CapturedDataFromContext(r.In.Context())
+ if cd == nil {
+ return
+ }
+ if email := cd.GetUserEmail(); isHeaderValueSafe(email) {
+ r.Out.Header.Set(headerNetBirdUser, email)
+ }
+ groupIDs := cd.GetUserGroups()
+ if len(groupIDs) == 0 {
+ return
+ }
+ groupNames := cd.GetUserGroupNames()
+ labels := make([]string, 0, len(groupIDs))
+ for i, id := range groupIDs {
+ label := id
+ if i < len(groupNames) && groupNames[i] != "" {
+ label = groupNames[i]
+ }
+ if !isHeaderValueSafe(label) || strings.ContainsRune(label, ',') {
+ continue
+ }
+ labels = append(labels, label)
+ }
+ if len(labels) > 0 {
+ r.Out.Header.Set(headerNetBirdGroups, strings.Join(labels, ","))
+ }
+}
diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go
index c53307837..d5158a6cc 100644
--- a/proxy/internal/proxy/reverseproxy_test.go
+++ b/proxy/internal/proxy/reverseproxy_test.go
@@ -1067,3 +1067,245 @@ func TestClassifyProxyError(t *testing.T) {
})
}
}
+
+func TestStampNetBirdIdentity_NoCapturedData_StripsOnly(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.In.Header.Set(headerNetBirdGroups, "admin")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ rewrite(pr)
+
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
+ "client-supplied X-NetBird-User must be stripped when no captured identity is present")
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups),
+ "client-supplied X-NetBird-Groups must be stripped when no captured identity is present")
+}
+
+func TestStampNetBirdIdentity_StampsFromCapturedData(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io")
+ cd.SetUserGroups([]string{"grp-eng", "grp-ops"})
+ cd.SetUserGroupNames([]string{"engineering", "operations"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "alice@netbird.io", pr.Out.Header.Get(headerNetBirdUser),
+ "captured email must overwrite any spoofed value")
+ assert.Equal(t, "engineering,operations", pr.Out.Header.Get(headerNetBirdGroups),
+ "group display names must be CSV-joined in positional order")
+}
+
+// TestStampNetBirdIdentity_GroupsOnlyWhenEmailEmpty covers the
+// tunnel-peer-without-user case (machine agents, unattached proxy peers).
+// The proxy must still stamp the peer's groups so downstream services can
+// authorise, but X-NetBird-User stays unset โ only its inbound stripping
+// must happen.
+func TestStampNetBirdIdentity_GroupsOnlyWhenEmailEmpty(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserGroups([]string{"grp-machines"})
+ cd.SetUserGroupNames([]string{"machines"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
+ "X-NetBird-User must remain unset when CapturedData carries no email")
+ assert.Equal(t, "machines", pr.Out.Header.Get(headerNetBirdGroups),
+ "groups must still be stamped for peers without a user identity")
+}
+
+// TestStampNetBirdIdentity_EmailOnlyWhenGroupsEmpty covers the symmetric
+// case: identity-resolved user without resolved group memberships.
+func TestStampNetBirdIdentity_EmailOnlyWhenGroupsEmpty(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("carol@netbird.io")
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "carol@netbird.io", pr.Out.Header.Get(headerNetBirdUser),
+ "email must be stamped even when no groups are captured")
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups),
+ "X-NetBird-Groups must remain unset when CapturedData carries no groups")
+}
+
+func TestStampNetBirdIdentity_FallsBackToGroupIDsWhenNameMissing(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("bob@netbird.io")
+ cd.SetUserGroups([]string{"grp-a", "grp-b", "grp-c"})
+ // "grp-b" gets an explicit empty-string display name (not just a
+ // shorter slice). Both gap shapes must fall back to the id.
+ cd.SetUserGroupNames([]string{"alpha", "", ""})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "alpha,grp-b,grp-c", pr.Out.Header.Get(headerNetBirdGroups),
+ "empty-string and out-of-range name slots must both fall back to the group id")
+}
+
+// TestStampNetBirdIdentity_DropsLabelsWithComma covers the
+// comma-separator constraint: a group display name that itself contains
+// a comma is dropped from the header (rather than corrupting the list),
+// and the remaining labels are stamped.
+func TestStampNetBirdIdentity_DropsLabelsWithComma(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io")
+ cd.SetUserGroups([]string{"grp-a", "grp-b", "grp-c"})
+ cd.SetUserGroupNames([]string{"engineering", "EU, EMEA", "operations"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "engineering,operations", pr.Out.Header.Get(headerNetBirdGroups),
+ "group label with embedded comma must be dropped, remaining labels stamped")
+}
+
+// TestStampNetBirdIdentity_RejectsControlCharsInEmail covers the
+// header-injection defence: an email value containing CR/LF/control
+// chars is omitted entirely (not partially stamped) so the upstream
+// request stays well-formed and no header injection is possible.
+func TestStampNetBirdIdentity_RejectsControlCharsInEmail(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io\r\nX-Admin: yes")
+ cd.SetUserGroups([]string{"grp-a"})
+ cd.SetUserGroupNames([]string{"engineering"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
+ "email with CR/LF must be dropped, not partially stamped")
+ assert.Equal(t, "engineering", pr.Out.Header.Get(headerNetBirdGroups),
+ "groups remain stampable even when email is invalid")
+}
+
+// TestStampNetBirdIdentity_RejectsControlCharsInGroup covers the
+// per-label defence: a group name with a control char is silently
+// dropped, the rest are stamped.
+func TestStampNetBirdIdentity_RejectsControlCharsInGroup(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io")
+ cd.SetUserGroups([]string{"grp-a", "grp-b"})
+ cd.SetUserGroupNames([]string{"engineering\r\nsneaky", "operations"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "operations", pr.Out.Header.Get(headerNetBirdGroups),
+ "group label with control char must be dropped, valid ones kept")
+}
+
+// TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid covers the
+// edge case where every group label is rejected: the header must not be
+// set at all (rather than set to an empty string).
+func TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io")
+ cd.SetUserGroups([]string{"grp-a", "grp-b"})
+ cd.SetUserGroupNames([]string{"with,comma", "with\nbreak"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ _, present := pr.Out.Header[http.CanonicalHeaderKey(headerNetBirdGroups)]
+ assert.False(t, present,
+ "X-NetBird-Groups must not be set when every group label is rejected")
+}
+
+// TestStampNetBirdIdentity_CapturedDataPresentButEmpty covers requests
+// that carry CapturedData with no identity fields populated (e.g. the
+// auth middleware ran but the request didn't authenticate). Both
+// headers must be cleared and neither stamped.
+func TestStampNetBirdIdentity_CapturedDataPresentButEmpty(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
+ "X-NetBird-User must be stripped when CapturedData has no email")
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups),
+ "X-NetBird-Groups must be stripped when CapturedData has no groups")
+}
diff --git a/proxy/internal/proxy/servicemapping.go b/proxy/internal/proxy/servicemapping.go
index fe470cf01..46b4d2e8d 100644
--- a/proxy/internal/proxy/servicemapping.go
+++ b/proxy/internal/proxy/servicemapping.go
@@ -28,6 +28,10 @@ type PathTarget struct {
RequestTimeout time.Duration
PathRewrite PathRewriteMode
CustomHeaders map[string]string
+ // DirectUpstream selects the stdlib HTTP transport (host network stack)
+ // over the embedded NetBird WireGuard client when forwarding requests
+ // to this target. Default false โ embedded client (existing behaviour).
+ DirectUpstream bool
}
// Mapping describes how a domain is routed by the HTTP reverse proxy.
diff --git a/proxy/internal/restrict/restrict.go b/proxy/internal/restrict/restrict.go
index f3e0fa695..67756b88b 100644
--- a/proxy/internal/restrict/restrict.go
+++ b/proxy/internal/restrict/restrict.go
@@ -191,6 +191,18 @@ func (f *Filter) IsObserveOnly(v Verdict) bool {
return v.IsCrowdSec() && f.CrowdSecMode == CrowdSecObserve
}
+// CheckCIDR runs only the CIDR allow/block evaluation. Use this when
+// country and CrowdSec checks don't apply โ e.g. requests arriving
+// from the WireGuard overlay, whose source addresses live in the
+// CGNAT range and have no meaningful geolocation or IP-reputation
+// data.
+func (f *Filter) CheckCIDR(addr netip.Addr) Verdict {
+ if f == nil {
+ return Allow
+ }
+ return f.checkCIDR(addr.Unmap())
+}
+
// Check evaluates whether addr is permitted. CIDR rules are evaluated
// first because they are O(n) prefix comparisons. Country rules run
// only when CIDR checks pass and require a geo lookup. CrowdSec checks
diff --git a/proxy/internal/restrict/restrict_test.go b/proxy/internal/restrict/restrict_test.go
index abaa1afdc..16aa1e139 100644
--- a/proxy/internal/restrict/restrict_test.go
+++ b/proxy/internal/restrict/restrict_test.go
@@ -514,6 +514,34 @@ func TestFilter_CrowdSec_Observe_NilChecker(t *testing.T) {
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
}
+func TestFilter_CheckCIDR_AllowsWithoutCountryOrCrowdSec(t *testing.T) {
+ cs := &mockCrowdSec{ready: true, decisions: map[string]*CrowdSecDecision{
+ "100.64.5.6": {Type: DecisionBan},
+ }}
+ f := ParseFilter(FilterConfig{
+ AllowedCIDRs: []string{"100.64.0.0/10"},
+ AllowedCountries: []string{"US"},
+ CrowdSec: cs,
+ CrowdSecMode: CrowdSecEnforce,
+ })
+
+ // CheckCIDR skips country + CrowdSec evaluation: an address inside
+ // the allowed CIDR passes even when it would be denied by CrowdSec
+ // or by the country allowlist (CGNAT addresses have no geo data).
+ assert.Equal(t, Allow, f.CheckCIDR(netip.MustParseAddr("100.64.5.6")),
+ "CheckCIDR must not run CrowdSec lookups on overlay traffic")
+
+ // CIDR denials still fire.
+ assert.Equal(t, DenyCIDR, f.CheckCIDR(netip.MustParseAddr("198.51.100.1")),
+ "CheckCIDR must still reject addresses outside the allow list")
+}
+
+func TestFilter_CheckCIDR_NilFilter(t *testing.T) {
+ var f *Filter
+ assert.Equal(t, Allow, f.CheckCIDR(netip.MustParseAddr("100.64.5.6")),
+ "CheckCIDR on a nil filter must allow")
+}
+
func TestFilter_HasRestrictions_CrowdSec(t *testing.T) {
cs := &mockCrowdSec{ready: true}
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce})
diff --git a/proxy/internal/roundtrip/multi.go b/proxy/internal/roundtrip/multi.go
new file mode 100644
index 000000000..567249437
--- /dev/null
+++ b/proxy/internal/roundtrip/multi.go
@@ -0,0 +1,112 @@
+package roundtrip
+
+import (
+ "crypto/tls"
+ "errors"
+ "net"
+ "net/http"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// MultiTransport dispatches each request to either the embedded NetBird
+// http.RoundTripper or a stdlib http.Transport based on a per-request
+// context flag set by the reverse-proxy rewrite step. When the flag is
+// absent (the default for every existing target), requests follow the
+// embedded NetBird path โ current behaviour, preserved.
+//
+// The stdlib branch is used when a target was configured with
+// direct_upstream=true. It dials via the host's network stack, which is
+// what private (`netbird proxy`) deployments and centralised proxies
+// fronting host-reachable upstreams (public APIs, LAN services,
+// localhost sidecars) want.
+//
+// An embedded roundtripper is required. To run direct-only (no WG
+// branch at all), construct the MultiTransport via NewDirectOnly.
+type MultiTransport struct {
+ embedded http.RoundTripper
+ direct *http.Transport
+ insecure *http.Transport
+}
+
+// errNoEmbeddedTransport is returned when a request reaches the
+// embedded branch on a MultiTransport that wasn't given one. Surfaces
+// the misconfiguration to the caller instead of silently routing to
+// the direct branch, which would bypass the WG tunnel.
+var errNoEmbeddedTransport = errors.New("multitransport: embedded roundtripper not configured")
+
+// NewMultiTransport wires both branches. embedded is the existing NetBird
+// roundtripper and must not be nil โ pass to NewDirectOnly for a
+// MultiTransport that only ever uses the direct branch. The direct
+// branches honour the same NB_PROXY_* tuning env vars as the embedded
+// transport (see loadTransportConfig) plus a dial-timeout wrapper that
+// respects types.WithDialTimeout.
+func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTransport {
+ if logger == nil {
+ logger = log.StandardLogger()
+ }
+ cfg := loadTransportConfig(logger)
+ dialer := &net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+ direct := &http.Transport{
+ DialContext: dialWithTimeout(dialer.DialContext),
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: cfg.maxIdleConns,
+ MaxIdleConnsPerHost: cfg.maxIdleConnsPerHost,
+ MaxConnsPerHost: cfg.maxConnsPerHost,
+ IdleConnTimeout: cfg.idleConnTimeout,
+ TLSHandshakeTimeout: cfg.tlsHandshakeTimeout,
+ ExpectContinueTimeout: cfg.expectContinueTimeout,
+ ResponseHeaderTimeout: cfg.responseHeaderTimeout,
+ WriteBufferSize: cfg.writeBufferSize,
+ ReadBufferSize: cfg.readBufferSize,
+ DisableCompression: cfg.disableCompression,
+ }
+ insecure := direct.Clone()
+ insecure.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // matches the embedded NetBird transport's per-target opt-in
+
+ return &MultiTransport{
+ embedded: embedded,
+ direct: direct,
+ insecure: insecure,
+ }
+}
+
+// NewDirectOnly returns a MultiTransport with no embedded branch.
+// Every request goes through the direct branch regardless of the
+// per-request flag, so the embedded path can never be reached
+// silently โ wiring code that needs WG must use NewMultiTransport.
+func NewDirectOnly(logger *log.Logger) *MultiTransport {
+ return NewMultiTransport(noEmbeddedRoundTripper{}, logger)
+}
+
+// noEmbeddedRoundTripper is the sentinel embedded transport for
+// direct-only MultiTransports. RoundTrip is never called in practice
+// because the direct branch matches every request, but if anything
+// ever did reach this path it would fail loudly instead of falling
+// back to direct.
+type noEmbeddedRoundTripper struct{}
+
+func (noEmbeddedRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
+ return nil, errNoEmbeddedTransport
+}
+
+// RoundTrip dispatches by reading the direct-upstream flag from the request
+// context. When set, the request is forwarded via the stdlib transport,
+// honouring the existing per-request skip-TLS-verify flag. Otherwise it
+// goes through the embedded NetBird roundtripper.
+func (m *MultiTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ if DirectUpstreamFromContext(req.Context()) {
+ if skipTLSVerifyFromContext(req.Context()) {
+ return m.insecure.RoundTrip(req)
+ }
+ return m.direct.RoundTrip(req)
+ }
+ if m.embedded == nil {
+ return nil, errNoEmbeddedTransport
+ }
+ return m.embedded.RoundTrip(req)
+}
diff --git a/proxy/internal/roundtrip/multi_test.go b/proxy/internal/roundtrip/multi_test.go
new file mode 100644
index 000000000..5c6cf1c97
--- /dev/null
+++ b/proxy/internal/roundtrip/multi_test.go
@@ -0,0 +1,134 @@
+package roundtrip
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// stubRoundTripper records whether RoundTrip was called and returns a
+// canned response so tests can assert the dispatch decision without
+// running a real network.
+type stubRoundTripper struct {
+ called bool
+ body string
+}
+
+func (s *stubRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) {
+ s.called = true
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(s.body)),
+ Header: http.Header{},
+ }, nil
+}
+
+func TestMultiTransport_DispatchesByContextFlag(t *testing.T) {
+ embedded := &stubRoundTripper{body: "embedded"}
+ mt := NewMultiTransport(embedded, nil)
+
+ t.Run("default routes to embedded", func(t *testing.T) {
+ embedded.called = false
+ req := httptest.NewRequest(http.MethodGet, "http://example.invalid", nil)
+ resp, err := mt.RoundTrip(req)
+ require.NoError(t, err, "embedded path must not error on stubbed transport")
+ require.NotNil(t, resp)
+ _ = resp.Body.Close()
+ assert.True(t, embedded.called, "request without WithDirectUpstream must hit the embedded transport")
+ })
+
+ t.Run("WithDirectUpstream skips embedded", func(t *testing.T) {
+ embedded.called = false
+ // Hit a server we control to verify the stdlib transport is used.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "direct")
+ }))
+ defer srv.Close()
+
+ req, err := http.NewRequestWithContext(WithDirectUpstream(context.Background()), http.MethodGet, srv.URL, nil)
+ require.NoError(t, err)
+ resp, err := mt.RoundTrip(req)
+ require.NoError(t, err, "direct path must dial via stdlib transport")
+ body, err := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ require.NoError(t, err)
+ assert.Equal(t, "direct", string(body), "stdlib transport must reach the test server")
+ assert.False(t, embedded.called, "WithDirectUpstream must bypass the embedded transport")
+ })
+}
+
+// TestMultiTransport_AppliesEnvOverridesToDirect verifies that the
+// NB_PROXY_* env vars consumed by loadTransportConfig flow into the
+// direct branches (previously they only applied to the embedded
+// roundtripper, so direct-upstream traffic ignored operator tuning).
+func TestMultiTransport_AppliesEnvOverridesToDirect(t *testing.T) {
+ t.Setenv(EnvMaxIdleConns, "42")
+ t.Setenv(EnvIdleConnTimeout, "11s")
+ t.Setenv(EnvTLSHandshakeTimeout, "7s")
+
+ mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil)
+
+ assert.Equal(t, 42, mt.direct.MaxIdleConns,
+ "NB_PROXY_MAX_IDLE_CONNS must propagate to the direct transport")
+ assert.Equal(t, 11*time.Second, mt.direct.IdleConnTimeout,
+ "NB_PROXY_IDLE_CONN_TIMEOUT must propagate to the direct transport")
+ assert.Equal(t, 7*time.Second, mt.direct.TLSHandshakeTimeout,
+ "NB_PROXY_TLS_HANDSHAKE_TIMEOUT must propagate to the direct transport")
+ assert.Equal(t, 42, mt.insecure.MaxIdleConns,
+ "env tuning must also apply to the insecure-skip-verify direct transport")
+}
+
+// TestMultiTransport_NilEmbeddedErrorsWhenWGPathRequested guards
+// against the previous silent fallback: a MultiTransport constructed
+// without an embedded transport must reject requests that don't
+// explicitly opt into the direct branch, rather than routing them
+// over the host stack and bypassing WireGuard.
+func TestMultiTransport_NilEmbeddedErrorsWhenWGPathRequested(t *testing.T) {
+ mt := NewMultiTransport(nil, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.invalid", nil)
+ resp, err := mt.RoundTrip(req)
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+ require.Error(t, err, "nil embedded must surface as an explicit error, not a silent direct dispatch")
+ assert.Nil(t, resp)
+ assert.ErrorIs(t, err, errNoEmbeddedTransport,
+ "the error must be the sentinel so callers can distinguish misconfiguration from network failures")
+}
+
+// TestMultiTransport_DirectOnlyServesDirectBranch verifies NewDirectOnly
+// constructs a MultiTransport whose direct branch handles requests with
+// the direct-upstream flag set, and surfaces the explicit sentinel
+// when the embedded path is reached.
+func TestMultiTransport_DirectOnlyServesDirectBranch(t *testing.T) {
+ mt := NewDirectOnly(nil)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "ok")
+ }))
+ defer srv.Close()
+
+ req, err := http.NewRequestWithContext(WithDirectUpstream(context.Background()), http.MethodGet, srv.URL, nil)
+ require.NoError(t, err)
+ resp, err := mt.RoundTrip(req)
+ require.NoError(t, err, "direct-only must serve requests that opt into the direct branch")
+ _ = resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ wgReq := httptest.NewRequest(http.MethodGet, "http://example.invalid", nil)
+ resp, err = mt.RoundTrip(wgReq)
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+ require.Error(t, err, "direct-only must refuse requests that didn't opt into the direct branch")
+ assert.Nil(t, resp)
+ assert.ErrorIs(t, err, errNoEmbeddedTransport)
+}
diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go
index 28eba7810..133e86f05 100644
--- a/proxy/internal/roundtrip/netbird.go
+++ b/proxy/internal/roundtrip/netbird.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
+ "net/netip"
"sync"
"time"
@@ -76,11 +77,11 @@ type clientEntry struct {
services map[ServiceKey]serviceInfo
createdAt time.Time
started bool
- // ready is closed once the client has been fully initialized.
- // Callers that find a pending entry wait on this channel before
- // accessing the client. A nil initErr means success.
- ready chan struct{}
- initErr error
+ // inbound is opaque per-account state owned by the NetBird parent's
+ // ReadyHandler. The roundtrip package never inspects this value; it
+ // only stores it so RemovePeer / StopAll can hand it back to the
+ // matching StopHandler. Nil when no inbound integration is active.
+ inbound any
// Per-backend in-flight limiting keyed by target host:port.
// TODO: clean up stale entries when backend targets change.
inflightMu sync.Mutex
@@ -88,6 +89,19 @@ type clientEntry struct {
maxInflight int
}
+// IdentityForIP resolves a tunnel IP to the peer identity locally known by
+// this account's embedded client. Returns (pubKey, fqdn) on success.
+// ok=false means the IP is not in the account's roster โ callers can use
+// that as a fast deny without round-tripping management. The returned
+// strings carry only what the embedded peerstore exposes; user identity
+// (UserID / Email / Groups) still flows through ValidateTunnelPeer.
+func (e *clientEntry) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
+ if e == nil || e.client == nil || !ip.IsValid() {
+ return "", "", false
+ }
+ return e.client.IdentityForIP(ip)
+}
+
// acquireInflight attempts to acquire an in-flight slot for the given backend.
// It returns a release function that must always be called, and true on success.
func (e *clientEntry) acquireInflight(backend backendKey) (release func(), ok bool) {
@@ -117,6 +131,12 @@ type ClientConfig struct {
MgmtAddr string
WGPort uint16
PreSharedKey string
+ // BlockInbound mirrors embed.Options.BlockInbound. Set to true on the
+ // standalone proxy where the embedded client never accepts inbound;
+ // set to false on the private/embedded proxy so the engine creates
+ // the ACL manager and applies management's per-policy firewall rules
+ // (which is what gates per-account inbound listeners on the netstack).
+ BlockInbound bool
}
type statusNotifier interface {
@@ -142,6 +162,14 @@ type NetBird struct {
clients map[types.AccountID]*clientEntry
initLogOnce sync.Once
statusNotifier statusNotifier
+ // readyHandler runs after the embedded client for an account reports
+ // Ready. The opaque return value is stored on clientEntry and handed
+ // back to stopHandler when the entry is torn down. Nil disables the
+ // hook entirely (default for the standalone proxy).
+ readyHandler func(ctx context.Context, accountID types.AccountID, client *embed.Client) any
+ // stopHandler runs when an account's last service is removed (or the
+ // transport is shutting down). Receives whatever readyHandler returned.
+ stopHandler func(accountID types.AccountID, state any)
// OnAddPeer, when set, is called after AddPeer completes for a new account
// (i.e. when a new client was actually created, not when an existing one
@@ -167,9 +195,6 @@ type skipTLSVerifyContextKey struct{}
// AddPeer registers a service for an account. If the account doesn't have a client yet,
// one is created by authenticating with the management server using the provided token.
// Multiple services can share the same client.
-//
-// Client creation (WG keygen, gRPC, embed.New) runs without holding clientsMux
-// so that concurrent AddPeer calls for different accounts execute in parallel.
func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error {
si := serviceInfo{serviceID: serviceID}
@@ -177,23 +202,10 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
entry, exists := n.clients[accountID]
if exists {
- ready := entry.ready
entry.services[key] = si
started := entry.started
n.clientsMux.Unlock()
- // If the entry is still being initialized by another goroutine, wait.
- if ready != nil {
- select {
- case <-ready:
- case <-ctx.Done():
- return ctx.Err()
- }
- if entry.initErr != nil {
- return fmt.Errorf("peer initialization failed: %w", entry.initErr)
- }
- }
-
n.logger.WithFields(log.Fields{
"account_id": accountID,
"service_key": key,
@@ -210,43 +222,19 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
return nil
}
- // Insert a placeholder so other goroutines calling AddPeer for the same
- // account will wait on the ready channel instead of starting a second
- // client creation.
- entry = &clientEntry{
- services: map[ServiceKey]serviceInfo{key: si},
- ready: make(chan struct{}),
- }
- n.clients[accountID] = entry
- n.clientsMux.Unlock()
-
createStart := time.Now()
- created, err := n.createClientEntry(ctx, accountID, key, authToken, si)
+ entry, err := n.createClientEntry(ctx, accountID, key, authToken, si)
if n.OnAddPeer != nil {
n.OnAddPeer(time.Since(createStart), err)
}
if err != nil {
- entry.initErr = err
- close(entry.ready)
-
- n.clientsMux.Lock()
- delete(n.clients, accountID)
n.clientsMux.Unlock()
return err
}
- // Transfer any services that were registered by concurrent AddPeer calls
- // while we were creating the client.
- n.clientsMux.Lock()
- for k, v := range entry.services {
- created.services[k] = v
- }
- created.ready = nil
- n.clients[accountID] = created
+ n.clients[accountID] = entry
n.clientsMux.Unlock()
- close(entry.ready)
-
n.logger.WithFields(log.Fields{
"account_id": accountID,
"service_key": key,
@@ -254,13 +242,13 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
// Attempt to start the client in the background; if this fails we will
// retry on the first request via RoundTrip.
- go n.runClientStartup(ctx, accountID, created.client)
+ go n.runClientStartup(ctx, accountID, entry.client)
return nil
}
// createClientEntry generates a WireGuard keypair, authenticates with management,
-// and creates an embedded NetBird client.
+// and creates an embedded NetBird client. Must be called with clientsMux held.
func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) {
serviceID := si.serviceID
n.logger.WithFields(log.Fields{
@@ -318,9 +306,15 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
ManagementURL: n.clientCfg.MgmtAddr,
PrivateKey: privateKey.String(),
LogLevel: log.WarnLevel.String(),
- BlockInbound: true,
- WireguardPort: &wgPort,
- PreSharedKey: n.clientCfg.PreSharedKey,
+ BlockInbound: n.clientCfg.BlockInbound,
+ // The embedded proxy peer must never be a stepping stone into
+ // the proxy host's LAN: it only exists to reach NetBird mesh
+ // targets or, when direct_upstream is set, the host network
+ // stack via the MultiTransport's direct branch (which bypasses
+ // the engine routing entirely).
+ BlockLANAccess: true,
+ WireguardPort: &wgPort,
+ PreSharedKey: n.clientCfg.PreSharedKey,
})
if err != nil {
return nil, fmt.Errorf("create netbird client: %w", err)
@@ -385,8 +379,25 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
toNotify = append(toNotify, serviceNotification{key: key, serviceID: info.serviceID})
}
}
+ readyHandler := n.readyHandler
n.clientsMux.Unlock()
+ if readyHandler != nil {
+ state := readyHandler(ctx, accountID, client)
+ n.clientsMux.Lock()
+ if e, ok := n.clients[accountID]; ok {
+ e.inbound = state
+ } else if state != nil && n.stopHandler != nil {
+ // Account was removed while readyHandler ran; tear down the
+ // resources it just brought up.
+ stop := n.stopHandler
+ n.clientsMux.Unlock()
+ stop(accountID, state)
+ n.clientsMux.Lock()
+ }
+ n.clientsMux.Unlock()
+ }
+
if n.statusNotifier == nil {
return
}
@@ -432,11 +443,15 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
stopClient := len(entry.services) == 0
var client *embed.Client
var transport, insecureTransport *http.Transport
+ var inbound any
+ var stopHandler func(types.AccountID, any)
if stopClient {
n.logger.WithField("account_id", accountID).Info("stopping client, no more services")
client = entry.client
transport = entry.transport
insecureTransport = entry.insecureTransport
+ inbound = entry.inbound
+ stopHandler = n.stopHandler
delete(n.clients, accountID)
} else {
n.logger.WithFields(log.Fields{
@@ -450,6 +465,9 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
n.notifyDisconnect(ctx, accountID, key, si.serviceID)
if stopClient {
+ if inbound != nil && stopHandler != nil {
+ stopHandler(accountID, inbound)
+ }
transport.CloseIdleConnections()
insecureTransport.CloseIdleConnections()
if err := client.Stop(ctx); err != nil {
@@ -536,8 +554,12 @@ func (n *NetBird) StopAll(ctx context.Context) error {
n.clientsMux.Lock()
defer n.clientsMux.Unlock()
+ stopHandler := n.stopHandler
var merr *multierror.Error
for accountID, entry := range n.clients {
+ if entry.inbound != nil && stopHandler != nil {
+ stopHandler(accountID, entry.inbound)
+ }
entry.transport.CloseIdleConnections()
entry.insecureTransport.CloseIdleConnections()
if err := entry.client.Stop(ctx); err != nil {
@@ -590,6 +612,19 @@ func (n *NetBird) GetClient(accountID types.AccountID) (*embed.Client, bool) {
return entry.client, true
}
+// IdentityForIP resolves a tunnel IP to a peer identity local to the given
+// account. Delegates to clientEntry.IdentityForIP. Returns ok=false when
+// the account has no client or the IP is not in its peerstore.
+func (n *NetBird) IdentityForIP(accountID types.AccountID, ip netip.Addr) (pubKey, fqdn string, ok bool) {
+ n.clientsMux.RLock()
+ entry, exists := n.clients[accountID]
+ n.clientsMux.RUnlock()
+ if !exists {
+ return "", "", false
+ }
+ return entry.IdentityForIP(ip)
+}
+
// ListClientsForDebug returns information about all clients for debug purposes.
func (n *NetBird) ListClientsForDebug() map[types.AccountID]ClientDebugInfo {
n.clientsMux.RLock()
@@ -645,6 +680,18 @@ func NewNetBird(proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.L
}
}
+// SetClientLifecycle registers callbacks that run when an embedded
+// client becomes ready and when its entry is torn down. The opaque value
+// returned by ready is stored on the entry and handed back to stop on
+// cleanup. Must be called before AddPeer. A nil pair leaves the
+// outbound-only behaviour intact.
+func (n *NetBird) SetClientLifecycle(ready func(ctx context.Context, accountID types.AccountID, client *embed.Client) any, stop func(accountID types.AccountID, state any)) {
+ n.clientsMux.Lock()
+ defer n.clientsMux.Unlock()
+ n.readyHandler = ready
+ n.stopHandler = stop
+}
+
// dialWithTimeout wraps a DialContext function so that any dial timeout
// stored in the context (via types.WithDialTimeout) is applied only to
// the connection establishment phase, not the full request lifetime.
@@ -687,3 +734,22 @@ func skipTLSVerifyFromContext(ctx context.Context) bool {
v, _ := ctx.Value(skipTLSVerifyContextKey{}).(bool)
return v
}
+
+// directUpstreamContextKey signals that the request should bypass the embedded
+// NetBird WireGuard client and dial via the host's network stack instead.
+// Set by the reverse-proxy rewrite step when the matched target carries
+// PathTarget.DirectUpstream; consumed by MultiTransport.
+type directUpstreamContextKey struct{}
+
+// WithDirectUpstream marks the context so MultiTransport routes the request
+// through its stdlib transport instead of the embedded NetBird roundtripper.
+func WithDirectUpstream(ctx context.Context) context.Context {
+ return context.WithValue(ctx, directUpstreamContextKey{}, true)
+}
+
+// DirectUpstreamFromContext reports whether the context has been marked to
+// bypass the embedded NetBird client.
+func DirectUpstreamFromContext(ctx context.Context) bool {
+ v, _ := ctx.Value(directUpstreamContextKey{}).(bool)
+ return v
+}
diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go
index 5444f6c11..3f3e4138a 100644
--- a/proxy/internal/roundtrip/netbird_test.go
+++ b/proxy/internal/roundtrip/netbird_test.go
@@ -3,6 +3,7 @@ package roundtrip
import (
"context"
"net/http"
+ "net/netip"
"sync"
"testing"
@@ -305,6 +306,36 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
assert.True(t, calls[0].connected)
}
+// TestNetBird_IdentityForIP_UnknownAccountReturnsFalse confirms that the
+// public lookup short-circuits when no client has been registered for
+// the queried account. The auth middleware uses ok=false as a fast deny.
+func TestNetBird_IdentityForIP_UnknownAccountReturnsFalse(t *testing.T) {
+ nb := mockNetBird()
+ _, _, ok := nb.IdentityForIP("acct-missing", netip.MustParseAddr("100.64.0.10"))
+ assert.False(t, ok, "unknown account must yield ok=false")
+}
+
+// TestClientEntry_IdentityForIP_NilClientGuard ensures the receiver
+// methods stay safe when called on partially-initialized state, which
+// can happen briefly during AddPeer setup or test fixtures.
+func TestClientEntry_IdentityForIP_NilClientGuard(t *testing.T) {
+ var e *clientEntry
+ _, _, ok := e.IdentityForIP(netip.MustParseAddr("100.64.0.10"))
+ assert.False(t, ok, "nil clientEntry must yield ok=false")
+
+ e = &clientEntry{}
+ _, _, ok = e.IdentityForIP(netip.MustParseAddr("100.64.0.10"))
+ assert.False(t, ok, "clientEntry with nil embed.Client must yield ok=false")
+}
+
+// TestClientEntry_IdentityForIP_InvalidIPReturnsFalse covers the input
+// guard so callers don't have to repeat the check.
+func TestClientEntry_IdentityForIP_InvalidIPReturnsFalse(t *testing.T) {
+ e := &clientEntry{}
+ _, _, ok := e.IdentityForIP(netip.Addr{})
+ assert.False(t, ok, "invalid IP must yield ok=false")
+}
+
func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
notifier := &mockStatusNotifier{}
nb := NewNetBird("test-proxy", "invalid.test", ClientConfig{
diff --git a/proxy/internal/tcp/bench_test.go b/proxy/internal/tcp/bench_test.go
index 049f8395d..6c0b1eea7 100644
--- a/proxy/internal/tcp/bench_test.go
+++ b/proxy/internal/tcp/bench_test.go
@@ -36,7 +36,7 @@ func BenchmarkPeekClientHello_TLS(b *testing.B) {
for b.Loop() {
r := bytes.NewReader(hello)
conn := &readerConn{Reader: r}
- sni, wrapped, err := PeekClientHello(conn)
+ sni, wrapped, _, err := PeekClientHello(conn)
if err != nil {
b.Fatal(err)
}
@@ -59,7 +59,7 @@ func BenchmarkPeekClientHello_NonTLS(b *testing.B) {
for b.Loop() {
r := bytes.NewReader(httpReq)
conn := &readerConn{Reader: r}
- _, wrapped, err := PeekClientHello(conn)
+ _, wrapped, _, err := PeekClientHello(conn)
if err != nil {
b.Fatal(err)
}
diff --git a/proxy/internal/tcp/router.go b/proxy/internal/tcp/router.go
index 05beb658b..15c5022b0 100644
--- a/proxy/internal/tcp/router.go
+++ b/proxy/internal/tcp/router.go
@@ -100,28 +100,50 @@ type Router struct {
// httpCh is immutable after construction: set only in NewRouter, nil in NewPortRouter.
httpCh chan net.Conn
httpListener *chanListener
- mu sync.RWMutex
- routes map[SNIHost][]Route
- fallback *Route
- draining bool
- dialResolve DialResolver
- activeConns sync.WaitGroup
- activeRelays sync.WaitGroup
- relaySem chan struct{}
- drainDone chan struct{}
- observer RelayObserver
- accessLog l4Logger
- geo restrict.GeoResolver
+ // httpPlainCh feeds non-TLS HTTP connections to a parallel http.Server.
+ // Set only when NewRouter is called with WithPlainHTTP option (used by
+ // per-account inbound listeners that accept both :80 and :443 traffic).
+ // Nil for the host SNI router and for port routers.
+ httpPlainCh chan net.Conn
+ httpPlainListener *chanListener
+ mu sync.RWMutex
+ routes map[SNIHost][]Route
+ fallback *Route
+ draining bool
+ dialResolve DialResolver
+ activeConns sync.WaitGroup
+ activeRelays sync.WaitGroup
+ relaySem chan struct{}
+ drainDone chan struct{}
+ observer RelayObserver
+ accessLog l4Logger
+ geo restrict.GeoResolver
// svcCtxs tracks a context per service ID. All relay goroutines for a
// service derive from its context; canceling it kills them immediately.
svcCtxs map[types.ServiceID]context.Context
svcCancels map[types.ServiceID]context.CancelFunc
}
+// RouterOption customises Router construction.
+type RouterOption func(*Router)
+
+// WithPlainHTTP enables a parallel plain-HTTP channel on the router. When
+// set, connections whose first byte is not a TLS handshake are forwarded
+// to the plain channel returned by HTTPListenerPlain instead of the TLS
+// channel. Used by per-account inbound listeners that share both :80 and
+// :443 traffic on the same router.
+func WithPlainHTTP(addr net.Addr) RouterOption {
+ return func(r *Router) {
+ ch := make(chan net.Conn, httpChannelBuffer)
+ r.httpPlainCh = ch
+ r.httpPlainListener = newChanListener(ch, addr)
+ }
+}
+
// NewRouter creates a new SNI-based connection router.
-func NewRouter(logger *log.Logger, dialResolve DialResolver, addr net.Addr) *Router {
+func NewRouter(logger *log.Logger, dialResolve DialResolver, addr net.Addr, opts ...RouterOption) *Router {
httpCh := make(chan net.Conn, httpChannelBuffer)
- return &Router{
+ r := &Router{
logger: logger,
httpCh: httpCh,
httpListener: newChanListener(httpCh, addr),
@@ -131,6 +153,10 @@ func NewRouter(logger *log.Logger, dialResolve DialResolver, addr net.Addr) *Rou
svcCtxs: make(map[types.ServiceID]context.Context),
svcCancels: make(map[types.ServiceID]context.CancelFunc),
}
+ for _, opt := range opts {
+ opt(r)
+ }
+ return r
}
// NewPortRouter creates a Router for a dedicated port without an HTTP
@@ -153,6 +179,16 @@ func (r *Router) HTTPListener() net.Listener {
return r.httpListener
}
+// HTTPListenerPlain returns a net.Listener yielding non-TLS connections
+// for use with a parallel plain http.Server. Returns nil when the router
+// was not constructed with WithPlainHTTP.
+func (r *Router) HTTPListenerPlain() net.Listener {
+ if r.httpPlainListener == nil {
+ return nil
+ }
+ return r.httpPlainListener
+}
+
// AddRoute registers an SNI route. Multiple routes for the same host are
// stored and resolved by priority at lookup time (HTTP > TCP).
// Empty host is ignored to prevent conflicts with ECH/ESNI fallback.
@@ -254,6 +290,9 @@ func (r *Router) Serve(ctx context.Context, ln net.Listener) error {
if r.httpListener != nil {
r.httpListener.Close()
}
+ if r.httpPlainListener != nil {
+ r.httpPlainListener.Close()
+ }
case <-done:
}
}()
@@ -270,6 +309,7 @@ func (r *Router) Serve(ctx context.Context, ln net.Listener) error {
r.logger.Debugf("SNI router accept: %v", err)
continue
}
+ r.logger.Debugf("SNI router accepted conn from %s on %s", conn.RemoteAddr(), conn.LocalAddr())
r.activeConns.Add(1)
go func() {
defer r.activeConns.Done()
@@ -278,13 +318,24 @@ func (r *Router) Serve(ctx context.Context, ln net.Listener) error {
}
}
+// HandleConn lets external accept loops feed a connection through the
+// router's peek-and-dispatch logic. Use this when the same router serves
+// a secondary listener (for example, a per-account inbound :80 socket
+// alongside its :443 socket).
+func (r *Router) HandleConn(ctx context.Context, conn net.Conn) {
+ r.activeConns.Add(1)
+ defer r.activeConns.Done()
+ r.handleConn(ctx, conn)
+}
+
// handleConn peeks at the TLS ClientHello and routes the connection.
func (r *Router) handleConn(ctx context.Context, conn net.Conn) {
// Fast path: when no SNI routes and no HTTP channel exist (pure TCP
// fallback port), skip the TLS peek entirely to avoid read errors on
// non-TLS connections and reduce latency.
if r.isFallbackOnly() {
- r.handleUnmatched(ctx, conn)
+ r.logger.Debugf("SNI router fallback-only mode for conn from %s; skipping ClientHello peek", conn.RemoteAddr())
+ r.handleUnmatched(ctx, conn, false)
return
}
@@ -294,11 +345,11 @@ func (r *Router) handleConn(ctx context.Context, conn net.Conn) {
return
}
- sni, wrapped, err := PeekClientHello(conn)
+ sni, wrapped, isTLS, err := PeekClientHello(conn)
if err != nil {
- r.logger.Debugf("SNI peek: %v", err)
+ r.logger.Debugf("SNI peek failed for conn from %s: %v", conn.RemoteAddr(), err)
if wrapped != nil {
- r.handleUnmatched(ctx, wrapped)
+ r.handleUnmatched(ctx, wrapped, isTLS)
} else {
_ = conn.Close()
}
@@ -313,13 +364,20 @@ func (r *Router) handleConn(ctx context.Context, conn net.Conn) {
host := SNIHost(strings.ToLower(sni))
route, ok := r.lookupRoute(host)
+ r.logger.WithFields(log.Fields{
+ "remote": wrapped.RemoteAddr().String(),
+ "sni": string(host),
+ "match": ok,
+ "tls": isTLS,
+ }).Debug("SNI route lookup")
if !ok {
- r.handleUnmatched(ctx, wrapped)
+ r.handleUnmatched(ctx, wrapped, isTLS)
return
}
if route.Type == RouteHTTP {
- r.sendToHTTP(wrapped)
+ r.logger.Debugf("SNI %q routed to HTTP handler (service_id=%s)", host, route.ServiceID)
+ r.sendToHTTP(wrapped, isTLS)
return
}
@@ -344,15 +402,17 @@ func (r *Router) isFallbackOnly() bool {
}
// handleUnmatched routes a connection that didn't match any SNI route.
-// This includes ECH/ESNI connections where the cleartext SNI is empty.
+// This includes ECH/ESNI connections where the cleartext SNI is empty,
+// and plain (non-TLS) HTTP connections when isTLS is false.
// It tries the fallback relay first, then the HTTP channel, and closes
// the connection if neither is available.
-func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn) {
+func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn, isTLS bool) {
r.mu.RLock()
fb := r.fallback
r.mu.RUnlock()
if fb != nil {
+ r.logger.Debugf("unmatched conn from %s relayed to TCP fallback (service_id=%s, target=%s)", conn.RemoteAddr(), fb.ServiceID, fb.Target)
if err := r.relayTCP(ctx, conn, SNIHost("fallback"), *fb); err != nil {
if !errors.Is(err, errAccessRestricted) {
r.logger.WithFields(log.Fields{
@@ -364,7 +424,8 @@ func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn) {
}
return
}
- r.sendToHTTP(conn)
+ r.logger.Debugf("unmatched conn from %s sent to HTTP channel (no TCP fallback configured)", conn.RemoteAddr())
+ r.sendToHTTP(conn, isTLS)
}
// lookupRoute returns the highest-priority route for the given SNI host.
@@ -386,10 +447,20 @@ func (r *Router) lookupRoute(host SNIHost) (Route, bool) {
}
// sendToHTTP feeds the connection to the HTTP handler via the channel.
-// If no HTTP channel is configured (port router), the router is
-// draining, or the channel is full, the connection is closed.
-func (r *Router) sendToHTTP(conn net.Conn) {
- if r.httpCh == nil {
+// When isTLS is false and a plain channel is configured the connection
+// is forwarded to the plain channel; otherwise it lands on the TLS
+// channel. If no usable channel exists, the router is draining, or the
+// channel is full, the connection is closed.
+func (r *Router) sendToHTTP(conn net.Conn, isTLS bool) {
+ ch := r.httpCh
+ chanName := "HTTP"
+ if !isTLS && r.httpPlainCh != nil {
+ ch = r.httpPlainCh
+ chanName = "HTTP-plain"
+ }
+
+ if ch == nil {
+ r.logger.Debugf("%s channel nil; dropping conn from %s", chanName, conn.RemoteAddr())
_ = conn.Close()
return
}
@@ -399,14 +470,15 @@ func (r *Router) sendToHTTP(conn net.Conn) {
r.mu.RUnlock()
if draining {
+ r.logger.Debugf("router draining; dropping conn from %s", conn.RemoteAddr())
_ = conn.Close()
return
}
select {
- case r.httpCh <- conn:
+ case ch <- conn:
default:
- r.logger.Warnf("HTTP channel full, dropping connection from %s", conn.RemoteAddr())
+ r.logger.Warnf("%s channel full, dropping connection from %s", chanName, conn.RemoteAddr())
_ = conn.Close()
}
}
diff --git a/proxy/internal/tcp/router_test.go b/proxy/internal/tcp/router_test.go
index 93b6560f4..2f96d142c 100644
--- a/proxy/internal/tcp/router_test.go
+++ b/proxy/internal/tcp/router_test.go
@@ -1739,3 +1739,97 @@ func TestCheckRestrictions_IPv4MappedIPv6(t *testing.T) {
connOutside := &fakeConn{remote: fakeAddr("[::ffff:192.168.1.1]:5678")}
assert.NotEqual(t, restrict.Allow, router.checkRestrictions(connOutside, route), "::ffff:192.168.1.1 not in v4 CIDR")
}
+
+// TestRouter_PlainHTTP_RoutesToPlainChannel verifies that a plain (non-TLS)
+// connection lands on the plain HTTP channel when the router was built
+// with WithPlainHTTP, leaving the TLS channel untouched.
+func TestRouter_PlainHTTP_RoutesToPlainChannel(t *testing.T) {
+ logger := log.StandardLogger()
+ addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443}
+
+ router := NewRouter(logger, nil, addr, WithPlainHTTP(addr))
+ router.AddRoute("example.com", Route{Type: RouteHTTP})
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "test listener bind must succeed")
+ defer ln.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go func() {
+ _ = router.Serve(ctx, ln)
+ }()
+
+ // Plain HTTP request (no TLS handshake byte).
+ go func() {
+ conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
+ if err != nil {
+ return
+ }
+ _, _ = conn.Write([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
+ }()
+
+ plainListener := router.HTTPListenerPlain()
+ require.NotNil(t, plainListener, "plain listener must be exposed when WithPlainHTTP is set")
+
+ acceptDone := make(chan net.Conn, 1)
+ go func() {
+ conn, err := plainListener.Accept()
+ if err == nil {
+ acceptDone <- conn
+ }
+ }()
+
+ select {
+ case conn := <-acceptDone:
+ require.NotNil(t, conn)
+ _ = conn.Close()
+ case <-router.HTTPListener().(*chanListener).ch:
+ t.Fatal("plain HTTP request leaked into TLS channel")
+ case <-time.After(3 * time.Second):
+ t.Fatal("plain HTTP connection never reached plain channel")
+ }
+}
+
+// TestRouter_TLS_StaysOnTLSChannel_WhenPlainEnabled verifies that the
+// presence of a plain channel does not divert TLS traffic โ TLS still
+// goes to the TLS channel as before.
+func TestRouter_TLS_StaysOnTLSChannel_WhenPlainEnabled(t *testing.T) {
+ logger := log.StandardLogger()
+ addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443}
+
+ router := NewRouter(logger, nil, addr, WithPlainHTTP(addr))
+ router.AddRoute("example.com", Route{Type: RouteHTTP})
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "test listener bind must succeed")
+ defer ln.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go func() { _ = router.Serve(ctx, ln) }()
+
+ // Send a TLS ClientHello.
+ go func() {
+ conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
+ if err != nil {
+ return
+ }
+ tlsConn := tls.Client(conn, &tls.Config{
+ ServerName: "example.com",
+ InsecureSkipVerify: true, //nolint:gosec
+ })
+ _ = tlsConn.Handshake()
+ _ = tlsConn.Close()
+ }()
+
+ select {
+ case conn := <-router.httpCh:
+ require.NotNil(t, conn, "TLS conn should land on the TLS channel")
+ _ = conn.Close()
+ case <-time.After(5 * time.Second):
+ t.Fatal("TLS conn never reached the TLS channel")
+ }
+}
diff --git a/proxy/internal/tcp/snipeek.go b/proxy/internal/tcp/snipeek.go
index 25ab8e5ef..dc3c96498 100644
--- a/proxy/internal/tcp/snipeek.go
+++ b/proxy/internal/tcp/snipeek.go
@@ -30,26 +30,30 @@ const (
// bytes transparently. If the data is not a valid TLS ClientHello or
// contains no SNI extension, sni is empty and err is nil.
//
+// isTLS reports whether the first byte indicated a TLS handshake record.
+// Callers can use this to distinguish plain (non-TLS) traffic from a TLS
+// stream that simply lacked an SNI extension or used ECH.
+//
// ECH/ESNI: When the client uses Encrypted Client Hello (TLS 1.3), the
// real server name is encrypted inside the encrypted_client_hello
// extension. This parser only reads the cleartext server_name extension
// (type 0x0000), so ECH connections return sni="" and are routed through
// the fallback path (or HTTP channel), which is the correct behavior
// for a transparent proxy that does not terminate TLS.
-func PeekClientHello(conn net.Conn) (sni string, wrapped net.Conn, err error) {
+func PeekClientHello(conn net.Conn) (sni string, wrapped net.Conn, isTLS bool, err error) {
// Read the 5-byte TLS record header into a small stack-friendly buffer.
var header [tlsRecordHeaderLen]byte
if _, err := io.ReadFull(conn, header[:]); err != nil {
- return "", nil, fmt.Errorf("read TLS record header: %w", err)
+ return "", nil, false, fmt.Errorf("read TLS record header: %w", err)
}
if header[0] != contentTypeHandshake {
- return "", newPeekedConn(conn, header[:]), nil
+ return "", newPeekedConn(conn, header[:]), false, nil
}
recordLen := int(binary.BigEndian.Uint16(header[3:5]))
if recordLen == 0 || recordLen > maxClientHelloLen {
- return "", newPeekedConn(conn, header[:]), nil
+ return "", newPeekedConn(conn, header[:]), true, nil
}
// Single allocation for header + payload. The peekedConn takes
@@ -59,11 +63,11 @@ func PeekClientHello(conn net.Conn) (sni string, wrapped net.Conn, err error) {
n, err := io.ReadFull(conn, buf[tlsRecordHeaderLen:])
if err != nil {
- return "", newPeekedConn(conn, buf[:tlsRecordHeaderLen+n]), fmt.Errorf("read TLS handshake payload: %w", err)
+ return "", newPeekedConn(conn, buf[:tlsRecordHeaderLen+n]), true, fmt.Errorf("read TLS handshake payload: %w", err)
}
sni = extractSNI(buf[tlsRecordHeaderLen:])
- return sni, newPeekedConn(conn, buf), nil
+ return sni, newPeekedConn(conn, buf), true, nil
}
// extractSNI parses a TLS handshake payload to find the SNI extension.
diff --git a/proxy/internal/tcp/snipeek_test.go b/proxy/internal/tcp/snipeek_test.go
index 9afe6261d..85dee15c1 100644
--- a/proxy/internal/tcp/snipeek_test.go
+++ b/proxy/internal/tcp/snipeek_test.go
@@ -29,10 +29,11 @@ func TestPeekClientHello_ValidSNI(t *testing.T) {
_ = tlsConn.Handshake()
}()
- sni, wrapped, err := PeekClientHello(serverConn)
+ sni, wrapped, isTLS, err := PeekClientHello(serverConn)
require.NoError(t, err)
assert.Equal(t, expectedSNI, sni, "should extract SNI from ClientHello")
assert.NotNil(t, wrapped, "wrapped connection should not be nil")
+ assert.True(t, isTLS, "TLS ClientHello should be flagged as TLS")
// Verify the wrapped connection replays the peeked bytes.
// Read the first 5 bytes (TLS record header) to confirm replay.
@@ -83,10 +84,11 @@ func TestPeekClientHello_MultipleSNIs(t *testing.T) {
_ = tlsConn.Handshake()
}()
- sni, wrapped, err := PeekClientHello(serverConn)
+ sni, wrapped, isTLS, err := PeekClientHello(serverConn)
require.NoError(t, err)
assert.Equal(t, tt.expectedSNI, sni)
assert.NotNil(t, wrapped)
+ assert.True(t, isTLS, "TLS handshake should be flagged as TLS")
})
}
}
@@ -102,10 +104,11 @@ func TestPeekClientHello_NonTLSData(t *testing.T) {
_, _ = clientConn.Write(httpData)
}()
- sni, wrapped, err := PeekClientHello(serverConn)
+ sni, wrapped, isTLS, err := PeekClientHello(serverConn)
require.NoError(t, err)
assert.Empty(t, sni, "should return empty SNI for non-TLS data")
assert.NotNil(t, wrapped)
+ assert.False(t, isTLS, "plain HTTP data should not be flagged as TLS")
// Verify the wrapped connection still provides the original data.
buf := make([]byte, len(httpData))
@@ -124,7 +127,7 @@ func TestPeekClientHello_TruncatedHeader(t *testing.T) {
clientConn.Close()
}()
- _, _, err := PeekClientHello(serverConn)
+ _, _, _, err := PeekClientHello(serverConn)
assert.Error(t, err, "should error on truncated header")
}
@@ -140,7 +143,7 @@ func TestPeekClientHello_TruncatedPayload(t *testing.T) {
clientConn.Close()
}()
- _, _, err := PeekClientHello(serverConn)
+ _, _, _, err := PeekClientHello(serverConn)
assert.Error(t, err, "should error on truncated payload")
}
@@ -154,10 +157,11 @@ func TestPeekClientHello_ZeroLengthRecord(t *testing.T) {
_, _ = clientConn.Write([]byte{0x16, 0x03, 0x01, 0x00, 0x00})
}()
- sni, wrapped, err := PeekClientHello(serverConn)
+ sni, wrapped, isTLS, err := PeekClientHello(serverConn)
require.NoError(t, err)
assert.Empty(t, sni)
assert.NotNil(t, wrapped)
+ assert.True(t, isTLS, "zero-length record should still be a TLS handshake byte")
}
func TestExtractSNI_InvalidPayload(t *testing.T) {
diff --git a/proxy/internal/types/types.go b/proxy/internal/types/types.go
index bf3731803..ffdbf2301 100644
--- a/proxy/internal/types/types.go
+++ b/proxy/internal/types/types.go
@@ -54,3 +54,23 @@ func DialTimeoutFromContext(ctx context.Context) (time.Duration, bool) {
d, ok := ctx.Value(dialTimeoutKey{}).(time.Duration)
return d, ok && d > 0
}
+
+// overlayOriginKey is the context key set by per-account inbound
+// listeners to mark a request as originating from the WireGuard
+// overlay rather than the public-facing host listener.
+type overlayOriginKey struct{}
+
+// WithOverlayOrigin marks the context as originating from the
+// embedded NetBird overlay (tunnel-side inbound listener).
+func WithOverlayOrigin(ctx context.Context) context.Context {
+ return context.WithValue(ctx, overlayOriginKey{}, true)
+}
+
+// IsOverlayOrigin reports whether the request reached the proxy via
+// the overlay listener. Middlewares that only make sense for WAN
+// traffic (geolocation, CrowdSec IP reputation) should short-circuit
+// when this is true.
+func IsOverlayOrigin(ctx context.Context) bool {
+ v, _ := ctx.Value(overlayOriginKey{}).(bool)
+ return v
+}
diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go
new file mode 100644
index 000000000..6cb420722
--- /dev/null
+++ b/proxy/lifecycle.go
@@ -0,0 +1,160 @@
+package proxy
+
+import (
+ "net/netip"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/proxy/internal/acme"
+)
+
+// Config bundles every knob the proxy reads at construction time. It mirrors
+// the public fields on Server so library callers don't have to learn the
+// internal struct layout. Zero values mean "feature off" or "fall back to the
+// internal default" depending on the field โ see the per-field doc.
+//
+// The standalone binary continues to populate Server fields directly, so
+// adding fields here must not change the zero-value behaviour of Server.
+type Config struct {
+ // ListenAddr is the TCP address the main listener binds. Required.
+ ListenAddr string
+ // ID identifies this proxy instance to management. Empty value lets
+ // New generate a timestamped default.
+ ID string
+ // Logger is the logrus logger used everywhere. Empty value falls back
+ // to log.StandardLogger().
+ Logger *log.Logger
+ // Version is the build version string reported to management. Empty
+ // becomes "dev".
+ Version string
+ // ProxyURL is the public address operators use to reach this proxy.
+ ProxyURL string
+ // ManagementAddress is the gRPC URL of the management server.
+ ManagementAddress string
+ // ProxyToken authenticates this proxy with the management server.
+ ProxyToken string
+
+ // CertificateDirectory is the directory holding TLS certificate
+ // material (static or ACME-provisioned).
+ CertificateDirectory string
+ // CertificateFile is the certificate filename within
+ // CertificateDirectory.
+ CertificateFile string
+ // CertificateKeyFile is the private key filename within
+ // CertificateDirectory.
+ CertificateKeyFile string
+ // GenerateACMECertificates toggles ACME certificate provisioning.
+ GenerateACMECertificates bool
+ // ACMEChallengeAddress is the listen address for HTTP-01 challenges.
+ ACMEChallengeAddress string
+ // ACMEDirectory is the ACME directory URL (Let's Encrypt by default).
+ ACMEDirectory string
+ // ACMEEABKID is the External Account Binding Key ID for CAs that
+ // require EAB (e.g. ZeroSSL).
+ ACMEEABKID string
+ // ACMEEABHMACKey is the External Account Binding HMAC key for CAs
+ // that require EAB.
+ ACMEEABHMACKey string
+ // ACMEChallengeType is the ACME challenge type ("tls-alpn-01" or
+ // "http-01"). Empty defaults to "tls-alpn-01".
+ ACMEChallengeType string
+ // CertLockMethod controls how ACME certificate locks are coordinated
+ // across replicas.
+ CertLockMethod acme.CertLockMethod
+ // WildcardCertDir is an optional directory containing static wildcard
+ // certificates that override ACME for matching domains.
+ WildcardCertDir string
+
+ // DebugEndpointEnabled toggles the debug HTTP endpoint.
+ DebugEndpointEnabled bool
+ // DebugEndpointAddress is the bind address for the debug endpoint.
+ DebugEndpointAddress string
+ // HealthAddr is the bind address for the health probe and metrics
+ // surface. Empty disables the health probe entirely (library callers
+ // can attach their own).
+ HealthAddr string
+
+ // ForwardedProto overrides the X-Forwarded-Proto value sent to
+ // backends. Valid values: "auto", "http", "https".
+ ForwardedProto string
+ // TrustedProxies is a list of IP prefixes for trusted upstream
+ // proxies that may set forwarding headers.
+ TrustedProxies []netip.Prefix
+ // WireguardPort is the UDP port for the embedded NetBird tunnel.
+ // Zero asks the OS for a random port.
+ WireguardPort uint16
+ // ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners.
+ ProxyProtocol bool
+ // PreSharedKey is the WireGuard pre-shared key used between the
+ // proxy's embedded clients and peers.
+ PreSharedKey string
+
+ // SupportsCustomPorts indicates whether the proxy can bind arbitrary
+ // ports for TCP/UDP/TLS services.
+ SupportsCustomPorts bool
+ // RequireSubdomain forces accounts to use a subdomain in front of
+ // the proxy's cluster domain.
+ RequireSubdomain bool
+ // Private flags this proxy as embedded in a netbird client and
+ // serving exclusively over the WireGuard tunnel. Also enables
+ // per-account inbound listeners on each embedded client's netstack.
+ Private bool
+
+ // MaxDialTimeout caps the per-service backend dial timeout.
+ MaxDialTimeout time.Duration
+ // MaxSessionIdleTimeout caps the per-service session idle timeout.
+ MaxSessionIdleTimeout time.Duration
+
+ // GeoDataDir is the directory containing GeoLite2 MMDB files.
+ GeoDataDir string
+ // CrowdSecAPIURL is the CrowdSec LAPI URL. Empty disables CrowdSec.
+ CrowdSecAPIURL string
+ // CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables
+ // CrowdSec.
+ CrowdSecAPIKey string
+}
+
+// New builds a Server from cfg without performing any I/O. No goroutines
+// are spawned, no network connections are dialed, and no listeners are
+// bound โ call Start to bring the proxy up. Returning a fully-formed
+// Server keeps the standalone code path (which still constructs Server
+// directly) byte-for-byte equivalent.
+func New(cfg Config) *Server {
+ return &Server{
+ ListenAddr: cfg.ListenAddr,
+ ID: cfg.ID,
+ Logger: cfg.Logger,
+ Version: cfg.Version,
+ ProxyURL: cfg.ProxyURL,
+ ManagementAddress: cfg.ManagementAddress,
+ ProxyToken: cfg.ProxyToken,
+ CertificateDirectory: cfg.CertificateDirectory,
+ CertificateFile: cfg.CertificateFile,
+ CertificateKeyFile: cfg.CertificateKeyFile,
+ GenerateACMECertificates: cfg.GenerateACMECertificates,
+ ACMEChallengeAddress: cfg.ACMEChallengeAddress,
+ ACMEDirectory: cfg.ACMEDirectory,
+ ACMEEABKID: cfg.ACMEEABKID,
+ ACMEEABHMACKey: cfg.ACMEEABHMACKey,
+ ACMEChallengeType: cfg.ACMEChallengeType,
+ CertLockMethod: cfg.CertLockMethod,
+ WildcardCertDir: cfg.WildcardCertDir,
+ DebugEndpointEnabled: cfg.DebugEndpointEnabled,
+ DebugEndpointAddress: cfg.DebugEndpointAddress,
+ HealthAddress: cfg.HealthAddr,
+ ForwardedProto: cfg.ForwardedProto,
+ TrustedProxies: cfg.TrustedProxies,
+ WireguardPort: cfg.WireguardPort,
+ ProxyProtocol: cfg.ProxyProtocol,
+ PreSharedKey: cfg.PreSharedKey,
+ SupportsCustomPorts: cfg.SupportsCustomPorts,
+ RequireSubdomain: cfg.RequireSubdomain,
+ Private: cfg.Private,
+ MaxDialTimeout: cfg.MaxDialTimeout,
+ MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
+ GeoDataDir: cfg.GeoDataDir,
+ CrowdSecAPIURL: cfg.CrowdSecAPIURL,
+ CrowdSecAPIKey: cfg.CrowdSecAPIKey,
+ }
+}
diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go
index d7e891801..bf5067b85 100644
--- a/proxy/management_integration_test.go
+++ b/proxy/management_integration_test.go
@@ -239,6 +239,10 @@ func (m *testProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
+func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
+ return nil
+}
+
func (m *testProxyManager) CleanupStale(_ context.Context, _ time.Duration) error {
return nil
}
@@ -565,6 +569,7 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T
proxytypes.AccountID(mapping.GetAccountId()),
proxytypes.ServiceID(mapping.GetId()),
nil,
+ mapping.GetPrivate(),
)
require.NoError(t, err)
diff --git a/proxy/server.go b/proxy/server.go
index 6ccfa3e9a..63a0c577a 100644
--- a/proxy/server.go
+++ b/proxy/server.go
@@ -37,6 +37,8 @@ import (
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
grpcstatus "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/encoding/protojson"
+ goproto "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/proxy/internal/accesslog"
@@ -114,9 +116,28 @@ type Server struct {
// The mapping worker waits on this before processing updates.
routerReady chan struct{}
+ // inbound, when non-nil, manages per-account inbound listeners. Set by
+ // initPrivateInbound only when Private is true so the standalone
+ // proxy keeps its zero-overhead default path.
+ inbound *inboundManager
+
+ // Lifecycle state โ populated by Start, consumed by Stop. The fields
+ // stay zero on a fresh Server until Start runs so direct struct
+ // construction (`&Server{...}`) used by tests still works.
+ runCancel context.CancelFunc
+ mgmtConn *grpc.ClientConn
+ runErr error
+ runErrCh chan struct{}
+ startMu sync.Mutex
+ started bool
+ stopOnce sync.Once
+
// Mostly used for debugging on management.
startTime time.Time
+ // ListenAddr is the address the main TCP listener binds. Populated by
+ // New from Config or by ListenAndServe from its addr argument.
+ ListenAddr string
ID string
Logger *log.Logger
Version string
@@ -177,6 +198,14 @@ type Server struct {
// in front of this proxy's cluster domain. When true, accounts cannot
// create services on the bare cluster domain.
RequireSubdomain bool
+ // Private flags this proxy as embedded in a netbird client and serving
+ // exclusively over the WireGuard tunnel (i.e. `netbird proxy`). Reported
+ // upstream as a capability so dashboards can distinguish per-peer
+ // clusters from centralised ones, and turns on per-account inbound
+ // listeners on each embedded client's netstack: every account that
+ // registers a service exposes :80 + :443 inside its own WG tunnel,
+ // scoped to that account's services only.
+ Private bool
// MaxDialTimeout caps the per-service backend dial timeout.
// When the API sends a timeout, it is clamped to this value.
// When the API sends no timeout, this value is used as the default.
@@ -222,12 +251,16 @@ func (s *Server) NotifyStatus(ctx context.Context, accountID types.AccountID, se
status = proto.ProxyStatus_PROXY_STATUS_ACTIVE
}
- _, err := s.mgmtClient.SendStatusUpdate(ctx, &proto.SendStatusUpdateRequest{
+ req := &proto.SendStatusUpdateRequest{
ServiceId: string(serviceID),
AccountId: string(accountID),
Status: status,
CertificateIssued: false,
- })
+ }
+ if connected {
+ req.InboundListener = s.inboundListenerProto(accountID)
+ }
+ _, err := s.mgmtClient.SendStatusUpdate(ctx, req)
return err
}
@@ -238,56 +271,68 @@ func (s *Server) NotifyCertificateIssued(ctx context.Context, accountID types.Ac
AccountId: string(accountID),
Status: proto.ProxyStatus_PROXY_STATUS_ACTIVE,
CertificateIssued: true,
+ InboundListener: s.inboundListenerProto(accountID),
})
return err
}
-func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
- s.initDefaults()
- s.routerReady = make(chan struct{})
- s.udpRelays = make(map[types.ServiceID]*udprelay.Relay)
- s.portRouters = make(map[uint16]*portRouter)
- s.svcPorts = make(map[types.ServiceID][]uint16)
- s.lastMappings = make(map[types.ServiceID]*proto.ProxyMapping)
-
- exporter, err := prometheus.New()
- if err != nil {
- return fmt.Errorf("create prometheus exporter: %w", err)
+// inboundListenerProto resolves the per-account inbound listener state for
+// the SendStatusUpdate payload. Returns nil when --private-inbound is off
+// or the account has no live listener so management treats the field as
+// absent.
+func (s *Server) inboundListenerProto(accountID types.AccountID) *proto.ProxyInboundListener {
+ if s.inbound == nil {
+ return nil
}
-
- provider := metric.NewMeterProvider(metric.WithReader(exporter))
- pkg := reflect.TypeOf(Server{}).PkgPath()
- meter := provider.Meter(pkg)
-
- s.meter, err = proxymetrics.New(ctx, meter)
- if err != nil {
- return fmt.Errorf("create metrics: %w", err)
+ info, ok := s.inbound.ListenerInfo(accountID)
+ if !ok || info.TunnelIP == "" {
+ return nil
}
+ return &proto.ProxyInboundListener{
+ TunnelIp: info.TunnelIP,
+ HttpsPort: uint32(info.HTTPSPort),
+ HttpPort: uint32(info.HTTPPort),
+ }
+}
- mgmtConn, err := s.dialManagement()
- if err != nil {
+// ListenAndServe is the standalone entrypoint. It binds the listener, runs
+// the proxy until ctx is cancelled or a background goroutine fails, then
+// drains and stops. Library callers should prefer New + Start + Stop and
+// own their own shutdown signalling.
+func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
+ s.ListenAddr = addr
+ if err := s.Start(ctx); err != nil {
return err
}
- defer func() {
- if err := mgmtConn.Close(); err != nil {
- s.Logger.Debugf("management connection close: %v", err)
- }
- }()
- s.mgmtClient = proto.NewProxyServiceClient(mgmtConn)
+ return s.waitAndStop(ctx)
+}
+
+// Start brings the proxy up: dials management, configures TLS, binds the
+// main listener, and spawns the SNI router and HTTPS server goroutines. It
+// returns once the listener is bound; background errors are surfaced
+// through Stop's return value. Start is not safe to call twice.
+func (s *Server) Start(ctx context.Context) error {
+ s.startMu.Lock()
+ if s.started {
+ s.startMu.Unlock()
+ return errors.New("proxy already started")
+ }
+ s.started = true
+ s.startMu.Unlock()
+
+ s.initLifecycleState()
+ if err := s.initMetrics(ctx); err != nil {
+ return err
+ }
+
+ if err := s.initManagementClient(); err != nil {
+ return err
+ }
+
runCtx, runCancel := context.WithCancel(ctx)
- defer runCancel()
+ s.runCancel = runCancel
- // Initialize the netbird client, this is required to build peer connections
- // to proxy over.
- s.netbird = roundtrip.NewNetBird(s.ID, s.ProxyURL, roundtrip.ClientConfig{
- MgmtAddr: s.ManagementAddress,
- WGPort: s.WireguardPort,
- PreSharedKey: s.PreSharedKey,
- }, s.Logger, s, s.mgmtClient)
- s.netbird.OnAddPeer = s.meter.RecordAddPeerDuration
-
- // Create health checker before the mapping worker so it can track
- // management connectivity from the first stream connection.
+ s.initNetBirdClient()
s.healthChecker = health.NewChecker(s.Logger, s.netbird)
s.crowdsecRegistry = crowdsec.NewRegistry(s.CrowdSecAPIURL, s.CrowdSecAPIKey, log.NewEntry(s.Logger))
@@ -300,34 +345,25 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
return err
}
- // Configure the reverse proxy using NetBird's HTTP Client Transport for proxying.
- s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(s.netbird), s.ForwardedProto, s.TrustedProxies, s.Logger)
+ s.initReverseProxy()
- geoLookup, err := geolocation.NewLookup(s.Logger, s.GeoDataDir)
- if err != nil {
- return fmt.Errorf("initialize geolocation: %w", err)
- }
- s.geoRaw = geoLookup
- if geoLookup != nil {
- s.geo = geoLookup
+ if err := s.initGeoLookup(); err != nil {
+ return err
}
- var startupOK bool
+ startupOK := false
defer func() {
if startupOK {
return
}
if s.geoRaw != nil {
- if err := s.geoRaw.Close(); err != nil {
- s.Logger.Debugf("close geolocation on startup failure: %v", err)
+ if closeErr := s.geoRaw.Close(); closeErr != nil {
+ s.Logger.Debugf("close geolocation on startup failure: %v", closeErr)
}
}
}()
- // Configure the authentication middleware with session validator for OIDC group checks.
s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo)
-
- // Configure Access logs to management server.
s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies)
s.startDebugEndpoint()
@@ -336,35 +372,21 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
return err
}
- // Build the handler chain from inside out.
- handler := http.Handler(s.proxy)
- handler = s.auth.Protect(handler)
- handler = web.AssetHandler(handler)
- handler = s.accessLog.Middleware(handler)
- handler = s.meter.Middleware(handler)
- handler = s.hijackTracker.Middleware(handler)
+ handler := s.buildHandlerChain()
+ s.initPrivateInbound(handler, tlsConfig)
- // Start a raw TCP listener; the SNI router peeks at ClientHello
- // and routes to either the HTTP handler or a TCP relay.
- lc := net.ListenConfig{}
- ln, err := lc.Listen(ctx, "tcp", addr)
+ ln, err := s.bindMainListener(ctx)
if err != nil {
- return fmt.Errorf("listen on %s: %w", addr, err)
+ return err
}
- if s.ProxyProtocol {
- ln = s.wrapProxyProtocol(ln)
- }
- s.mainPort = uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // port from OS is always valid
- // Set up the SNI router for TCP/HTTP multiplexing on the main port.
s.mainRouter = nbtcp.NewRouter(s.Logger, s.resolveDialFunc, ln.Addr())
s.mainRouter.SetObserver(s.meter)
s.mainRouter.SetAccessLogger(s.accessLog)
close(s.routerReady)
- // The HTTP server uses the chanListener fed by the SNI router.
s.https = &http.Server{
- Addr: addr,
+ Addr: s.ListenAddr,
Handler: handler,
TLSConfig: tlsConfig,
ReadHeaderTimeout: httpReadHeaderTimeout,
@@ -374,35 +396,201 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
startupOK = true
- httpsErr := make(chan error, 1)
go func() {
s.Logger.Debug("starting HTTPS server on SNI router HTTP channel")
- httpsErr <- s.https.ServeTLS(s.mainRouter.HTTPListener(), "", "")
+ if serveErr := s.https.ServeTLS(s.mainRouter.HTTPListener(), "", ""); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
+ s.recordRunErr(fmt.Errorf("https server: %w", serveErr))
+ }
}()
- routerErr := make(chan error, 1)
go func() {
- s.Logger.Debugf("starting SNI router on %s", addr)
- routerErr <- s.mainRouter.Serve(runCtx, ln)
+ s.Logger.Debugf("starting SNI router on %s", s.ListenAddr)
+ if serveErr := s.mainRouter.Serve(runCtx, ln); serveErr != nil {
+ s.recordRunErr(fmt.Errorf("SNI router: %w", serveErr))
+ }
}()
+ return nil
+}
+
+// Stop drains in-flight connections, shuts down all background services,
+// and releases resources. Idempotent; calling it before Start is a no-op.
+// Returns the first fatal error reported by a background goroutine, if
+// any. The provided ctx bounds the total wait time; once it is cancelled
+// Stop returns even if drain is still in flight.
+func (s *Server) Stop(ctx context.Context) error {
+ s.stopOnce.Do(func() {
+ s.startMu.Lock()
+ started := s.started
+ s.startMu.Unlock()
+ if !started {
+ return
+ }
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ s.gracefulShutdown()
+ if s.runCancel != nil {
+ s.runCancel()
+ }
+ if s.mgmtConn != nil {
+ if err := s.mgmtConn.Close(); err != nil {
+ s.Logger.Debugf("management connection close: %v", err)
+ }
+ }
+ }()
+
+ select {
+ case <-done:
+ case <-ctx.Done():
+ s.Logger.Warnf("proxy stop deadline exceeded: %v", ctx.Err())
+ }
+ })
+
+ s.startMu.Lock()
+ defer s.startMu.Unlock()
+ return s.runErr
+}
+
+// waitAndStop blocks until ctx is cancelled or a background goroutine
+// reports a fatal error, then drains and stops. Used by ListenAndServe.
+func (s *Server) waitAndStop(ctx context.Context) error {
select {
- case err := <-httpsErr:
- s.shutdownServices()
- if !errors.Is(err, http.ErrServerClosed) {
- return fmt.Errorf("https server: %w", err)
- }
- return nil
- case err := <-routerErr:
- s.shutdownServices()
- if err != nil {
- return fmt.Errorf("SNI router: %w", err)
- }
- return nil
case <-ctx.Done():
- s.gracefulShutdown()
- return nil
+ case <-s.runErrCh:
}
+ stopCtx, cancel := context.WithTimeout(context.Background(), shutdownDrainTimeout+shutdownServiceTimeout)
+ defer cancel()
+ return s.Stop(stopCtx)
+}
+
+// recordRunErr stores the first fatal background error and signals
+// waitAndStop. Subsequent errors are logged at debug level so the first
+// cause is preserved.
+func (s *Server) recordRunErr(err error) {
+ s.startMu.Lock()
+ defer s.startMu.Unlock()
+ if s.runErr != nil {
+ s.Logger.Debugf("background error after first failure: %v", err)
+ return
+ }
+ s.runErr = err
+ if s.runErrCh != nil {
+ close(s.runErrCh)
+ }
+}
+
+// initLifecycleState seeds the maps and channels Start needs to wire up
+// background goroutines. Called once at the top of Start.
+func (s *Server) initLifecycleState() {
+ s.initDefaults()
+ s.routerReady = make(chan struct{})
+ s.runErrCh = make(chan struct{})
+ s.udpRelays = make(map[types.ServiceID]*udprelay.Relay)
+ s.portRouters = make(map[uint16]*portRouter)
+ s.svcPorts = make(map[types.ServiceID][]uint16)
+ s.lastMappings = make(map[types.ServiceID]*proto.ProxyMapping)
+}
+
+// initMetrics builds the prometheus exporter and meter bundle.
+func (s *Server) initMetrics(ctx context.Context) error {
+ exporter, err := prometheus.New()
+ if err != nil {
+ return fmt.Errorf("create prometheus exporter: %w", err)
+ }
+ provider := metric.NewMeterProvider(metric.WithReader(exporter))
+ pkg := reflect.TypeOf(Server{}).PkgPath()
+ meter := provider.Meter(pkg)
+ s.meter, err = proxymetrics.New(ctx, meter)
+ if err != nil {
+ return fmt.Errorf("create metrics: %w", err)
+ }
+ return nil
+}
+
+// initManagementClient dials management and stashes the connection so
+// Stop can close it deterministically.
+func (s *Server) initManagementClient() error {
+ conn, err := s.dialManagement()
+ if err != nil {
+ return err
+ }
+ s.mgmtConn = conn
+ s.mgmtClient = proto.NewProxyServiceClient(conn)
+ return nil
+}
+
+// initNetBirdClient builds the multi-tenant embedded NetBird client used
+// for outbound RoundTripping and (when --private-inbound is on) per-account
+// inbound listeners.
+func (s *Server) initNetBirdClient() {
+ s.netbird = roundtrip.NewNetBird(s.ID, s.ProxyURL, roundtrip.ClientConfig{
+ MgmtAddr: s.ManagementAddress,
+ WGPort: s.WireguardPort,
+ PreSharedKey: s.PreSharedKey,
+ // On --private the embedded client serves per-account inbound
+ // listeners and must apply management's ACL: keep BlockInbound off
+ // so the engine creates the ACL manager. On the standalone proxy
+ // the embedded client never accepts inbound, so block.
+ BlockInbound: !s.Private,
+ }, s.Logger, s, s.mgmtClient)
+ s.netbird.OnAddPeer = s.meter.RecordAddPeerDuration
+}
+
+// initReverseProxy builds the meter-instrumented reverse proxy. MultiTransport
+// routes targets opted into direct_upstream through the host's network stack
+// (stdlib transport); everything else falls through to the embedded NetBird
+// client. The split is needed so direct_upstream targets resolve DNS via the
+// proxy host's resolver instead of the tunnel's DNS.
+func (s *Server) initReverseProxy() {
+ upstreamRT := roundtrip.NewMultiTransport(s.netbird, s.Logger)
+ s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(upstreamRT), s.ForwardedProto, s.TrustedProxies, s.Logger)
+}
+
+// initGeoLookup configures the GeoLite2 lookup used for country-based
+// access restrictions and access-log enrichment.
+func (s *Server) initGeoLookup() error {
+ geoLookup, err := geolocation.NewLookup(s.Logger, s.GeoDataDir)
+ if err != nil {
+ return fmt.Errorf("initialize geolocation: %w", err)
+ }
+ s.geoRaw = geoLookup
+ if geoLookup != nil {
+ s.geo = geoLookup
+ }
+ return nil
+}
+
+// buildHandlerChain wires the request middlewares from inside out.
+func (s *Server) buildHandlerChain() http.Handler {
+ handler := http.Handler(s.proxy)
+ handler = s.auth.Protect(handler)
+ handler = web.AssetHandler(handler)
+ handler = s.accessLog.Middleware(handler)
+ handler = s.meter.Middleware(handler)
+ return s.hijackTracker.Middleware(handler)
+}
+
+// bindMainListener binds the main TCP listener and wraps it with PROXY
+// protocol when configured.
+func (s *Server) bindMainListener(ctx context.Context) (net.Listener, error) {
+ lc := net.ListenConfig{}
+ ln, err := lc.Listen(ctx, "tcp", s.ListenAddr)
+ if err != nil {
+ return nil, fmt.Errorf("listen on %s: %w", s.ListenAddr, err)
+ }
+ if s.ProxyProtocol {
+ ln = s.wrapProxyProtocol(ln)
+ }
+ s.mainPort = uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // port from OS is always valid
+ s.Logger.WithFields(log.Fields{
+ "requested_addr": s.ListenAddr,
+ "bound_addr": ln.Addr().String(),
+ "private": s.Private,
+ "proxy_protocol": s.ProxyProtocol,
+ }).Info("proxy main listener bound")
+ return ln, nil
}
// initDefaults sets fallback values for optional Server fields.
@@ -434,6 +622,9 @@ func (s *Server) startDebugEndpoint() {
if s.acme != nil {
debugHandler.SetCertStatus(s.acme)
}
+ if s.inbound != nil {
+ debugHandler.SetInboundProvider(inboundDebugAdapter{mgr: s.inbound})
+ }
s.debug = &http.Server{
Addr: debugAddr,
Handler: debugHandler,
@@ -447,16 +638,18 @@ func (s *Server) startDebugEndpoint() {
}()
}
-// startHealthServer launches the health probe and metrics server.
+// startHealthServer launches the health probe and metrics server. Empty
+// HealthAddress disables the probe entirely (intended for library callers
+// that want to manage their own health surface).
func (s *Server) startHealthServer() error {
- healthAddr := s.HealthAddress
- if healthAddr == "" {
- healthAddr = defaultHealthAddr
+ if s.HealthAddress == "" {
+ s.Logger.Debug("health probe disabled (empty HealthAddress)")
+ return nil
}
- s.healthServer = health.NewServer(healthAddr, s.healthChecker, s.Logger, promhttp.HandlerFor(prometheus2.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true}))
- healthListener, err := net.Listen("tcp", healthAddr)
+ s.healthServer = health.NewServer(s.HealthAddress, s.healthChecker, s.Logger, promhttp.HandlerFor(prometheus2.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true}))
+ healthListener, err := net.Listen("tcp", s.HealthAddress)
if err != nil {
- return fmt.Errorf("health probe server listen on %s: %w", healthAddr, err)
+ return fmt.Errorf("health probe server listen on %s: %w", s.HealthAddress, err)
}
go func() {
if err := s.healthServer.Serve(healthListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -507,8 +700,9 @@ func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxypr
}
const (
- defaultHealthAddr = "localhost:8080"
- defaultDebugAddr = "localhost:8444"
+ // defaultDebugAddr is the localhost-bound fallback for the debug endpoint
+ // when DebugEndpointAddress is empty.
+ defaultDebugAddr = "localhost:8444"
// proxyProtoHeaderTimeout is the deadline for reading the PROXY protocol
// header after accepting a connection.
@@ -661,8 +855,10 @@ func (s *Server) gracefulShutdown() {
defer drainCancel()
s.Logger.Info("draining in-flight connections")
- if err := s.https.Shutdown(drainCtx); err != nil {
- s.Logger.Warnf("https server drain: %v", err)
+ if s.https != nil {
+ if err := s.https.Shutdown(drainCtx); err != nil {
+ s.Logger.Warnf("https server drain: %v", err)
+ }
}
// Step 4: Close hijacked connections (WebSocket) that Shutdown does not handle.
@@ -809,6 +1005,18 @@ func (s *Server) resolveDialFunc(accountID types.AccountID) (types.DialContextFu
return client.DialContext, nil
}
+// initPrivateInbound wires per-account inbound listeners when --private
+// is set. When the flag is off this is a no-op and the standalone proxy keeps
+// its byte-for-byte previous behaviour.
+func (s *Server) initPrivateInbound(handler http.Handler, tlsConfig *tls.Config) {
+ if !s.Private {
+ return
+ }
+ s.inbound = newInboundManager(s.Logger, handler, tlsConfig)
+ s.netbird.SetClientLifecycle(s.inbound.onClientReady, s.inbound.onClientStop)
+ s.Logger.Info("private inbound listeners enabled (per-account :80 + :443)")
+}
+
// notifyError reports a resource error back to management so it can be
// surfaced to the user (e.g. port bind failure, dialer resolution error).
func (s *Server) notifyError(ctx context.Context, mapping *proto.ProxyMapping, err error) {
@@ -942,7 +1150,8 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
}
// syncSupported tracks whether management supports SyncMappings.
- // Starts true; set to false on first Unimplemented error.
+ // Starts true; set to false on the first Unimplemented error so
+ // subsequent retries skip straight to GetMappingUpdate.
syncSupported := true
initialSyncDone := false
@@ -992,10 +1201,15 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
supportsCrowdSec := s.crowdsecRegistry.Available()
+ privateCapability := s.Private
+ // Always true: this build enforces ProxyMapping.private via the auth middleware.
+ supportsPrivateService := true
return &proto.ProxyCapabilities{
- SupportsCustomPorts: &s.SupportsCustomPorts,
- RequireSubdomain: &s.RequireSubdomain,
- SupportsCrowdsec: &supportsCrowdSec,
+ SupportsCustomPorts: &s.SupportsCustomPorts,
+ RequireSubdomain: &s.RequireSubdomain,
+ SupportsCrowdsec: &supportsCrowdSec,
+ Private: &privateCapability,
+ SupportsPrivateService: &supportsPrivateService,
}
}
@@ -1027,7 +1241,6 @@ func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceC
return fmt.Errorf("create sync stream: %w", err)
}
- // Send init message.
if err := stream.Send(&proto.SyncMappingsRequest{
Msg: &proto.SyncMappingsRequest_Init{
Init: &proto.SyncMappingsInit{
@@ -1058,6 +1271,10 @@ func isSyncUnimplemented(err error) bool {
return ok && st.Code() == codes.Unimplemented
}
+// handleSyncMappingsStream consumes batches from a bidirectional SyncMappings
+// stream, sending an ack after each batch is fully processed. Management waits
+// for the ack before sending the next batch, providing application-level
+// back-pressure.
func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.ProxyService_SyncMappingsClient, initialSyncDone *bool, connectTime time.Time) error {
select {
case <-s.routerReady:
@@ -1095,39 +1312,10 @@ func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.Prox
}
}
-func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient, initialSyncDone *bool, connectTime time.Time) error {
- select {
- case <-s.routerReady:
- case <-ctx.Done():
- return ctx.Err()
- }
-
- tracker := s.newSnapshotTracker(initialSyncDone, connectTime)
-
- for {
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- msg, err := mappingClient.Recv()
- switch {
- case errors.Is(err, io.EOF):
- return nil
- case err != nil:
- return fmt.Errorf("receive msg: %w", err)
- }
-
- batchStart := time.Now()
- s.Logger.Debug("Received mapping update, starting processing")
- s.processMappings(ctx, msg.GetMapping())
- s.Logger.Debug("Processing mapping update completed")
- tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
- }
- }
-}
-
// snapshotTracker accumulates service IDs during the initial snapshot and
-// finalises sync state when the complete flag arrives.
+// finalises sync state when the complete flag arrives. Used by both
+// handleMappingStream and handleSyncMappingsStream so metric emission and
+// reconciliation behave identically on either RPC.
type snapshotTracker struct {
done *bool
connectTime time.Time
@@ -1171,6 +1359,37 @@ func (t *snapshotTracker) recordBatch(ctx context.Context, s *Server, mappings [
s.Logger.Info("Initial mapping sync complete")
}
+func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient, initialSyncDone *bool, connectTime time.Time) error {
+ select {
+ case <-s.routerReady:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+
+ tracker := s.newSnapshotTracker(initialSyncDone, connectTime)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ msg, err := mappingClient.Recv()
+ switch {
+ case errors.Is(err, io.EOF):
+ return nil
+ case err != nil:
+ return fmt.Errorf("receive msg: %w", err)
+ }
+
+ batchStart := time.Now()
+ s.Logger.Debug("Received mapping update, starting processing")
+ s.processMappings(ctx, msg.GetMapping())
+ s.Logger.Debug("Processing mapping update completed")
+ tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
+ }
+ }
+}
+
// reconcileSnapshot removes local mappings that are absent from the snapshot.
// This ensures services deleted while the proxy was disconnected get cleaned up.
func (s *Server) reconcileSnapshot(ctx context.Context, snapshotIDs map[types.ServiceID]struct{}) {
@@ -1192,17 +1411,58 @@ func (s *Server) reconcileSnapshot(ctx context.Context, snapshotIDs map[types.Se
}
}
-func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) {
- s.ensurePeers(ctx, mappings)
+// mappingJSONMarshal dumps mappings on one line with zero-value fields visible for debug logs.
+var mappingJSONMarshal = protojson.MarshalOptions{
+ Multiline: false,
+ EmitUnpopulated: true,
+ UseProtoNames: true,
+}
+// redactMappingForLog returns a deep copy of the mapping with sensitive fields
+// (auth_token, header-auth hashed values, custom upstream headers) replaced so
+// debug logs never carry credentials.
+func redactMappingForLog(m *proto.ProxyMapping) *proto.ProxyMapping {
+ const placeholder = "[REDACTED]"
+ c := goproto.Clone(m).(*proto.ProxyMapping)
+ if c.GetAuthToken() != "" {
+ c.AuthToken = placeholder
+ }
+ if c.Auth != nil {
+ for _, h := range c.Auth.GetHeaderAuths() {
+ if h.GetHashedValue() != "" {
+ h.HashedValue = placeholder
+ }
+ }
+ }
+ for _, p := range c.GetPath() {
+ opts := p.GetOptions()
+ if opts == nil || len(opts.CustomHeaders) == 0 {
+ continue
+ }
+ redacted := make(map[string]string, len(opts.CustomHeaders))
+ for k := range opts.CustomHeaders {
+ redacted[k] = placeholder
+ }
+ opts.CustomHeaders = redacted
+ }
+ return c
+}
+
+func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) {
+ debug := s.Logger != nil && s.Logger.IsLevelEnabled(log.DebugLevel)
for _, mapping := range mappings {
- s.Logger.WithFields(log.Fields{
- "type": mapping.GetType(),
- "domain": mapping.GetDomain(),
- "mode": mapping.GetMode(),
- "port": mapping.GetListenPort(),
- "id": mapping.GetId(),
- }).Debug("Processing mapping update")
+ if debug {
+ raw, err := mappingJSONMarshal.Marshal(redactMappingForLog(mapping))
+ if err != nil {
+ raw = []byte(fmt.Sprintf("", err))
+ }
+ s.Logger.WithFields(log.Fields{
+ "type": mapping.GetType(),
+ "domain": mapping.GetDomain(),
+ "id": mapping.GetId(),
+ "mapping": string(raw),
+ }).Debug("Processing mapping update")
+ }
switch mapping.GetType() {
case proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED:
if err := s.addMapping(ctx, mapping); err != nil {
@@ -1228,60 +1488,6 @@ func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMap
}
}
-// ensurePeers pre-creates NetBird peers for all unique accounts referenced by
-// CREATED mappings. Peers for different accounts are created concurrently,
-// which avoids serializing Nร100ms gRPC round-trips during large initial syncs.
-func (s *Server) ensurePeers(ctx context.Context, mappings []*proto.ProxyMapping) {
- // Collect one representative mapping per account that needs a new peer.
- type peerReq struct {
- accountID types.AccountID
- svcKey roundtrip.ServiceKey
- authToken string
- svcID types.ServiceID
- }
- seen := make(map[types.AccountID]struct{})
- var reqs []peerReq
- for _, m := range mappings {
- if m.GetType() != proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED {
- continue
- }
- accountID := types.AccountID(m.GetAccountId())
- if _, ok := seen[accountID]; ok {
- continue
- }
- seen[accountID] = struct{}{}
- if s.netbird.HasClient(accountID) {
- continue
- }
- reqs = append(reqs, peerReq{
- accountID: accountID,
- svcKey: s.serviceKeyForMapping(m),
- authToken: m.GetAuthToken(),
- svcID: types.ServiceID(m.GetId()),
- })
- }
-
- if len(reqs) <= 1 {
- return
- }
-
- var wg sync.WaitGroup
- wg.Add(len(reqs))
- for _, r := range reqs {
- go func() {
- defer wg.Done()
- if err := s.netbird.AddPeer(ctx, r.accountID, r.svcKey, r.authToken, r.svcID); err != nil {
- s.Logger.WithFields(log.Fields{
- "account_id": r.accountID,
- "service_id": r.svcID,
- "error": err,
- }).Warn("failed to pre-create peer for account")
- }
- }()
- }
- wg.Wait()
-}
-
// addMapping registers a service mapping and starts the appropriate relay or routes.
func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) error {
accountID := types.AccountID(mapping.GetAccountId())
@@ -1353,12 +1559,16 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi
if s.acme != nil {
wildcardHit = s.acme.AddDomain(d, accountID, svcID)
}
- s.mainRouter.AddRoute(nbtcp.SNIHost(mapping.GetDomain()), nbtcp.Route{
+ httpRoute := nbtcp.Route{
Type: nbtcp.RouteHTTP,
AccountID: accountID,
ServiceID: svcID,
Domain: mapping.GetDomain(),
- })
+ }
+ s.mainRouter.AddRoute(nbtcp.SNIHost(mapping.GetDomain()), httpRoute)
+ if s.inbound != nil {
+ s.inbound.AddRoute(accountID, nbtcp.SNIHost(mapping.GetDomain()), httpRoute)
+ }
if err := s.updateMapping(ctx, mapping); err != nil {
return fmt.Errorf("update mapping for domain %q: %w", d, err)
}
@@ -1718,7 +1928,7 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
- if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions); err != nil {
+ if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil {
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
}
m := s.protoToMapping(ctx, mapping)
@@ -1774,6 +1984,9 @@ func (s *Server) cleanupMappingRoutes(mapping *proto.ProxyMapping) {
}
// Remove SNI route from the main router (covers both HTTP and main-port TLS).
s.mainRouter.RemoveRoute(nbtcp.SNIHost(host), svcID)
+ if s.inbound != nil {
+ s.inbound.RemoveRoute(types.AccountID(mapping.GetAccountId()), nbtcp.SNIHost(host), svcID)
+ }
}
// Extract and delete tracked custom-port entries atomically.
@@ -1861,6 +2074,7 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping
if d := opts.GetRequestTimeout(); d != nil {
pt.RequestTimeout = d.AsDuration()
}
+ pt.DirectUpstream = opts.GetDirectUpstream()
}
pt.RequestTimeout = s.clampDialTimeout(pt.RequestTimeout)
paths[pathMapping.GetPath()] = pt
diff --git a/proxy/server_test.go b/proxy/server_test.go
index b4fb4f8ba..10d38f250 100644
--- a/proxy/server_test.go
+++ b/proxy/server_test.go
@@ -1,9 +1,17 @@
package proxy
import (
+ "context"
+ "errors"
+ "io"
"testing"
+ "time"
+ log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/shared/management/proto"
)
func TestDebugEndpointDisabledByDefault(t *testing.T) {
@@ -46,3 +54,151 @@ func TestDebugEndpointAddr(t *testing.T) {
})
}
}
+
+// quietLifecycleLogger keeps lifecycle tests from spamming the test output.
+func quietLifecycleLogger() *log.Logger {
+ l := log.New()
+ l.SetOutput(io.Discard)
+ l.SetLevel(log.PanicLevel)
+ return l
+}
+
+func TestStopBeforeStartIsNoOp(t *testing.T) {
+ srv := New(Config{Logger: quietLifecycleLogger()})
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ err := srv.Stop(ctx)
+ assert.NoError(t, err, "Stop on an unstarted server must succeed without error")
+
+ err = srv.Stop(ctx)
+ assert.NoError(t, err, "Stop must remain idempotent across repeated calls")
+}
+
+func TestStartFailsWithoutManagement(t *testing.T) {
+ srv := New(Config{
+ Logger: quietLifecycleLogger(),
+ ListenAddr: "127.0.0.1:0",
+ ManagementAddress: "://broken-url",
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ err := srv.Start(ctx)
+ require.Error(t, err, "Start must surface management dial failures")
+
+ assert.True(t, srv.started, "started flag is set before any dial attempt so a second Start fails fast")
+
+ err = srv.Start(ctx)
+ require.Error(t, err, "second Start must reject")
+ assert.Contains(t, err.Error(), "already started", "error must explain why the call was rejected")
+}
+
+func TestStopIsIdempotent(t *testing.T) {
+ srv := &Server{
+ Logger: quietLifecycleLogger(),
+ started: true,
+ runErrCh: make(chan struct{}),
+ runCancel: func() {},
+ }
+ srv.recordRunErr(errors.New("synthetic"))
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ err := srv.Stop(ctx)
+ require.Error(t, err, "Stop must surface the recorded background error")
+ assert.Contains(t, err.Error(), "synthetic", "error must round-trip recordRunErr's value")
+
+ err = srv.Stop(ctx)
+ require.Error(t, err, "second Stop must still report the same error")
+ assert.Contains(t, err.Error(), "synthetic", "idempotent Stop must return the cached error")
+}
+
+func TestRecordRunErrPreservesFirstFailure(t *testing.T) {
+ srv := &Server{
+ Logger: quietLifecycleLogger(),
+ runErrCh: make(chan struct{}),
+ }
+
+ srv.recordRunErr(errors.New("first"))
+ srv.recordRunErr(errors.New("second"))
+
+ require.Error(t, srv.runErr, "first failure must be retained")
+ assert.Contains(t, srv.runErr.Error(), "first", "second call must not overwrite the cached error")
+
+ select {
+ case <-srv.runErrCh:
+ default:
+ t.Fatal("recordRunErr must close runErrCh so waitAndStop unblocks")
+ }
+}
+
+func TestStopSkipsShutdownWhenNeverStarted(t *testing.T) {
+ srv := New(Config{Logger: quietLifecycleLogger()})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err := srv.Stop(ctx)
+ assert.NoError(t, err, "Stop on an unstarted server should not block on the cancelled ctx")
+}
+
+func TestRedactMappingForLog_ScrubsSensitiveFields(t *testing.T) {
+ original := &proto.ProxyMapping{
+ Id: "svc-1",
+ Domain: "example.com",
+ AuthToken: "super-secret-token",
+ Auth: &proto.Authentication{
+ SessionKey: "pubkey-not-secret",
+ HeaderAuths: []*proto.HeaderAuth{
+ {Header: "Authorization", HashedValue: "argon2-hash-1"},
+ {Header: "X-Api-Key", HashedValue: "argon2-hash-2"},
+ },
+ },
+ Path: []*proto.PathMapping{
+ {
+ Path: "/api",
+ Target: "10.0.0.1:8080",
+ Options: &proto.PathTargetOptions{
+ CustomHeaders: map[string]string{
+ "Authorization": "Bearer upstream-token",
+ "X-Tenant": "acme",
+ },
+ },
+ },
+ },
+ }
+
+ redacted := redactMappingForLog(original)
+
+ assert.Equal(t, "super-secret-token", original.AuthToken, "original must not be mutated")
+ assert.Equal(t, "argon2-hash-1", original.Auth.HeaderAuths[0].HashedValue, "original header hash must not be mutated")
+ assert.Equal(t, "Bearer upstream-token", original.Path[0].Options.CustomHeaders["Authorization"], "original custom header must not be mutated")
+
+ assert.Equal(t, "[REDACTED]", redacted.AuthToken, "auth_token must be redacted")
+ require.Len(t, redacted.Auth.HeaderAuths, 2, "header auths must be preserved in count")
+ assert.Equal(t, "Authorization", redacted.Auth.HeaderAuths[0].Header, "header name must be preserved")
+ assert.Equal(t, "[REDACTED]", redacted.Auth.HeaderAuths[0].HashedValue, "hashed_value must be redacted")
+ assert.Equal(t, "[REDACTED]", redacted.Auth.HeaderAuths[1].HashedValue, "hashed_value must be redacted for every header auth")
+ assert.Equal(t, "pubkey-not-secret", redacted.Auth.SessionKey, "session_key (public) must be preserved")
+
+ headers := redacted.Path[0].Options.CustomHeaders
+ require.Len(t, headers, 2, "custom header keys must be preserved")
+ assert.Equal(t, "[REDACTED]", headers["Authorization"], "custom header values must be redacted")
+ assert.Equal(t, "[REDACTED]", headers["X-Tenant"], "every custom header value must be redacted")
+
+ assert.Equal(t, "svc-1", redacted.Id, "non-sensitive fields must round-trip")
+ assert.Equal(t, "example.com", redacted.Domain, "non-sensitive fields must round-trip")
+}
+
+func TestRedactMappingForLog_HandlesEmptyOrNilFields(t *testing.T) {
+ empty := &proto.ProxyMapping{Id: "svc-empty"}
+ redacted := redactMappingForLog(empty)
+
+ assert.Equal(t, "", redacted.AuthToken, "empty auth_token must remain empty (no placeholder)")
+ assert.Nil(t, redacted.Auth, "nil Auth must remain nil")
+ assert.Empty(t, redacted.Path, "empty Path must remain empty")
+}
diff --git a/shared/management/client/rest/client.go b/shared/management/client/rest/client.go
index f0cb4d2d1..43312b9e6 100644
--- a/shared/management/client/rest/client.go
+++ b/shared/management/client/rest/client.go
@@ -143,6 +143,10 @@ type Client struct {
// ReverseProxyDomains NetBird reverse proxy domains APIs
ReverseProxyDomains *ReverseProxyDomainsAPI
+
+ // ReverseProxyTokens account-scoped proxy access tokens used to register
+ // self-hosted (bring-your-own-proxy) `netbird proxy` instances.
+ ReverseProxyTokens *ReverseProxyTokensAPI
}
// New initialize new Client instance using PAT token
@@ -204,6 +208,7 @@ func (c *Client) initialize() {
c.ReverseProxyServices = &ReverseProxyServicesAPI{c}
c.ReverseProxyClusters = &ReverseProxyClustersAPI{c}
c.ReverseProxyDomains = &ReverseProxyDomainsAPI{c}
+ c.ReverseProxyTokens = &ReverseProxyTokensAPI{c}
}
// NewRequest creates and executes new management API request
diff --git a/shared/management/client/rest/reverse_proxy_clusters.go b/shared/management/client/rest/reverse_proxy_clusters.go
index b55cd35a3..249833b01 100644
--- a/shared/management/client/rest/reverse_proxy_clusters.go
+++ b/shared/management/client/rest/reverse_proxy_clusters.go
@@ -2,6 +2,7 @@ package rest
import (
"context"
+ "net/url"
"github.com/netbirdio/netbird/shared/management/http/api"
)
@@ -11,7 +12,10 @@ type ReverseProxyClustersAPI struct {
c *Client
}
-// List lists all available proxy clusters
+// List lists all available proxy clusters. Each cluster is enriched with the
+// capability flags reported by its connected proxies (supports_custom_ports,
+// supports_crowdsec, private, etc.), so callers can render UX gates without
+// a follow-up round-trip.
func (a *ReverseProxyClustersAPI) List(ctx context.Context) ([]api.ProxyCluster, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/reverse-proxies/clusters", nil, nil)
if err != nil {
@@ -23,3 +27,18 @@ func (a *ReverseProxyClustersAPI) List(ctx context.Context) ([]api.ProxyCluster,
ret, err := parseResponse[[]api.ProxyCluster](resp)
return ret, err
}
+
+// Delete removes every self-hosted (BYOP) proxy registration for the given
+// cluster address owned by the calling account. Shared clusters operated by
+// NetBird cannot be deleted via this endpoint; the server returns 404 / 400
+// for cluster addresses the account does not own.
+func (a *ReverseProxyClustersAPI) Delete(ctx context.Context, clusterAddress string) error {
+ resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/clusters/"+url.PathEscape(clusterAddress), nil, nil)
+ if err != nil {
+ return err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ return nil
+}
diff --git a/shared/management/client/rest/reverse_proxy_clusters_test.go b/shared/management/client/rest/reverse_proxy_clusters_test.go
new file mode 100644
index 000000000..2d9f6f7bb
--- /dev/null
+++ b/shared/management/client/rest/reverse_proxy_clusters_test.go
@@ -0,0 +1,90 @@
+//go:build integration
+
+package rest_test
+
+import (
+ "context"
+ "encoding/json"
+ "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"
+)
+
+func boolPtr(b bool) *bool { return &b }
+
+var testCluster = api.ProxyCluster{
+ Id: "cluster-1",
+ Address: "proxy.netbird.local",
+ Type: "shared",
+ Online: true,
+ ConnectedProxies: 2,
+ SupportsCustomPorts: boolPtr(true),
+ RequireSubdomain: boolPtr(false),
+ SupportsCrowdsec: boolPtr(false),
+ Private: boolPtr(true),
+}
+
+func TestReverseProxyClusters_List_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/clusters", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "GET", r.Method, "List must use GET")
+ retBytes, _ := json.Marshal([]api.ProxyCluster{testCluster})
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyClusters.List(context.Background())
+ require.NoError(t, err)
+ require.Len(t, ret, 1)
+ assert.Equal(t, testCluster.Id, ret[0].Id)
+ assert.Equal(t, testCluster.Address, ret[0].Address)
+ require.NotNil(t, ret[0].Private, "private capability must round-trip through the client")
+ assert.True(t, *ret[0].Private, "private capability must reflect the server value")
+ })
+}
+
+func TestReverseProxyClusters_List_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/clusters", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 500})
+ w.WriteHeader(500)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyClusters.List(context.Background())
+ assert.Error(t, err)
+ assert.Empty(t, ret)
+ })
+}
+
+func TestReverseProxyClusters_Delete_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ // PathEscape on "proxy.netbird.local" leaves it intact; the route mux
+ // matches the unescaped form. Sanity-check both the method and that
+ // path-escaping doesn't double-encode the dotted address.
+ mux.HandleFunc("/api/reverse-proxies/clusters/proxy.netbird.local", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "DELETE", r.Method, "Delete must use DELETE")
+ w.WriteHeader(200)
+ })
+ err := c.ReverseProxyClusters.Delete(context.Background(), "proxy.netbird.local")
+ require.NoError(t, err)
+ })
+}
+
+func TestReverseProxyClusters_Delete_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/clusters/proxy.netbird.local", 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.ReverseProxyClusters.Delete(context.Background(), "proxy.netbird.local")
+ assert.Error(t, err)
+ })
+}
diff --git a/shared/management/client/rest/reverse_proxy_services_test.go b/shared/management/client/rest/reverse_proxy_services_test.go
index 164563e97..1a93472db 100644
--- a/shared/management/client/rest/reverse_proxy_services_test.go
+++ b/shared/management/client/rest/reverse_proxy_services_test.go
@@ -116,8 +116,8 @@ func TestReverseProxyServices_Create_200(t *testing.T) {
Name: "test-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{testServiceTarget},
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{testServiceTarget},
})
require.NoError(t, err)
assert.Equal(t, testService.Id, ret.Id)
@@ -136,8 +136,8 @@ func TestReverseProxyServices_Create_Err(t *testing.T) {
Name: "test-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{testServiceTarget},
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{testServiceTarget},
})
assert.Error(t, err)
assert.Equal(t, "No", err.Error())
@@ -154,8 +154,9 @@ func TestReverseProxyServices_Create_WithPerTargetOptions(t *testing.T) {
var req api.ServiceRequest
require.NoError(t, json.Unmarshal(reqBytes, &req))
- require.Len(t, req.Targets, 1)
- target := req.Targets[0]
+ require.NotNil(t, req.Targets, "targets must be set on the request")
+ require.Len(t, *req.Targets, 1)
+ target := (*req.Targets)[0]
require.NotNil(t, target.Options, "options should be present")
opts := target.Options
require.NotNil(t, opts.SkipTlsVerify, "skip_tls_verify should be present")
@@ -177,8 +178,8 @@ func TestReverseProxyServices_Create_WithPerTargetOptions(t *testing.T) {
Name: "test-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{
{
TargetId: "peer-123",
TargetType: "peer",
@@ -216,8 +217,8 @@ func TestReverseProxyServices_Update_200(t *testing.T) {
Name: "updated-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{testServiceTarget},
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{testServiceTarget},
})
require.NoError(t, err)
assert.Equal(t, testService.Id, ret.Id)
@@ -236,8 +237,8 @@ func TestReverseProxyServices_Update_Err(t *testing.T) {
Name: "updated-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{testServiceTarget},
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{testServiceTarget},
})
assert.Error(t, err)
assert.Equal(t, "No", err.Error())
diff --git a/shared/management/client/rest/reverse_proxy_tokens.go b/shared/management/client/rest/reverse_proxy_tokens.go
new file mode 100644
index 000000000..de59f3176
--- /dev/null
+++ b/shared/management/client/rest/reverse_proxy_tokens.go
@@ -0,0 +1,72 @@
+package rest
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/url"
+
+ "github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+// ReverseProxyTokensAPI exposes the account-scoped proxy access tokens that
+// self-hosted (bring-your-own-proxy) deployments use to register a
+// `netbird proxy` instance with management. Tokens are bound to the
+// calling account; revoking a token disconnects every proxy that
+// authenticated with it.
+type ReverseProxyTokensAPI struct {
+ c *Client
+}
+
+// List returns every proxy token the calling account has minted, including
+// already-revoked entries. The plain token is never returned โ only the
+// metadata (id, name, created_at, last_used, revoked).
+func (a *ReverseProxyTokensAPI) List(ctx context.Context) ([]api.ProxyToken, error) {
+ resp, err := a.c.NewRequest(ctx, "GET", "/api/reverse-proxies/proxy-tokens", nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[[]api.ProxyToken](resp)
+ return ret, err
+}
+
+// Create mints a fresh account-scoped proxy token. The returned
+// ProxyTokenCreated.PlainToken is shown only once โ callers must persist
+// it immediately. Subsequent reads will only expose the token metadata,
+// not the secret material.
+func (a *ReverseProxyTokensAPI) Create(ctx context.Context, request api.ProxyTokenRequest) (*api.ProxyTokenCreated, error) {
+ requestBytes, err := json.Marshal(request)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := a.c.NewRequest(ctx, "POST", "/api/reverse-proxies/proxy-tokens", bytes.NewReader(requestBytes), nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[api.ProxyTokenCreated](resp)
+ if err != nil {
+ return nil, err
+ }
+ return &ret, nil
+}
+
+// Delete revokes a previously-issued proxy token by ID. Revoked tokens
+// remain in List output (with revoked=true) so operators can audit which
+// credentials existed; the plain secret can no longer authenticate any
+// new proxy registration.
+func (a *ReverseProxyTokensAPI) Delete(ctx context.Context, tokenID string) error {
+ resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/proxy-tokens/"+url.PathEscape(tokenID), nil, nil)
+ if err != nil {
+ return err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ return nil
+}
diff --git a/shared/management/client/rest/reverse_proxy_tokens_test.go b/shared/management/client/rest/reverse_proxy_tokens_test.go
new file mode 100644
index 000000000..a3f5e014f
--- /dev/null
+++ b/shared/management/client/rest/reverse_proxy_tokens_test.go
@@ -0,0 +1,131 @@
+//go:build integration
+
+package rest_test
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/shared/management/client/rest"
+ "github.com/netbirdio/netbird/shared/management/http/api"
+ "github.com/netbirdio/netbird/shared/management/http/util"
+)
+
+func intPtr(v int) *int { return &v }
+
+var testProxyToken = api.ProxyToken{
+ Id: "tok-1",
+ Name: "ci-runner",
+ CreatedAt: time.Date(2026, 5, 21, 9, 0, 0, 0, time.UTC),
+ Revoked: false,
+}
+
+var testProxyTokenCreated = api.ProxyTokenCreated{
+ Id: "tok-1",
+ Name: "ci-runner",
+ CreatedAt: time.Date(2026, 5, 21, 9, 0, 0, 0, time.UTC),
+ PlainToken: "nbproxy_abcdef0123456789",
+ Revoked: false,
+}
+
+func TestReverseProxyTokens_List_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "GET", r.Method, "List must use GET")
+ retBytes, _ := json.Marshal([]api.ProxyToken{testProxyToken})
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyTokens.List(context.Background())
+ require.NoError(t, err)
+ require.Len(t, ret, 1)
+ assert.Equal(t, testProxyToken.Id, ret[0].Id)
+ assert.Equal(t, testProxyToken.Name, ret[0].Name)
+ })
+}
+
+func TestReverseProxyTokens_List_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 500})
+ w.WriteHeader(500)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyTokens.List(context.Background())
+ assert.Error(t, err)
+ assert.Empty(t, ret)
+ })
+}
+
+func TestReverseProxyTokens_Create_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "POST", r.Method, "Create must use POST")
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ var req api.ProxyTokenRequest
+ require.NoError(t, json.Unmarshal(body, &req), "server must receive a valid ProxyTokenRequest body")
+ assert.Equal(t, "ci-runner", req.Name, "name must round-trip through the client")
+ require.NotNil(t, req.ExpiresIn, "expires_in must be sent when provided")
+ assert.Equal(t, 3600, *req.ExpiresIn, "expires_in value must round-trip unchanged")
+
+ retBytes, _ := json.Marshal(testProxyTokenCreated)
+ _, err = w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyTokens.Create(context.Background(), api.ProxyTokenRequest{
+ Name: "ci-runner",
+ ExpiresIn: intPtr(3600),
+ })
+ require.NoError(t, err)
+ assert.Equal(t, testProxyTokenCreated.Id, ret.Id)
+ assert.Equal(t, testProxyTokenCreated.PlainToken, ret.PlainToken,
+ "PlainToken must be returned to the caller โ it's the one-shot secret")
+ })
+}
+
+func TestReverseProxyTokens_Create_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Bad", Code: 400})
+ w.WriteHeader(400)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyTokens.Create(context.Background(), api.ProxyTokenRequest{Name: ""})
+ assert.Error(t, err)
+ assert.Nil(t, ret)
+ })
+}
+
+func TestReverseProxyTokens_Delete_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens/tok-1", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "DELETE", r.Method, "Delete must use DELETE")
+ w.WriteHeader(200)
+ })
+ err := c.ReverseProxyTokens.Delete(context.Background(), "tok-1")
+ require.NoError(t, err)
+ })
+}
+
+func TestReverseProxyTokens_Delete_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens/tok-1", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404})
+ w.WriteHeader(404)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ err := c.ReverseProxyTokens.Delete(context.Background(), "tok-1")
+ assert.Error(t, err)
+ })
+}
diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml
index 353aff72d..6b8939598 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -3067,6 +3067,17 @@ components:
$ref: '#/components/schemas/AccessRestrictions'
meta:
$ref: '#/components/schemas/ServiceMeta'
+ private:
+ type: boolean
+ description: When true, the service is NetBird-only โ its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
+ default: false
+ example: false
+ access_groups:
+ type: array
+ items:
+ type: string
+ description: NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
+ example: ["group-engineering"]
required:
- id
- name
@@ -3147,6 +3158,17 @@ components:
$ref: '#/components/schemas/ServiceAuthConfig'
access_restrictions:
$ref: '#/components/schemas/AccessRestrictions'
+ private:
+ type: boolean
+ description: When true, the service is NetBird-only โ its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
+ default: false
+ example: false
+ access_groups:
+ type: array
+ items:
+ type: string
+ description: NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
+ example: ["group-engineering"]
required:
- name
- domain
@@ -3185,6 +3207,15 @@ components:
type: string
description: Idle timeout before a UDP session is reaped, as a Go duration string (e.g. "30s", "2m").
example: "2m"
+ direct_upstream:
+ type: boolean
+ description: |
+ When true, the proxy dials this target via the host's network stack
+ instead of through its embedded NetBird client. Use for upstreams
+ reachable without WireGuard (public APIs, LAN services, localhost
+ sidecars).
+ default: false
+ example: false
ServiceTarget:
type: object
properties:
@@ -3195,7 +3226,7 @@ components:
target_type:
type: string
description: Target type
- enum: [peer, host, domain, subnet]
+ enum: [peer, host, domain, subnet, cluster]
example: "subnet"
path:
type: string
@@ -3439,6 +3470,10 @@ components:
type: boolean
description: Whether all active proxies in the cluster have CrowdSec configured
example: false
+ private:
+ type: boolean
+ description: True when at least one connected proxy in this cluster is running embedded in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. Lets the dashboard distinguish per-peer / private clusters from centralised ones.
+ example: false
required:
- id
- address
@@ -3494,6 +3529,10 @@ components:
type: boolean
description: Whether the proxy cluster has CrowdSec configured
example: false
+ supports_private:
+ type: boolean
+ description: Whether the proxy cluster supports private (NetBird-only) services. True when at least one connected proxy in the cluster runs embedded in a netbird client.
+ example: false
required:
- id
- domain
diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go
index 16e765f8c..d7945e448 100644
--- a/shared/management/http/api/types.gen.go
+++ b/shared/management/http/api/types.gen.go
@@ -1063,15 +1063,18 @@ func (e ServiceTargetProtocol) Valid() bool {
// Defines values for ServiceTargetTargetType.
const (
- ServiceTargetTargetTypeDomain ServiceTargetTargetType = "domain"
- ServiceTargetTargetTypeHost ServiceTargetTargetType = "host"
- ServiceTargetTargetTypePeer ServiceTargetTargetType = "peer"
- ServiceTargetTargetTypeSubnet ServiceTargetTargetType = "subnet"
+ ServiceTargetTargetTypeCluster ServiceTargetTargetType = "cluster"
+ ServiceTargetTargetTypeDomain ServiceTargetTargetType = "domain"
+ ServiceTargetTargetTypeHost ServiceTargetTargetType = "host"
+ ServiceTargetTargetTypePeer ServiceTargetTargetType = "peer"
+ ServiceTargetTargetTypeSubnet ServiceTargetTargetType = "subnet"
)
// Valid indicates whether the value is a known member of the ServiceTargetTargetType enum.
func (e ServiceTargetTargetType) Valid() bool {
switch e {
+ case ServiceTargetTargetTypeCluster:
+ return true
case ServiceTargetTargetTypeDomain:
return true
case ServiceTargetTargetTypeHost:
@@ -3819,6 +3822,9 @@ type ProxyCluster struct {
// Online Whether at least one proxy in the cluster has heartbeated within the active window
Online bool `json:"online"`
+ // Private True when at least one connected proxy in this cluster is running embedded in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. Lets the dashboard distinguish per-peer / private clusters from centralised ones.
+ Private *bool `json:"private,omitempty"`
+
// RequireSubdomain Whether services on this cluster must include a subdomain label
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
@@ -3896,6 +3902,9 @@ type ReverseProxyDomain struct {
// SupportsCustomPorts Whether the cluster supports binding arbitrary TCP/UDP ports
SupportsCustomPorts *bool `json:"supports_custom_ports,omitempty"`
+ // SupportsPrivate Whether the proxy cluster supports private (NetBird-only) services. True when at least one connected proxy in the cluster runs embedded in a netbird client.
+ SupportsPrivate *bool `json:"supports_private,omitempty"`
+
// TargetCluster The proxy cluster this domain is validated against (only for custom domains)
TargetCluster *string `json:"target_cluster,omitempty"`
@@ -4085,6 +4094,9 @@ type SentinelOneMatchAttributesNetworkStatus string
// Service defines model for Service.
type Service struct {
+ // AccessGroups NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
+ AccessGroups *[]string `json:"access_groups,omitempty"`
+
// AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services.
AccessRestrictions *AccessRestrictions `json:"access_restrictions,omitempty"`
Auth ServiceAuthConfig `json:"auth"`
@@ -4114,6 +4126,9 @@ type Service struct {
// PortAutoAssigned Whether the listen port was auto-assigned
PortAutoAssigned *bool `json:"port_auto_assigned,omitempty"`
+ // Private When true, the service is NetBird-only โ its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
+ Private *bool `json:"private,omitempty"`
+
// ProxyCluster The proxy cluster handling this service (derived from domain)
ProxyCluster *string `json:"proxy_cluster,omitempty"`
@@ -4156,6 +4171,9 @@ type ServiceMetaStatus string
// ServiceRequest defines model for ServiceRequest.
type ServiceRequest struct {
+ // AccessGroups NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
+ AccessGroups *[]string `json:"access_groups,omitempty"`
+
// AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services.
AccessRestrictions *AccessRestrictions `json:"access_restrictions,omitempty"`
Auth *ServiceAuthConfig `json:"auth,omitempty"`
@@ -4178,6 +4196,9 @@ type ServiceRequest struct {
// PassHostHeader When true, the original client Host header is passed through to the backend instead of being rewritten to the backend's address
PassHostHeader *bool `json:"pass_host_header,omitempty"`
+ // Private When true, the service is NetBird-only โ its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
+ Private *bool `json:"private,omitempty"`
+
// RewriteRedirects When true, Location headers in backend responses are rewritten to replace the backend address with the public-facing domain
RewriteRedirects *bool `json:"rewrite_redirects,omitempty"`
@@ -4224,6 +4245,12 @@ type ServiceTargetOptions struct {
// CustomHeaders Extra headers sent to the backend. Hop-by-hop and proxy-managed headers (Host, Connection, Transfer-Encoding, etc.) are rejected.
CustomHeaders *map[string]string `json:"custom_headers,omitempty"`
+ // DirectUpstream When true, the proxy dials this target via the host's network stack
+ // instead of through its embedded NetBird client. Use for upstreams
+ // reachable without WireGuard (public APIs, LAN services, localhost
+ // sidecars).
+ DirectUpstream *bool `json:"direct_upstream,omitempty"`
+
// PathRewrite Controls how the request path is rewritten before forwarding to the backend. Default strips the matched prefix. "preserve" keeps the full original request path.
PathRewrite *ServiceTargetOptionsPathRewrite `json:"path_rewrite,omitempty"`
diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go
index a3a5e4588..22c215074 100644
--- a/shared/management/proto/proxy_service.pb.go
+++ b/shared/management/proto/proxy_service.pb.go
@@ -188,6 +188,13 @@ type ProxyCapabilities struct {
RequireSubdomain *bool `protobuf:"varint,2,opt,name=require_subdomain,json=requireSubdomain,proto3,oneof" json:"require_subdomain,omitempty"`
// Whether the proxy has CrowdSec configured and can enforce IP reputation checks.
SupportsCrowdsec *bool `protobuf:"varint,3,opt,name=supports_crowdsec,json=supportsCrowdsec,proto3,oneof" json:"supports_crowdsec,omitempty"`
+ // Whether the proxy is running embedded in the netbird client and serving
+ // exclusively over the WireGuard tunnel (i.e. `netbird proxy` rather than
+ // the standalone netbird-proxy binary). Surfaces upstream so dashboards can
+ // distinguish per-peer / private clusters from centralised ones.
+ Private *bool `protobuf:"varint,4,opt,name=private,proto3,oneof" json:"private,omitempty"`
+ // Whether the proxy enforces ProxyMapping.private (fails closed on ValidateTunnelPeer failure). Management MUST NOT stream private mappings to proxies that don't claim this.
+ SupportsPrivateService *bool `protobuf:"varint,5,opt,name=supports_private_service,json=supportsPrivateService,proto3,oneof" json:"supports_private_service,omitempty"`
}
func (x *ProxyCapabilities) Reset() {
@@ -243,6 +250,20 @@ func (x *ProxyCapabilities) GetSupportsCrowdsec() bool {
return false
}
+func (x *ProxyCapabilities) GetPrivate() bool {
+ if x != nil && x.Private != nil {
+ return *x.Private
+ }
+ return false
+}
+
+func (x *ProxyCapabilities) GetSupportsPrivateService() bool {
+ if x != nil && x.SupportsPrivateService != nil {
+ return *x.SupportsPrivateService
+ }
+ return false
+}
+
// GetMappingUpdateRequest is sent to initialise a mapping stream.
type GetMappingUpdateRequest struct {
state protoimpl.MessageState
@@ -396,6 +417,11 @@ type PathTargetOptions struct {
ProxyProtocol bool `protobuf:"varint,5,opt,name=proxy_protocol,json=proxyProtocol,proto3" json:"proxy_protocol,omitempty"`
// Idle timeout before a UDP session is reaped.
SessionIdleTimeout *durationpb.Duration `protobuf:"bytes,6,opt,name=session_idle_timeout,json=sessionIdleTimeout,proto3" json:"session_idle_timeout,omitempty"`
+ // When true, the proxy dials this target via the host's network stack
+ // instead of through the embedded NetBird client. Useful for upstreams
+ // reachable without WireGuard (public APIs, LAN services, localhost
+ // sidecars). Defaults to false โ embedded client is the standard path.
+ DirectUpstream bool `protobuf:"varint,7,opt,name=direct_upstream,json=directUpstream,proto3" json:"direct_upstream,omitempty"`
}
func (x *PathTargetOptions) Reset() {
@@ -472,6 +498,13 @@ func (x *PathTargetOptions) GetSessionIdleTimeout() *durationpb.Duration {
return nil
}
+func (x *PathTargetOptions) GetDirectUpstream() bool {
+ if x != nil {
+ return x.DirectUpstream
+ }
+ return false
+}
+
type PathMapping struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -782,6 +815,8 @@ type ProxyMapping struct {
// For L4/TLS: the port the proxy listens on.
ListenPort int32 `protobuf:"varint,11,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"`
AccessRestrictions *AccessRestrictions `protobuf:"bytes,12,opt,name=access_restrictions,json=accessRestrictions,proto3" json:"access_restrictions,omitempty"`
+ // NetBird-only: the proxy MUST call ValidateTunnelPeer and fail closed; operator auth schemes are bypassed.
+ Private bool `protobuf:"varint,13,opt,name=private,proto3" json:"private,omitempty"`
}
func (x *ProxyMapping) Reset() {
@@ -900,6 +935,13 @@ func (x *ProxyMapping) GetAccessRestrictions() *AccessRestrictions {
return nil
}
+func (x *ProxyMapping) GetPrivate() bool {
+ if x != nil {
+ return x.Private
+ }
+ return false
+}
+
// SendAccessLogRequest consists of one or more AccessLogs from a Proxy.
type SendAccessLogRequest struct {
state protoimpl.MessageState
@@ -1489,6 +1531,11 @@ type SendStatusUpdateRequest struct {
Status ProxyStatus `protobuf:"varint,3,opt,name=status,proto3,enum=management.ProxyStatus" json:"status,omitempty"`
CertificateIssued bool `protobuf:"varint,4,opt,name=certificate_issued,json=certificateIssued,proto3" json:"certificate_issued,omitempty"`
ErrorMessage *string `protobuf:"bytes,5,opt,name=error_message,json=errorMessage,proto3,oneof" json:"error_message,omitempty"`
+ // Per-account inbound listener state for the account that owns
+ // service_id. Populated only when --private-inbound is enabled and the
+ // embedded client for the account is up. Field numbers >=50 reserved
+ // for observability extensions.
+ InboundListener *ProxyInboundListener `protobuf:"bytes,50,opt,name=inbound_listener,json=inboundListener,proto3,oneof" json:"inbound_listener,omitempty"`
}
func (x *SendStatusUpdateRequest) Reset() {
@@ -1558,6 +1605,84 @@ func (x *SendStatusUpdateRequest) GetErrorMessage() string {
return ""
}
+func (x *SendStatusUpdateRequest) GetInboundListener() *ProxyInboundListener {
+ if x != nil {
+ return x.InboundListener
+ }
+ return nil
+}
+
+// ProxyInboundListener describes a per-account inbound listener that the
+// proxy has bound on the embedded netstack of the account's WireGuard
+// client. Surfaced so dashboards can render "this account is reachable
+// at : on this proxy".
+type ProxyInboundListener struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // Tunnel IP the embedded netstack listens on. Same address other peers
+ // in the account see for the proxy peer.
+ TunnelIp string `protobuf:"bytes,1,opt,name=tunnel_ip,json=tunnelIp,proto3" json:"tunnel_ip,omitempty"`
+ // TLS port served on tunnel_ip (auto-detected, default 443).
+ HttpsPort uint32 `protobuf:"varint,2,opt,name=https_port,json=httpsPort,proto3" json:"https_port,omitempty"`
+ // Plain-HTTP port served on tunnel_ip (auto-detected, default 80).
+ HttpPort uint32 `protobuf:"varint,3,opt,name=http_port,json=httpPort,proto3" json:"http_port,omitempty"`
+}
+
+func (x *ProxyInboundListener) Reset() {
+ *x = ProxyInboundListener{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[18]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ProxyInboundListener) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProxyInboundListener) ProtoMessage() {}
+
+func (x *ProxyInboundListener) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[18]
+ 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 ProxyInboundListener.ProtoReflect.Descriptor instead.
+func (*ProxyInboundListener) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{18}
+}
+
+func (x *ProxyInboundListener) GetTunnelIp() string {
+ if x != nil {
+ return x.TunnelIp
+ }
+ return ""
+}
+
+func (x *ProxyInboundListener) GetHttpsPort() uint32 {
+ if x != nil {
+ return x.HttpsPort
+ }
+ return 0
+}
+
+func (x *ProxyInboundListener) GetHttpPort() uint32 {
+ if x != nil {
+ return x.HttpPort
+ }
+ return 0
+}
+
// SendStatusUpdateResponse is intentionally empty to allow for future expansion
type SendStatusUpdateResponse struct {
state protoimpl.MessageState
@@ -1568,7 +1693,7 @@ type SendStatusUpdateResponse struct {
func (x *SendStatusUpdateResponse) Reset() {
*x = SendStatusUpdateResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[18]
+ mi := &file_proxy_service_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1581,7 +1706,7 @@ func (x *SendStatusUpdateResponse) String() string {
func (*SendStatusUpdateResponse) ProtoMessage() {}
func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[18]
+ mi := &file_proxy_service_proto_msgTypes[19]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1594,7 +1719,7 @@ func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use SendStatusUpdateResponse.ProtoReflect.Descriptor instead.
func (*SendStatusUpdateResponse) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{18}
+ return file_proxy_service_proto_rawDescGZIP(), []int{19}
}
// CreateProxyPeerRequest is sent by the proxy to create a peer connection
@@ -1614,7 +1739,7 @@ type CreateProxyPeerRequest struct {
func (x *CreateProxyPeerRequest) Reset() {
*x = CreateProxyPeerRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[19]
+ mi := &file_proxy_service_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1627,7 +1752,7 @@ func (x *CreateProxyPeerRequest) String() string {
func (*CreateProxyPeerRequest) ProtoMessage() {}
func (x *CreateProxyPeerRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[19]
+ mi := &file_proxy_service_proto_msgTypes[20]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1640,7 +1765,7 @@ func (x *CreateProxyPeerRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateProxyPeerRequest.ProtoReflect.Descriptor instead.
func (*CreateProxyPeerRequest) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{19}
+ return file_proxy_service_proto_rawDescGZIP(), []int{20}
}
func (x *CreateProxyPeerRequest) GetServiceId() string {
@@ -1691,7 +1816,7 @@ type CreateProxyPeerResponse struct {
func (x *CreateProxyPeerResponse) Reset() {
*x = CreateProxyPeerResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[20]
+ mi := &file_proxy_service_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1704,7 +1829,7 @@ func (x *CreateProxyPeerResponse) String() string {
func (*CreateProxyPeerResponse) ProtoMessage() {}
func (x *CreateProxyPeerResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[20]
+ mi := &file_proxy_service_proto_msgTypes[21]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1717,7 +1842,7 @@ func (x *CreateProxyPeerResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateProxyPeerResponse.ProtoReflect.Descriptor instead.
func (*CreateProxyPeerResponse) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{20}
+ return file_proxy_service_proto_rawDescGZIP(), []int{21}
}
func (x *CreateProxyPeerResponse) GetSuccess() bool {
@@ -1747,7 +1872,7 @@ type GetOIDCURLRequest struct {
func (x *GetOIDCURLRequest) Reset() {
*x = GetOIDCURLRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[21]
+ mi := &file_proxy_service_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1760,7 +1885,7 @@ func (x *GetOIDCURLRequest) String() string {
func (*GetOIDCURLRequest) ProtoMessage() {}
func (x *GetOIDCURLRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[21]
+ mi := &file_proxy_service_proto_msgTypes[22]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1773,7 +1898,7 @@ func (x *GetOIDCURLRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetOIDCURLRequest.ProtoReflect.Descriptor instead.
func (*GetOIDCURLRequest) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{21}
+ return file_proxy_service_proto_rawDescGZIP(), []int{22}
}
func (x *GetOIDCURLRequest) GetId() string {
@@ -1808,7 +1933,7 @@ type GetOIDCURLResponse struct {
func (x *GetOIDCURLResponse) Reset() {
*x = GetOIDCURLResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[22]
+ mi := &file_proxy_service_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1821,7 +1946,7 @@ func (x *GetOIDCURLResponse) String() string {
func (*GetOIDCURLResponse) ProtoMessage() {}
func (x *GetOIDCURLResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[22]
+ mi := &file_proxy_service_proto_msgTypes[23]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1834,7 +1959,7 @@ func (x *GetOIDCURLResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetOIDCURLResponse.ProtoReflect.Descriptor instead.
func (*GetOIDCURLResponse) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{22}
+ return file_proxy_service_proto_rawDescGZIP(), []int{23}
}
func (x *GetOIDCURLResponse) GetUrl() string {
@@ -1856,7 +1981,7 @@ type ValidateSessionRequest struct {
func (x *ValidateSessionRequest) Reset() {
*x = ValidateSessionRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[23]
+ mi := &file_proxy_service_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1869,7 +1994,7 @@ func (x *ValidateSessionRequest) String() string {
func (*ValidateSessionRequest) ProtoMessage() {}
func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[23]
+ mi := &file_proxy_service_proto_msgTypes[24]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1882,7 +2007,7 @@ func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ValidateSessionRequest.ProtoReflect.Descriptor instead.
func (*ValidateSessionRequest) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{23}
+ return file_proxy_service_proto_rawDescGZIP(), []int{24}
}
func (x *ValidateSessionRequest) GetDomain() string {
@@ -1908,12 +2033,21 @@ type ValidateSessionResponse struct {
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
UserEmail string `protobuf:"bytes,3,opt,name=user_email,json=userEmail,proto3" json:"user_email,omitempty"`
DeniedReason string `protobuf:"bytes,4,opt,name=denied_reason,json=deniedReason,proto3" json:"denied_reason,omitempty"`
+ // peer_group_ids carries the calling user's group memberships so the
+ // proxy can authorise policy-aware middlewares without an additional
+ // management round-trip.
+ PeerGroupIds []string `protobuf:"bytes,5,rep,name=peer_group_ids,json=peerGroupIds,proto3" json:"peer_group_ids,omitempty"`
+ // peer_group_names carries the human-readable display names for the
+ // ids in peer_group_ids, ordered identically (positional pairing).
+ // Stamped onto upstream requests as X-NetBird-Groups so downstream
+ // services can read names rather than opaque ids.
+ PeerGroupNames []string `protobuf:"bytes,6,rep,name=peer_group_names,json=peerGroupNames,proto3" json:"peer_group_names,omitempty"`
}
func (x *ValidateSessionResponse) Reset() {
*x = ValidateSessionResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[24]
+ mi := &file_proxy_service_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1926,7 +2060,7 @@ func (x *ValidateSessionResponse) String() string {
func (*ValidateSessionResponse) ProtoMessage() {}
func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[24]
+ mi := &file_proxy_service_proto_msgTypes[25]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1939,7 +2073,7 @@ func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ValidateSessionResponse.ProtoReflect.Descriptor instead.
func (*ValidateSessionResponse) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{24}
+ return file_proxy_service_proto_rawDescGZIP(), []int{25}
}
func (x *ValidateSessionResponse) GetValid() bool {
@@ -1970,6 +2104,193 @@ func (x *ValidateSessionResponse) GetDeniedReason() string {
return ""
}
+func (x *ValidateSessionResponse) GetPeerGroupIds() []string {
+ if x != nil {
+ return x.PeerGroupIds
+ }
+ return nil
+}
+
+func (x *ValidateSessionResponse) GetPeerGroupNames() []string {
+ if x != nil {
+ return x.PeerGroupNames
+ }
+ return nil
+}
+
+// ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the
+// service domain whose group requirements should gate access. The calling
+// account is inferred from the proxy's gRPC metadata (ProxyToken).
+type ValidateTunnelPeerRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ TunnelIp string `protobuf:"bytes,1,opt,name=tunnel_ip,json=tunnelIp,proto3" json:"tunnel_ip,omitempty"`
+ Domain string `protobuf:"bytes,2,opt,name=domain,proto3" json:"domain,omitempty"`
+}
+
+func (x *ValidateTunnelPeerRequest) Reset() {
+ *x = ValidateTunnelPeerRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[26]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ValidateTunnelPeerRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ValidateTunnelPeerRequest) ProtoMessage() {}
+
+func (x *ValidateTunnelPeerRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[26]
+ 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 ValidateTunnelPeerRequest.ProtoReflect.Descriptor instead.
+func (*ValidateTunnelPeerRequest) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{26}
+}
+
+func (x *ValidateTunnelPeerRequest) GetTunnelIp() string {
+ if x != nil {
+ return x.TunnelIp
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerRequest) GetDomain() string {
+ if x != nil {
+ return x.Domain
+ }
+ return ""
+}
+
+// ValidateTunnelPeerResponse mirrors ValidateSessionResponse plus a freshly
+// minted session_token: when valid is true, the proxy installs the token as
+// a session cookie so subsequent requests skip the management round-trip,
+// matching the OIDC flow's UX. denied_reason values:
+//
+// "peer_not_found" โ no peer with that tunnel IP in the calling account
+// "no_user" โ peer exists but is not bound to a user
+// "service_not_found"
+// "account_mismatch"
+// "not_in_group" โ peer resolved but not in service.access_groups
+type ValidateTunnelPeerResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"`
+ UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
+ UserEmail string `protobuf:"bytes,3,opt,name=user_email,json=userEmail,proto3" json:"user_email,omitempty"`
+ DeniedReason string `protobuf:"bytes,4,opt,name=denied_reason,json=deniedReason,proto3" json:"denied_reason,omitempty"`
+ // session_token is set only when valid is true. Same shape as the JWT
+ // the OIDC flow produces โ proxy installs it via setSessionCookie so the
+ // tunnel fast-path is indistinguishable from OIDC for subsequent requests.
+ SessionToken string `protobuf:"bytes,5,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"`
+ // peer_group_ids carries the resolved peer's user group memberships so
+ // the proxy can authorise policy-aware middlewares without an additional
+ // management round-trip.
+ PeerGroupIds []string `protobuf:"bytes,6,rep,name=peer_group_ids,json=peerGroupIds,proto3" json:"peer_group_ids,omitempty"`
+ // peer_group_names carries the human-readable display names for the
+ // ids in peer_group_ids, ordered identically (positional pairing).
+ // Stamped onto upstream requests as X-NetBird-Groups so downstream
+ // services can read names rather than opaque ids.
+ PeerGroupNames []string `protobuf:"bytes,7,rep,name=peer_group_names,json=peerGroupNames,proto3" json:"peer_group_names,omitempty"`
+}
+
+func (x *ValidateTunnelPeerResponse) Reset() {
+ *x = ValidateTunnelPeerResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[27]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ValidateTunnelPeerResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ValidateTunnelPeerResponse) ProtoMessage() {}
+
+func (x *ValidateTunnelPeerResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[27]
+ 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 ValidateTunnelPeerResponse.ProtoReflect.Descriptor instead.
+func (*ValidateTunnelPeerResponse) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{27}
+}
+
+func (x *ValidateTunnelPeerResponse) GetValid() bool {
+ if x != nil {
+ return x.Valid
+ }
+ return false
+}
+
+func (x *ValidateTunnelPeerResponse) GetUserId() string {
+ if x != nil {
+ return x.UserId
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerResponse) GetUserEmail() string {
+ if x != nil {
+ return x.UserEmail
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerResponse) GetDeniedReason() string {
+ if x != nil {
+ return x.DeniedReason
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerResponse) GetSessionToken() string {
+ if x != nil {
+ return x.SessionToken
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerResponse) GetPeerGroupIds() []string {
+ if x != nil {
+ return x.PeerGroupIds
+ }
+ return nil
+}
+
+func (x *ValidateTunnelPeerResponse) GetPeerGroupNames() []string {
+ if x != nil {
+ return x.PeerGroupNames
+ }
+ return nil
+}
+
// SyncMappingsRequest is sent by the proxy on the bidirectional SyncMappings
// stream. The first message MUST be an init; all subsequent messages MUST be
// acks.
@@ -1988,7 +2309,7 @@ type SyncMappingsRequest struct {
func (x *SyncMappingsRequest) Reset() {
*x = SyncMappingsRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[25]
+ mi := &file_proxy_service_proto_msgTypes[28]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2001,7 +2322,7 @@ func (x *SyncMappingsRequest) String() string {
func (*SyncMappingsRequest) ProtoMessage() {}
func (x *SyncMappingsRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[25]
+ mi := &file_proxy_service_proto_msgTypes[28]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2014,7 +2335,7 @@ func (x *SyncMappingsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SyncMappingsRequest.ProtoReflect.Descriptor instead.
func (*SyncMappingsRequest) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{25}
+ return file_proxy_service_proto_rawDescGZIP(), []int{28}
}
func (m *SyncMappingsRequest) GetMsg() isSyncMappingsRequest_Msg {
@@ -2071,7 +2392,7 @@ type SyncMappingsInit struct {
func (x *SyncMappingsInit) Reset() {
*x = SyncMappingsInit{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[26]
+ mi := &file_proxy_service_proto_msgTypes[29]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2084,7 +2405,7 @@ func (x *SyncMappingsInit) String() string {
func (*SyncMappingsInit) ProtoMessage() {}
func (x *SyncMappingsInit) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[26]
+ mi := &file_proxy_service_proto_msgTypes[29]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2097,7 +2418,7 @@ func (x *SyncMappingsInit) ProtoReflect() protoreflect.Message {
// Deprecated: Use SyncMappingsInit.ProtoReflect.Descriptor instead.
func (*SyncMappingsInit) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{26}
+ return file_proxy_service_proto_rawDescGZIP(), []int{29}
}
func (x *SyncMappingsInit) GetProxyId() string {
@@ -2146,7 +2467,7 @@ type SyncMappingsAck struct {
func (x *SyncMappingsAck) Reset() {
*x = SyncMappingsAck{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[27]
+ mi := &file_proxy_service_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2159,7 +2480,7 @@ func (x *SyncMappingsAck) String() string {
func (*SyncMappingsAck) ProtoMessage() {}
func (x *SyncMappingsAck) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[27]
+ mi := &file_proxy_service_proto_msgTypes[30]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2172,7 +2493,7 @@ func (x *SyncMappingsAck) ProtoReflect() protoreflect.Message {
// Deprecated: Use SyncMappingsAck.ProtoReflect.Descriptor instead.
func (*SyncMappingsAck) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{27}
+ return file_proxy_service_proto_rawDescGZIP(), []int{30}
}
// SyncMappingsResponse is a batch of mappings sent by management.
@@ -2190,7 +2511,7 @@ type SyncMappingsResponse struct {
func (x *SyncMappingsResponse) Reset() {
*x = SyncMappingsResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[28]
+ mi := &file_proxy_service_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2203,7 +2524,7 @@ func (x *SyncMappingsResponse) String() string {
func (*SyncMappingsResponse) ProtoMessage() {}
func (x *SyncMappingsResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[28]
+ mi := &file_proxy_service_proto_msgTypes[31]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2216,7 +2537,7 @@ func (x *SyncMappingsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use SyncMappingsResponse.ProtoReflect.Descriptor instead.
func (*SyncMappingsResponse) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{28}
+ return file_proxy_service_proto_rawDescGZIP(), []int{31}
}
func (x *SyncMappingsResponse) GetMapping() []*ProxyMapping {
@@ -2242,7 +2563,7 @@ var file_proxy_service_proto_rawDesc = []byte{
0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x22, 0xf6, 0x01, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61,
+ 0x74, 0x6f, 0x22, 0xfd, 0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61,
0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x37, 0x0a, 0x15, 0x73, 0x75, 0x70, 0x70,
0x6f, 0x72, 0x74, 0x73, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x70, 0x6f, 0x72, 0x74,
0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x13, 0x73, 0x75, 0x70, 0x70, 0x6f,
@@ -2253,59 +2574,70 @@ var file_proxy_service_proto_rawDesc = []byte{
0x88, 0x01, 0x01, 0x12, 0x30, 0x0a, 0x11, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f,
0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02,
0x52, 0x10, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x43, 0x72, 0x6f, 0x77, 0x64, 0x73,
- 0x65, 0x63, 0x88, 0x01, 0x01, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72,
- 0x74, 0x73, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x42,
- 0x14, 0x0a, 0x12, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x5f, 0x73, 0x75, 0x62, 0x64,
- 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72,
- 0x74, 0x73, 0x5f, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x22, 0xe6, 0x01, 0x0a, 0x17,
+ 0x65, 0x63, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65,
+ 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x03, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74,
+ 0x65, 0x88, 0x01, 0x01, 0x12, 0x3d, 0x0a, 0x18, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73,
+ 0x5f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+ 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x48, 0x04, 0x52, 0x16, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72,
+ 0x74, 0x73, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+ 0x88, 0x01, 0x01, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73,
+ 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x42, 0x14, 0x0a,
+ 0x12, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x5f, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d,
+ 0x61, 0x69, 0x6e, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73,
+ 0x5f, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x70, 0x72,
+ 0x69, 0x76, 0x61, 0x74, 0x65, 0x42, 0x1b, 0x0a, 0x19, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72,
+ 0x74, 0x73, 0x5f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69,
+ 0x63, 0x65, 0x22, 0xe6, 0x01, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e,
+ 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19,
+ 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72,
+ 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73,
+ 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61,
+ 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+ 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
+ 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18,
+ 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61,
+ 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78,
+ 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63,
+ 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x82, 0x01, 0x0a, 0x18,
0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
- 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79,
- 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79,
- 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a,
- 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
- 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
- 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74,
- 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65,
- 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73,
- 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65,
- 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69,
- 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69,
- 0x74, 0x69, 0x65, 0x73, 0x22, 0x82, 0x01, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70,
- 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03,
- 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61,
- 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c,
- 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e,
- 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xce, 0x03, 0x0a, 0x11, 0x50, 0x61,
- 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12,
- 0x26, 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x74, 0x6c, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x69,
- 0x66, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x54, 0x6c,
- 0x73, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x12, 0x42, 0x0a, 0x0f, 0x72, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
+ 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70,
+ 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70,
+ 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15,
+ 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d,
+ 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69,
+ 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65,
+ 0x22, 0xf7, 0x03, 0x0a, 0x11, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f,
+ 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x74,
+ 0x6c, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x54, 0x6c, 0x73, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x12, 0x42,
+ 0x0a, 0x0f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75,
+ 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+ 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69,
+ 0x6f, 0x6e, 0x52, 0x0e, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x6f,
+ 0x75, 0x74, 0x12, 0x3e, 0x0a, 0x0c, 0x70, 0x61, 0x74, 0x68, 0x5f, 0x72, 0x65, 0x77, 0x72, 0x69,
+ 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74,
+ 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x0b, 0x70, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69,
+ 0x74, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x68, 0x65, 0x61,
+ 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67,
+ 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d,
+ 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x63, 0x75,
+ 0x73, 0x74, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x70,
+ 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20,
+ 0x01, 0x28, 0x08, 0x52, 0x0d, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
+ 0x6f, 0x6c, 0x12, 0x4b, 0x0a, 0x14, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64,
+ 0x6c, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
- 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0e, 0x72, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x3e, 0x0a, 0x0c, 0x70,
- 0x61, 0x74, 0x68, 0x5f, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
- 0x0e, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
- 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x0b,
- 0x70, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x63,
- 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20,
- 0x03, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f,
- 0x6e, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73,
- 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x48, 0x65, 0x61,
- 0x64, 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x72,
- 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x70, 0x72,
- 0x6f, 0x78, 0x79, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x4b, 0x0a, 0x14, 0x73,
- 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x6c, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65,
- 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
- 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61,
- 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x12, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x6c,
- 0x65, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x1a, 0x40, 0x0a, 0x12, 0x43, 0x75, 0x73, 0x74,
+ 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x12, 0x73, 0x65, 0x73,
+ 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12,
+ 0x27, 0x0a, 0x0f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x70, 0x73, 0x74, 0x72, 0x65,
+ 0x61, 0x6d, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74,
+ 0x55, 0x70, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x1a, 0x40, 0x0a, 0x12, 0x43, 0x75, 0x73, 0x74,
0x6f, 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
@@ -2350,7 +2682,7 @@ var file_proxy_service_proto_rawDesc = []byte{
0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x72,
0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0c, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x4d, 0x6f, 0x64, 0x65, 0x22,
- 0xe6, 0x03, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
+ 0x80, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
0x12, 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78,
0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79,
@@ -2380,244 +2712,292 @@ var file_proxy_service_proto_rawDesc = []byte{
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
0x69, 0x6f, 0x6e, 0x73, 0x52, 0x12, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74,
- 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64,
- 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73,
- 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e,
- 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
- 0x73, 0x65, 0x22, 0x84, 0x05, 0x0a, 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67,
- 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52,
- 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f,
- 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49,
- 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18,
- 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64,
- 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12,
- 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68,
- 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74,
- 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75,
- 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68,
- 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64,
- 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64,
- 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f,
- 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61,
- 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68,
- 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
- 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
- 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65,
- 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75,
- 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75,
- 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74,
- 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65,
- 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03,
- 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12,
- 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d,
- 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73,
- 0x73, 0x4c, 0x6f, 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74,
- 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d,
- 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a,
- 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12,
- 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
- 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, 0x41, 0x75,
- 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
- 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
+ 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76,
+ 0x61, 0x74, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61,
+ 0x74, 0x65, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73,
+ 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f,
+ 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03,
+ 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73,
+ 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x84, 0x05, 0x0a,
+ 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69,
+ 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+ 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
+ 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73,
+ 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61,
+ 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65,
+ 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+ 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73,
+ 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a,
+ 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74,
+ 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73,
+ 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+ 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65,
+ 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28,
+ 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12,
+ 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e,
+ 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e,
+ 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c,
+ 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12,
+ 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18,
+ 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f,
+ 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e,
+ 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65,
+ 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
+ 0x61, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x2e, 0x4d,
+ 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65,
+ 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
+ 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
+ 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
+ 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69,
+ 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69,
+ 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61,
+ 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61,
+ 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f,
+ 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73,
+ 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01,
+ 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69,
+ 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68,
+ 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41,
+ 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x57,
+ 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75,
+ 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x76, 0x61,
+ 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65,
+ 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72,
+ 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x65, 0x61,
+ 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77,
+ 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61,
+ 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61,
+ 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71,
+ 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e,
+ 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18,
+ 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73,
+ 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xda, 0x02,
+ 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61,
+ 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72,
+ 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73,
+ 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f,
+ 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63,
+ 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75,
+ 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
+ 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74,
+ 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74,
+ 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72,
+ 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
+ 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01,
+ 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73,
+ 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, 0x32, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e,
+ 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x48, 0x01, 0x52,
+ 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72,
+ 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65,
+ 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e,
+ 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x14, 0x50, 0x72,
+ 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e,
+ 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12,
+ 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x73, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20,
+ 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, 0x74, 0x74, 0x70, 0x73, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1b,
+ 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28,
+ 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x1a, 0x0a, 0x18, 0x53,
+ 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52,
+ 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61,
+ 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49,
0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64,
- 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48,
- 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70,
- 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65,
- 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72,
- 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x68,
- 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x22, 0x57, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75,
- 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x65, 0x61,
- 0x64, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b,
- 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a,
- 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a,
- 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69,
- 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, 0x14,
- 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70,
- 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18,
- 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23,
- 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18,
- 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f,
- 0x6b, 0x65, 0x6e, 0x22, 0xf3, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74,
- 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
- 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d,
- 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a,
- 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79,
- 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d,
- 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73,
- 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74,
- 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a,
- 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05,
- 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73,
- 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f,
- 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e,
- 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73,
- 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
- 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12,
- 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14,
- 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74,
- 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72,
- 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x75, 0x62,
- 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65,
- 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
- 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50,
- 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73,
- 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75,
- 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d,
- 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c,
- 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42,
- 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
- 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52,
- 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e,
- 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f,
- 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63,
- 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64,
- 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f,
- 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10,
- 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c,
- 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73,
- 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f,
- 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61,
- 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f,
- 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69,
- 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x8c, 0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69,
- 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
- 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
- 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
- 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69,
- 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73,
- 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64,
- 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d,
- 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32,
- 0x0a, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61,
- 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e,
- 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
- 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e,
- 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03,
- 0x61, 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53,
- 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12,
- 0x19, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65,
- 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72,
- 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f,
- 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
- 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
- 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12,
- 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70,
- 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32,
- 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f,
- 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c,
- 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f,
- 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22,
- 0x7e, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52,
- 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69,
- 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69,
- 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69,
- 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70,
- 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74,
- 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x2a,
- 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55,
- 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44,
- 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44,
- 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50,
- 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13,
- 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f,
- 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77,
- 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48,
- 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54,
- 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49,
- 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01,
- 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a,
- 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45,
- 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59,
- 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01,
- 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53,
- 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41,
- 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53,
- 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54,
- 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50,
- 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54,
- 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04,
- 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53,
- 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xd3, 0x05, 0x0a, 0x0c, 0x50, 0x72, 0x6f,
- 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74,
- 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61,
- 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
- 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79,
- 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75,
+ 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50,
+ 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73,
+ 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74,
+ 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78,
+ 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a,
+ 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
+ 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72,
+ 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
+ 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01,
+ 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
+ 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52,
+ 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f,
+ 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63,
+ 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72,
+ 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72,
+ 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65,
+ 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+ 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75,
+ 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65,
+ 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06,
+ 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f,
+ 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f,
+ 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73,
+ 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xdc, 0x01, 0x0a, 0x17, 0x56, 0x61,
+ 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75,
+ 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73,
+ 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61,
+ 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d,
+ 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65,
+ 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69,
+ 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72,
+ 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09,
+ 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28,
+ 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d,
+ 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72,
+ 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69,
+ 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f,
+ 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c,
+ 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56,
+ 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65,
+ 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
+ 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12,
+ 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72,
+ 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73,
+ 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65,
+ 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
+ 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d,
+ 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65,
+ 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f,
+ 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47,
+ 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f,
+ 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28,
+ 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65,
+ 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e,
+ 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69,
+ 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
+ 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a,
+ 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70,
- 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70,
- 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30,
- 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c,
- 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52,
- 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65,
- 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74,
- 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61,
- 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65,
- 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23,
+ 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05,
+ 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61,
+ 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72,
+ 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72,
+ 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
+ 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52,
+ 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64,
+ 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64,
+ 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69,
+ 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70,
+ 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62,
+ 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d,
+ 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79,
+ 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+ 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20,
+ 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d,
+ 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61,
+ 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79,
+ 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72,
+ 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
+ 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54,
+ 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a,
+ 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44,
+ 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54,
+ 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02,
+ 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d,
+ 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52,
+ 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a,
+ 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52,
+ 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f,
+ 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58,
+ 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47,
+ 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54,
+ 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50,
+ 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e,
+ 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02,
+ 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53,
+ 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e,
+ 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f,
+ 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41,
+ 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50,
+ 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f,
+ 0x52, 0x10, 0x05, 0x32, 0xb8, 0x06, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72,
+ 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69,
+ 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
+ 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61,
+ 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
+ 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70,
+ 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52,
+ 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73,
+ 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d,
+ 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41,
+ 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+ 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e,
+ 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+ 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61,
+ 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
+ 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61,
+ 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75,
+ 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64,
- 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
- 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74,
- 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65,
- 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
- 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72,
- 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73,
- 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43,
- 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
- 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65,
- 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69,
- 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53,
- 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08,
+ 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70,
+ 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72,
+ 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79,
+ 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50,
+ 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+ 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f,
+ 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49,
+ 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a,
+ 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
+ 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61,
+ 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71,
+ 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+ 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f,
+ 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c,
+ 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12,
+ 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c,
+ 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52,
+ 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e,
+ 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08,
0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
@@ -2634,53 +3014,56 @@ func file_proxy_service_proto_rawDescGZIP() []byte {
}
var file_proxy_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
-var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 31)
+var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 34)
var file_proxy_service_proto_goTypes = []interface{}{
- (ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType
- (PathRewriteMode)(0), // 1: management.PathRewriteMode
- (ProxyStatus)(0), // 2: management.ProxyStatus
- (*ProxyCapabilities)(nil), // 3: management.ProxyCapabilities
- (*GetMappingUpdateRequest)(nil), // 4: management.GetMappingUpdateRequest
- (*GetMappingUpdateResponse)(nil), // 5: management.GetMappingUpdateResponse
- (*PathTargetOptions)(nil), // 6: management.PathTargetOptions
- (*PathMapping)(nil), // 7: management.PathMapping
- (*HeaderAuth)(nil), // 8: management.HeaderAuth
- (*Authentication)(nil), // 9: management.Authentication
- (*AccessRestrictions)(nil), // 10: management.AccessRestrictions
- (*ProxyMapping)(nil), // 11: management.ProxyMapping
- (*SendAccessLogRequest)(nil), // 12: management.SendAccessLogRequest
- (*SendAccessLogResponse)(nil), // 13: management.SendAccessLogResponse
- (*AccessLog)(nil), // 14: management.AccessLog
- (*AuthenticateRequest)(nil), // 15: management.AuthenticateRequest
- (*HeaderAuthRequest)(nil), // 16: management.HeaderAuthRequest
- (*PasswordRequest)(nil), // 17: management.PasswordRequest
- (*PinRequest)(nil), // 18: management.PinRequest
- (*AuthenticateResponse)(nil), // 19: management.AuthenticateResponse
- (*SendStatusUpdateRequest)(nil), // 20: management.SendStatusUpdateRequest
- (*SendStatusUpdateResponse)(nil), // 21: management.SendStatusUpdateResponse
- (*CreateProxyPeerRequest)(nil), // 22: management.CreateProxyPeerRequest
- (*CreateProxyPeerResponse)(nil), // 23: management.CreateProxyPeerResponse
- (*GetOIDCURLRequest)(nil), // 24: management.GetOIDCURLRequest
- (*GetOIDCURLResponse)(nil), // 25: management.GetOIDCURLResponse
- (*ValidateSessionRequest)(nil), // 26: management.ValidateSessionRequest
- (*ValidateSessionResponse)(nil), // 27: management.ValidateSessionResponse
- (*SyncMappingsRequest)(nil), // 28: management.SyncMappingsRequest
- (*SyncMappingsInit)(nil), // 29: management.SyncMappingsInit
- (*SyncMappingsAck)(nil), // 30: management.SyncMappingsAck
- (*SyncMappingsResponse)(nil), // 31: management.SyncMappingsResponse
- nil, // 32: management.PathTargetOptions.CustomHeadersEntry
- nil, // 33: management.AccessLog.MetadataEntry
- (*timestamppb.Timestamp)(nil), // 34: google.protobuf.Timestamp
- (*durationpb.Duration)(nil), // 35: google.protobuf.Duration
+ (ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType
+ (PathRewriteMode)(0), // 1: management.PathRewriteMode
+ (ProxyStatus)(0), // 2: management.ProxyStatus
+ (*ProxyCapabilities)(nil), // 3: management.ProxyCapabilities
+ (*GetMappingUpdateRequest)(nil), // 4: management.GetMappingUpdateRequest
+ (*GetMappingUpdateResponse)(nil), // 5: management.GetMappingUpdateResponse
+ (*PathTargetOptions)(nil), // 6: management.PathTargetOptions
+ (*PathMapping)(nil), // 7: management.PathMapping
+ (*HeaderAuth)(nil), // 8: management.HeaderAuth
+ (*Authentication)(nil), // 9: management.Authentication
+ (*AccessRestrictions)(nil), // 10: management.AccessRestrictions
+ (*ProxyMapping)(nil), // 11: management.ProxyMapping
+ (*SendAccessLogRequest)(nil), // 12: management.SendAccessLogRequest
+ (*SendAccessLogResponse)(nil), // 13: management.SendAccessLogResponse
+ (*AccessLog)(nil), // 14: management.AccessLog
+ (*AuthenticateRequest)(nil), // 15: management.AuthenticateRequest
+ (*HeaderAuthRequest)(nil), // 16: management.HeaderAuthRequest
+ (*PasswordRequest)(nil), // 17: management.PasswordRequest
+ (*PinRequest)(nil), // 18: management.PinRequest
+ (*AuthenticateResponse)(nil), // 19: management.AuthenticateResponse
+ (*SendStatusUpdateRequest)(nil), // 20: management.SendStatusUpdateRequest
+ (*ProxyInboundListener)(nil), // 21: management.ProxyInboundListener
+ (*SendStatusUpdateResponse)(nil), // 22: management.SendStatusUpdateResponse
+ (*CreateProxyPeerRequest)(nil), // 23: management.CreateProxyPeerRequest
+ (*CreateProxyPeerResponse)(nil), // 24: management.CreateProxyPeerResponse
+ (*GetOIDCURLRequest)(nil), // 25: management.GetOIDCURLRequest
+ (*GetOIDCURLResponse)(nil), // 26: management.GetOIDCURLResponse
+ (*ValidateSessionRequest)(nil), // 27: management.ValidateSessionRequest
+ (*ValidateSessionResponse)(nil), // 28: management.ValidateSessionResponse
+ (*ValidateTunnelPeerRequest)(nil), // 29: management.ValidateTunnelPeerRequest
+ (*ValidateTunnelPeerResponse)(nil), // 30: management.ValidateTunnelPeerResponse
+ (*SyncMappingsRequest)(nil), // 31: management.SyncMappingsRequest
+ (*SyncMappingsInit)(nil), // 32: management.SyncMappingsInit
+ (*SyncMappingsAck)(nil), // 33: management.SyncMappingsAck
+ (*SyncMappingsResponse)(nil), // 34: management.SyncMappingsResponse
+ nil, // 35: management.PathTargetOptions.CustomHeadersEntry
+ nil, // 36: management.AccessLog.MetadataEntry
+ (*timestamppb.Timestamp)(nil), // 37: google.protobuf.Timestamp
+ (*durationpb.Duration)(nil), // 38: google.protobuf.Duration
}
var file_proxy_service_proto_depIdxs = []int32{
- 34, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp
+ 37, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp
3, // 1: management.GetMappingUpdateRequest.capabilities:type_name -> management.ProxyCapabilities
11, // 2: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping
- 35, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration
+ 38, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration
1, // 4: management.PathTargetOptions.path_rewrite:type_name -> management.PathRewriteMode
- 32, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry
- 35, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration
+ 35, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry
+ 38, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration
6, // 7: management.PathMapping.options:type_name -> management.PathTargetOptions
8, // 8: management.Authentication.header_auths:type_name -> management.HeaderAuth
0, // 9: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType
@@ -2688,38 +3071,41 @@ var file_proxy_service_proto_depIdxs = []int32{
9, // 11: management.ProxyMapping.auth:type_name -> management.Authentication
10, // 12: management.ProxyMapping.access_restrictions:type_name -> management.AccessRestrictions
14, // 13: management.SendAccessLogRequest.log:type_name -> management.AccessLog
- 34, // 14: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp
- 33, // 15: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry
+ 37, // 14: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp
+ 36, // 15: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry
17, // 16: management.AuthenticateRequest.password:type_name -> management.PasswordRequest
18, // 17: management.AuthenticateRequest.pin:type_name -> management.PinRequest
16, // 18: management.AuthenticateRequest.header_auth:type_name -> management.HeaderAuthRequest
2, // 19: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus
- 29, // 20: management.SyncMappingsRequest.init:type_name -> management.SyncMappingsInit
- 30, // 21: management.SyncMappingsRequest.ack:type_name -> management.SyncMappingsAck
- 34, // 22: management.SyncMappingsInit.started_at:type_name -> google.protobuf.Timestamp
- 3, // 23: management.SyncMappingsInit.capabilities:type_name -> management.ProxyCapabilities
- 11, // 24: management.SyncMappingsResponse.mapping:type_name -> management.ProxyMapping
- 4, // 25: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest
- 28, // 26: management.ProxyService.SyncMappings:input_type -> management.SyncMappingsRequest
- 12, // 27: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest
- 15, // 28: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest
- 20, // 29: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest
- 22, // 30: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest
- 24, // 31: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest
- 26, // 32: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest
- 5, // 33: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse
- 31, // 34: management.ProxyService.SyncMappings:output_type -> management.SyncMappingsResponse
- 13, // 35: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse
- 19, // 36: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse
- 21, // 37: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse
- 23, // 38: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse
- 25, // 39: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse
- 27, // 40: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse
- 33, // [33:41] is the sub-list for method output_type
- 25, // [25:33] is the sub-list for method input_type
- 25, // [25:25] is the sub-list for extension type_name
- 25, // [25:25] is the sub-list for extension extendee
- 0, // [0:25] is the sub-list for field type_name
+ 21, // 20: management.SendStatusUpdateRequest.inbound_listener:type_name -> management.ProxyInboundListener
+ 32, // 21: management.SyncMappingsRequest.init:type_name -> management.SyncMappingsInit
+ 33, // 22: management.SyncMappingsRequest.ack:type_name -> management.SyncMappingsAck
+ 37, // 23: management.SyncMappingsInit.started_at:type_name -> google.protobuf.Timestamp
+ 3, // 24: management.SyncMappingsInit.capabilities:type_name -> management.ProxyCapabilities
+ 11, // 25: management.SyncMappingsResponse.mapping:type_name -> management.ProxyMapping
+ 4, // 26: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest
+ 31, // 27: management.ProxyService.SyncMappings:input_type -> management.SyncMappingsRequest
+ 12, // 28: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest
+ 15, // 29: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest
+ 20, // 30: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest
+ 23, // 31: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest
+ 25, // 32: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest
+ 27, // 33: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest
+ 29, // 34: management.ProxyService.ValidateTunnelPeer:input_type -> management.ValidateTunnelPeerRequest
+ 5, // 35: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse
+ 34, // 36: management.ProxyService.SyncMappings:output_type -> management.SyncMappingsResponse
+ 13, // 37: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse
+ 19, // 38: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse
+ 22, // 39: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse
+ 24, // 40: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse
+ 26, // 41: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse
+ 28, // 42: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse
+ 30, // 43: management.ProxyService.ValidateTunnelPeer:output_type -> management.ValidateTunnelPeerResponse
+ 35, // [35:44] is the sub-list for method output_type
+ 26, // [26:35] is the sub-list for method input_type
+ 26, // [26:26] is the sub-list for extension type_name
+ 26, // [26:26] is the sub-list for extension extendee
+ 0, // [0:26] is the sub-list for field type_name
}
func init() { file_proxy_service_proto_init() }
@@ -2945,7 +3331,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SendStatusUpdateResponse); i {
+ switch v := v.(*ProxyInboundListener); i {
case 0:
return &v.state
case 1:
@@ -2957,7 +3343,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*CreateProxyPeerRequest); i {
+ switch v := v.(*SendStatusUpdateResponse); i {
case 0:
return &v.state
case 1:
@@ -2969,7 +3355,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*CreateProxyPeerResponse); i {
+ switch v := v.(*CreateProxyPeerRequest); i {
case 0:
return &v.state
case 1:
@@ -2981,7 +3367,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*GetOIDCURLRequest); i {
+ switch v := v.(*CreateProxyPeerResponse); i {
case 0:
return &v.state
case 1:
@@ -2993,7 +3379,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*GetOIDCURLResponse); i {
+ switch v := v.(*GetOIDCURLRequest); i {
case 0:
return &v.state
case 1:
@@ -3005,7 +3391,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ValidateSessionRequest); i {
+ switch v := v.(*GetOIDCURLResponse); i {
case 0:
return &v.state
case 1:
@@ -3017,7 +3403,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ValidateSessionResponse); i {
+ switch v := v.(*ValidateSessionRequest); i {
case 0:
return &v.state
case 1:
@@ -3029,7 +3415,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SyncMappingsRequest); i {
+ switch v := v.(*ValidateSessionResponse); i {
case 0:
return &v.state
case 1:
@@ -3041,7 +3427,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SyncMappingsInit); i {
+ switch v := v.(*ValidateTunnelPeerRequest); i {
case 0:
return &v.state
case 1:
@@ -3053,7 +3439,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SyncMappingsAck); i {
+ switch v := v.(*ValidateTunnelPeerResponse); i {
case 0:
return &v.state
case 1:
@@ -3065,6 +3451,42 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsInit); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsAck); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*SyncMappingsResponse); i {
case 0:
return &v.state
@@ -3084,8 +3506,8 @@ func file_proxy_service_proto_init() {
(*AuthenticateRequest_HeaderAuth)(nil),
}
file_proxy_service_proto_msgTypes[17].OneofWrappers = []interface{}{}
- file_proxy_service_proto_msgTypes[20].OneofWrappers = []interface{}{}
- file_proxy_service_proto_msgTypes[25].OneofWrappers = []interface{}{
+ file_proxy_service_proto_msgTypes[21].OneofWrappers = []interface{}{}
+ file_proxy_service_proto_msgTypes[28].OneofWrappers = []interface{}{
(*SyncMappingsRequest_Init)(nil),
(*SyncMappingsRequest_Ack)(nil),
}
@@ -3095,7 +3517,7 @@ func file_proxy_service_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proxy_service_proto_rawDesc,
NumEnums: 3,
- NumMessages: 31,
+ NumMessages: 34,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto
index d1171b27e..71e18c721 100644
--- a/shared/management/proto/proxy_service.proto
+++ b/shared/management/proto/proxy_service.proto
@@ -34,6 +34,15 @@ service ProxyService {
// ValidateSession validates a session token and checks user access permissions.
// Called by the proxy after receiving a session token from OIDC callback.
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
+
+ // ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
+ // checks the resolved user's access against the service's access_groups.
+ // Acts as a fast-path equivalent of OIDC for requests originating on the
+ // netbird mesh: when the source IP maps to a known peer in the calling
+ // account and that peer is in the service's access_groups, the proxy can
+ // issue a session cookie without redirecting through the OIDC flow.
+ // Mirrors ValidateSession's response shape.
+ rpc ValidateTunnelPeer(ValidateTunnelPeerRequest) returns (ValidateTunnelPeerResponse);
}
// ProxyCapabilities describes what a proxy can handle.
@@ -45,6 +54,13 @@ message ProxyCapabilities {
optional bool require_subdomain = 2;
// Whether the proxy has CrowdSec configured and can enforce IP reputation checks.
optional bool supports_crowdsec = 3;
+ // Whether the proxy is running embedded in the netbird client and serving
+ // exclusively over the WireGuard tunnel (i.e. `netbird proxy` rather than
+ // the standalone netbird-proxy binary). Surfaces upstream so dashboards can
+ // distinguish per-peer / private clusters from centralised ones.
+ optional bool private = 4;
+ // Whether the proxy enforces ProxyMapping.private (fails closed on ValidateTunnelPeer failure). Management MUST NOT stream private mappings to proxies that don't claim this.
+ optional bool supports_private_service = 5;
}
// GetMappingUpdateRequest is sent to initialise a mapping stream.
@@ -86,6 +102,11 @@ message PathTargetOptions {
bool proxy_protocol = 5;
// Idle timeout before a UDP session is reaped.
google.protobuf.Duration session_idle_timeout = 6;
+ // When true, the proxy dials this target via the host's network stack
+ // instead of through the embedded NetBird client. Useful for upstreams
+ // reachable without WireGuard (public APIs, LAN services, localhost
+ // sidecars). Defaults to false โ embedded client is the standard path.
+ bool direct_upstream = 7;
}
message PathMapping {
@@ -138,6 +159,8 @@ message ProxyMapping {
// For L4/TLS: the port the proxy listens on.
int32 listen_port = 11;
AccessRestrictions access_restrictions = 12;
+ // NetBird-only: the proxy MUST call ValidateTunnelPeer and fail closed; operator auth schemes are bypassed.
+ bool private = 13;
}
// SendAccessLogRequest consists of one or more AccessLogs from a Proxy.
@@ -213,6 +236,25 @@ message SendStatusUpdateRequest {
ProxyStatus status = 3;
bool certificate_issued = 4;
optional string error_message = 5;
+ // Per-account inbound listener state for the account that owns
+ // service_id. Populated only when --private-inbound is enabled and the
+ // embedded client for the account is up. Field numbers >=50 reserved
+ // for observability extensions.
+ optional ProxyInboundListener inbound_listener = 50;
+}
+
+// ProxyInboundListener describes a per-account inbound listener that the
+// proxy has bound on the embedded netstack of the account's WireGuard
+// client. Surfaced so dashboards can render "this account is reachable
+// at : on this proxy".
+message ProxyInboundListener {
+ // Tunnel IP the embedded netstack listens on. Same address other peers
+ // in the account see for the proxy peer.
+ string tunnel_ip = 1;
+ // TLS port served on tunnel_ip (auto-detected, default 443).
+ uint32 https_port = 2;
+ // Plain-HTTP port served on tunnel_ip (auto-detected, default 80).
+ uint32 http_port = 3;
}
// SendStatusUpdateResponse is intentionally empty to allow for future expansion
@@ -254,6 +296,52 @@ message ValidateSessionResponse {
string user_id = 2;
string user_email = 3;
string denied_reason = 4;
+ // peer_group_ids carries the calling user's group memberships so the
+ // proxy can authorise policy-aware middlewares without an additional
+ // management round-trip.
+ repeated string peer_group_ids = 5;
+ // peer_group_names carries the human-readable display names for the
+ // ids in peer_group_ids, ordered identically (positional pairing).
+ // Stamped onto upstream requests as X-NetBird-Groups so downstream
+ // services can read names rather than opaque ids.
+ repeated string peer_group_names = 6;
+}
+
+// ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the
+// service domain whose group requirements should gate access. The calling
+// account is inferred from the proxy's gRPC metadata (ProxyToken).
+message ValidateTunnelPeerRequest {
+ string tunnel_ip = 1;
+ string domain = 2;
+}
+
+// ValidateTunnelPeerResponse mirrors ValidateSessionResponse plus a freshly
+// minted session_token: when valid is true, the proxy installs the token as
+// a session cookie so subsequent requests skip the management round-trip,
+// matching the OIDC flow's UX. denied_reason values:
+// "peer_not_found" โ no peer with that tunnel IP in the calling account
+// "no_user" โ peer exists but is not bound to a user
+// "service_not_found"
+// "account_mismatch"
+// "not_in_group" โ peer resolved but not in service.access_groups
+message ValidateTunnelPeerResponse {
+ bool valid = 1;
+ string user_id = 2;
+ string user_email = 3;
+ string denied_reason = 4;
+ // session_token is set only when valid is true. Same shape as the JWT
+ // the OIDC flow produces โ proxy installs it via setSessionCookie so the
+ // tunnel fast-path is indistinguishable from OIDC for subsequent requests.
+ string session_token = 5;
+ // peer_group_ids carries the resolved peer's user group memberships so
+ // the proxy can authorise policy-aware middlewares without an additional
+ // management round-trip.
+ repeated string peer_group_ids = 6;
+ // peer_group_names carries the human-readable display names for the
+ // ids in peer_group_ids, ordered identically (positional pairing).
+ // Stamped onto upstream requests as X-NetBird-Groups so downstream
+ // services can read names rather than opaque ids.
+ repeated string peer_group_names = 7;
}
// SyncMappingsRequest is sent by the proxy on the bidirectional SyncMappings
@@ -287,3 +375,4 @@ message SyncMappingsResponse {
// initial_sync_complete is set on the last message of the initial snapshot.
bool initial_sync_complete = 2;
}
+
diff --git a/shared/management/proto/proxy_service_grpc.pb.go b/shared/management/proto/proxy_service_grpc.pb.go
index fdc031ed7..40064fe61 100644
--- a/shared/management/proto/proxy_service_grpc.pb.go
+++ b/shared/management/proto/proxy_service_grpc.pb.go
@@ -35,6 +35,14 @@ type ProxyServiceClient interface {
// ValidateSession validates a session token and checks user access permissions.
// Called by the proxy after receiving a session token from OIDC callback.
ValidateSession(ctx context.Context, in *ValidateSessionRequest, opts ...grpc.CallOption) (*ValidateSessionResponse, error)
+ // ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
+ // checks the resolved user's access against the service's access_groups.
+ // Acts as a fast-path equivalent of OIDC for requests originating on the
+ // netbird mesh: when the source IP maps to a known peer in the calling
+ // account and that peer is in the service's access_groups, the proxy can
+ // issue a session cookie without redirecting through the OIDC flow.
+ // Mirrors ValidateSession's response shape.
+ ValidateTunnelPeer(ctx context.Context, in *ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*ValidateTunnelPeerResponse, error)
}
type proxyServiceClient struct {
@@ -162,6 +170,15 @@ func (c *proxyServiceClient) ValidateSession(ctx context.Context, in *ValidateSe
return out, nil
}
+func (c *proxyServiceClient) ValidateTunnelPeer(ctx context.Context, in *ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*ValidateTunnelPeerResponse, error) {
+ out := new(ValidateTunnelPeerResponse)
+ err := c.cc.Invoke(ctx, "/management.ProxyService/ValidateTunnelPeer", in, out, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
// ProxyServiceServer is the server API for ProxyService service.
// All implementations must embed UnimplementedProxyServiceServer
// for forward compatibility
@@ -183,6 +200,14 @@ type ProxyServiceServer interface {
// ValidateSession validates a session token and checks user access permissions.
// Called by the proxy after receiving a session token from OIDC callback.
ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error)
+ // ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
+ // checks the resolved user's access against the service's access_groups.
+ // Acts as a fast-path equivalent of OIDC for requests originating on the
+ // netbird mesh: when the source IP maps to a known peer in the calling
+ // account and that peer is in the service's access_groups, the proxy can
+ // issue a session cookie without redirecting through the OIDC flow.
+ // Mirrors ValidateSession's response shape.
+ ValidateTunnelPeer(context.Context, *ValidateTunnelPeerRequest) (*ValidateTunnelPeerResponse, error)
mustEmbedUnimplementedProxyServiceServer()
}
@@ -214,6 +239,9 @@ func (UnimplementedProxyServiceServer) GetOIDCURL(context.Context, *GetOIDCURLRe
func (UnimplementedProxyServiceServer) ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ValidateSession not implemented")
}
+func (UnimplementedProxyServiceServer) ValidateTunnelPeer(context.Context, *ValidateTunnelPeerRequest) (*ValidateTunnelPeerResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method ValidateTunnelPeer not implemented")
+}
func (UnimplementedProxyServiceServer) mustEmbedUnimplementedProxyServiceServer() {}
// UnsafeProxyServiceServer may be embedded to opt out of forward compatibility for this service.
@@ -382,6 +410,24 @@ func _ProxyService_ValidateSession_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
+func _ProxyService_ValidateTunnelPeer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(ValidateTunnelPeerRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(ProxyServiceServer).ValidateTunnelPeer(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: "/management.ProxyService/ValidateTunnelPeer",
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(ProxyServiceServer).ValidateTunnelPeer(ctx, req.(*ValidateTunnelPeerRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
// ProxyService_ServiceDesc is the grpc.ServiceDesc for ProxyService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -413,6 +459,10 @@ var ProxyService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ValidateSession",
Handler: _ProxyService_ValidateSession_Handler,
},
+ {
+ MethodName: "ValidateTunnelPeer",
+ Handler: _ProxyService_ValidateTunnelPeer_Handler,
+ },
},
Streams: []grpc.StreamDesc{
{
From b3b0feb3b8500f8ee181445ceb1f5b79a4426269 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 26 May 2026 01:38:21 +0900
Subject: [PATCH 07/44] [client] Filter scoped/cloned default routes from BSD
network monitor RTM_ADD (#6208)
---
.../networkmonitor/check_change_common.go | 20 ++++++++++++------
.../systemops/routeflags_addfilter_bsd.go | 9 ++++++++
.../systemops/routeflags_addfilter_darwin.go | 21 +++++++++++++++++++
3 files changed, 44 insertions(+), 6 deletions(-)
create mode 100644 client/internal/routemanager/systemops/routeflags_addfilter_bsd.go
create mode 100644 client/internal/routemanager/systemops/routeflags_addfilter_darwin.go
diff --git a/client/internal/networkmonitor/check_change_common.go b/client/internal/networkmonitor/check_change_common.go
index a4a4f76ac..f693081a6 100644
--- a/client/internal/networkmonitor/check_change_common.go
+++ b/client/internal/networkmonitor/check_change_common.go
@@ -50,7 +50,7 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
switch msg.Type {
// handle route changes
case unix.RTM_ADD, syscall.RTM_DELETE:
- route, err := parseRouteMessage(buf[:n])
+ route, flags, err := parseRouteMessage(buf[:n])
if err != nil {
log.Debugf("Network monitor: error parsing routing message: %v", err)
continue
@@ -66,6 +66,10 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
}
switch msg.Type {
case unix.RTM_ADD:
+ if systemops.IgnoreAddedDefaultRoute(flags) {
+ log.Debugf("Network monitor: ignoring added default route via %s, interface %s, flags %#x", route.Gw, intf, flags)
+ continue
+ }
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
return nil
case unix.RTM_DELETE:
@@ -78,22 +82,26 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
}
}
-func parseRouteMessage(buf []byte) (*systemops.Route, error) {
+func parseRouteMessage(buf []byte) (*systemops.Route, int, error) {
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
if err != nil {
- return nil, fmt.Errorf("parse RIB: %v", err)
+ return nil, 0, fmt.Errorf("parse RIB: %v", err)
}
if len(msgs) != 1 {
- return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
+ return nil, 0, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
}
msg, ok := msgs[0].(*route.RouteMessage)
if !ok {
- return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
+ return nil, 0, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
}
- return systemops.MsgToRoute(msg)
+ r, err := systemops.MsgToRoute(msg)
+ if err != nil {
+ return nil, 0, err
+ }
+ return r, msg.Flags, nil
}
// waitReadable blocks until fd has data to read, or ctx is cancelled.
diff --git a/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go b/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go
new file mode 100644
index 000000000..45a1bfceb
--- /dev/null
+++ b/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go
@@ -0,0 +1,9 @@
+//go:build dragonfly || freebsd || netbsd || openbsd
+
+package systemops
+
+// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
+// given flags should be ignored by the network monitor.
+func IgnoreAddedDefaultRoute(flags int) bool {
+ return filterRoutesByFlags(flags)
+}
diff --git a/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go b/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go
new file mode 100644
index 000000000..e8f655387
--- /dev/null
+++ b/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go
@@ -0,0 +1,21 @@
+//go:build darwin
+
+package systemops
+
+import "golang.org/x/sys/unix"
+
+// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
+// given flags should be ignored by the network monitor. Scoped routes
+// (RTF_IFSCOPE) are tied to a specific interface index and cannot replace the
+// unscoped default the kernel uses for general egress, so flapping ones (e.g.
+// Wi-Fi calling IMS tunnels on ipsec0, Docker bridges, scoped utun defaults)
+// must not trigger an engine restart.
+func IgnoreAddedDefaultRoute(flags int) bool {
+ if filterRoutesByFlags(flags) {
+ return true
+ }
+ if flags&unix.RTF_IFSCOPE != 0 {
+ return true
+ }
+ return false
+}
From 4983b5cf17bfaaa733f2084b97a521143b94b179 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 26 May 2026 01:38:48 +0900
Subject: [PATCH 08/44] [client] Match DNS wildcard handlers on label
boundaries (#6255)
---
client/internal/dns/handler_chain.go | 3 +-
client/internal/dns/handler_chain_test.go | 61 +++++++++++++++++++++++
2 files changed, 62 insertions(+), 2 deletions(-)
diff --git a/client/internal/dns/handler_chain.go b/client/internal/dns/handler_chain.go
index 57e7722d4..dc20146eb 100644
--- a/client/internal/dns/handler_chain.go
+++ b/client/internal/dns/handler_chain.go
@@ -339,8 +339,7 @@ func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool {
case entry.Pattern == ".":
return true
case entry.IsWildcard:
- parts := strings.Split(strings.TrimSuffix(qname, entry.Pattern), ".")
- return len(parts) >= 2 && strings.HasSuffix(qname, entry.Pattern)
+ return strings.HasSuffix(qname, "."+entry.Pattern)
default:
// For non-wildcard patterns:
// If handler wants subdomain matching, allow suffix match
diff --git a/client/internal/dns/handler_chain_test.go b/client/internal/dns/handler_chain_test.go
index 034a760dc..b3db97ba3 100644
--- a/client/internal/dns/handler_chain_test.go
+++ b/client/internal/dns/handler_chain_test.go
@@ -164,6 +164,54 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) {
matchSubdomains: true,
shouldMatch: true,
},
+ {
+ name: "wildcard label-boundary mismatch (suffix overlap)",
+ handlerDomain: "*.b.test.",
+ queryDomain: "x.ab.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: false,
+ },
+ {
+ name: "wildcard label-boundary match",
+ handlerDomain: "*.b.test.",
+ queryDomain: "x.b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: true,
+ },
+ {
+ name: "wildcard multi-label match",
+ handlerDomain: "*.b.test.",
+ queryDomain: "x.y.b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: true,
+ },
+ {
+ name: "wildcard no match on multi-label apex",
+ handlerDomain: "*.b.test.",
+ queryDomain: "b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: false,
+ },
+ {
+ name: "wildcard no match on unrelated suffix containment",
+ handlerDomain: "*.example.com.",
+ queryDomain: "notexample.com.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: false,
+ },
+ {
+ name: "wildcard accepts pattern registered without trailing dot",
+ handlerDomain: "*.b.test",
+ queryDomain: "x.b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: true,
+ },
}
for _, tt := range tests {
@@ -273,6 +321,19 @@ func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) {
expectedCalls: 1,
expectedHandler: 2, // highest priority matching handler should be called
},
+ {
+ name: "overlapping wildcard suffixes route to correct handler",
+ handlers: []struct {
+ pattern string
+ priority int
+ }{
+ {pattern: "*.b.test.", priority: nbdns.PriorityDNSRoute},
+ {pattern: "*.ab.test.", priority: nbdns.PriorityDNSRoute},
+ },
+ queryDomain: "app.ab.test.",
+ expectedCalls: 1,
+ expectedHandler: 1,
+ },
{
name: "root zone with specific domain",
handlers: []struct {
From d542c60e2182ac83951056e2ac6a96b9e15c1593 Mon Sep 17 00:00:00 2001
From: Philip Laine
Date: Mon, 25 May 2026 21:00:24 +0200
Subject: [PATCH 09/44] Refactor Linux system info to use syscalls (#6230)
---
client/system/info_linux.go | 39 ++++++++++++-------------------------
1 file changed, 12 insertions(+), 27 deletions(-)
diff --git a/client/system/info_linux.go b/client/system/info_linux.go
index 6c7a23b95..de37a9f5b 100644
--- a/client/system/info_linux.go
+++ b/client/system/info_linux.go
@@ -3,15 +3,14 @@
package system
import (
- "bytes"
"context"
"os"
- "os/exec"
"regexp"
"runtime"
- "strings"
"time"
+ "golang.org/x/sys/unix"
+
log "github.com/sirupsen/logrus"
"github.com/zcalusic/sysinfo"
@@ -29,19 +28,11 @@ func UpdateStaticInfoAsync() {
// GetInfo retrieves and parses the system information
func GetInfo(ctx context.Context) *Info {
- info := _getInfo()
- for strings.Contains(info, "broken pipe") {
- info = _getInfo()
- time.Sleep(500 * time.Millisecond)
- }
-
- osStr := strings.ReplaceAll(info, "\n", "")
- osStr = strings.ReplaceAll(osStr, "\r\n", "")
- osInfo := strings.Split(osStr, " ")
+ kernelName, kernelVersion, kernelPlatform := kernelInfo()
osName, osVersion := readOsReleaseFile()
if osName == "" {
- osName = osInfo[3]
+ osName = kernelName
}
systemHostname, _ := os.Hostname()
@@ -58,8 +49,8 @@ func GetInfo(ctx context.Context) *Info {
}
gio := &Info{
- Kernel: osInfo[0],
- Platform: osInfo[2],
+ Kernel: kernelName,
+ Platform: kernelPlatform,
OS: osName,
OSVersion: osVersion,
Hostname: extractDeviceName(ctx, systemHostname),
@@ -67,7 +58,7 @@ func GetInfo(ctx context.Context) *Info {
CPUs: runtime.NumCPU(),
NetbirdVersion: version.NetbirdVersion(),
UIVersion: extractUserAgent(ctx),
- KernelVersion: osInfo[1],
+ KernelVersion: kernelVersion,
NetworkAddresses: addrs,
SystemSerialNumber: si.SystemSerialNumber,
SystemProductName: si.SystemProductName,
@@ -78,18 +69,12 @@ func GetInfo(ctx context.Context) *Info {
return gio
}
-func _getInfo() string {
- cmd := exec.Command("uname", "-srio")
- cmd.Stdin = strings.NewReader("some")
- var out bytes.Buffer
- var stderr bytes.Buffer
- cmd.Stdout = &out
- cmd.Stderr = &stderr
- err := cmd.Run()
- if err != nil {
- log.Warnf("getInfo: %s", err)
+func kernelInfo() (string, string, string) {
+ var uts unix.Utsname
+ if err := unix.Uname(&uts); err != nil {
+ return "", "", ""
}
- return out.String()
+ return unix.ByteSliceToString(uts.Sysname[:]), unix.ByteSliceToString(uts.Release[:]), unix.ByteSliceToString(uts.Machine[:])
}
func sysInfo() (string, string, string) {
From e89b1e0596bd0185193b065faa34d13b216d18bb Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 26 May 2026 18:51:53 +0900
Subject: [PATCH 10/44] [proxy, client] Bound embed client WireGuard per-Device
memory (#5962)
---
client/embed/embed.go | 47 ++++++++
client/internal/engine.go | 23 ++++
go.mod | 2 +-
go.sum | 4 +-
proxy/cmd/proxy/cmd/debug.go | 30 +++++
proxy/cmd/proxy/cmd/root.go | 51 +++++++++
proxy/internal/debug/client.go | 57 ++++++++++
proxy/internal/debug/handler.go | 164 +++++++++++++++++++++++-----
proxy/internal/roundtrip/netbird.go | 4 +-
proxy/lifecycle.go | 6 +
proxy/server.go | 7 ++
11 files changed, 365 insertions(+), 30 deletions(-)
diff --git a/client/embed/embed.go b/client/embed/embed.go
index 7e7f6c337..04bc60fb8 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -12,6 +12,7 @@ import (
"sync"
"github.com/sirupsen/logrus"
+ wgdevice "golang.zx2c4.com/wireguard/device"
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/iface"
@@ -100,6 +101,26 @@ type Options struct {
MTU *uint16
// DNSLabels defines additional DNS labels configured in the peer.
DNSLabels []string
+ // Performance configures the tunnel's buffer pool cap and batch size.
+ Performance Performance
+}
+
+// Performance configures the embedded client's tunnel memory/throughput knobs.
+//
+// These settings are process-global: any non-nil field also becomes the
+// default for Clients constructed by later embed.New calls in the same
+// process. Nil fields are ignored.
+type Performance struct {
+ // PreallocatedBuffersPerPool caps the per-tunnel buffer pool. Zero
+ // leaves the pool unbounded. Lower values trade throughput for a
+ // tighter memory ceiling. May also be changed on a running Client via
+ // Client.SetPerformance, provided this field was nonzero at construction.
+ PreallocatedBuffersPerPool *uint32
+ // MaxBatchSize overrides the number of packets the tunnel reads or
+ // writes per syscall, which also bounds eager buffer allocation per
+ // worker. Zero uses the platform default. Applied at construction
+ // only; ignored by Client.SetPerformance.
+ MaxBatchSize *uint32
}
// validateCredentials checks that exactly one credential type is provided
@@ -199,6 +220,13 @@ func New(opts Options) (*Client, error) {
config.PrivateKey = opts.PrivateKey
}
+ if opts.Performance.PreallocatedBuffersPerPool != nil {
+ wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
+ }
+ if opts.Performance.MaxBatchSize != nil {
+ wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize)
+ }
+
return &Client{
deviceName: opts.DeviceName,
setupKey: opts.SetupKey,
@@ -495,6 +523,25 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
}
+// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
+// takes effect, and only when it was nonzero at construction;
+// MaxBatchSize is construction-only and returns an error if set here.
+//
+// Returns ErrClientNotStarted / ErrEngineNotStarted if the Client is not
+// running yet.
+func (c *Client) SetPerformance(t Performance) error {
+ if t.MaxBatchSize != nil {
+ return errors.New("MaxBatchSize is construction-only and cannot be changed at runtime")
+ }
+ engine, err := c.getEngine()
+ if err != nil {
+ return err
+ }
+ return engine.SetPerformance(internal.Performance{
+ PreallocatedBuffersPerPool: t.PreallocatedBuffersPerPool,
+ })
+}
+
// StartCapture begins capturing packets on this client's tunnel device.
// Only one capture can be active at a time; starting a new one stops the previous.
// Call StopCapture (or CaptureSession.Stop) to end it.
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 3bd0d4621..b82eb95b7 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -1967,6 +1967,29 @@ func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
return e.clientMetrics
}
+// Performance bundles runtime-adjustable tunnel pool knobs.
+// See Engine.SetPerformance. Nil fields are ignored.
+type Performance struct {
+ PreallocatedBuffersPerPool *uint32
+}
+
+// SetPerformance applies the given tuning to this engine's live Device.
+func (e *Engine) SetPerformance(t Performance) error {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+ if e.wgInterface == nil {
+ return fmt.Errorf("wg interface not initialized")
+ }
+ dev := e.wgInterface.GetWGDevice()
+ if dev == nil {
+ return fmt.Errorf("wg device not initialized")
+ }
+ if t.PreallocatedBuffersPerPool != nil {
+ dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
+ }
+ return nil
+}
+
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
iface, err := net.InterfaceByName(ifaceName)
if err != nil {
diff --git a/go.mod b/go.mod
index 7c1a95e79..ea0d8d73d 100644
--- a/go.mod
+++ b/go.mod
@@ -335,7 +335,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024
replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949
-replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0
+replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f
replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6
diff --git a/go.sum b/go.sum
index 53789f49d..f95efefa6 100644
--- a/go.sum
+++ b/go.sum
@@ -499,8 +499,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
-github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0 h1:h/QnNzm7xzHPm+gajcblYUOclrW2FeNeDlUNj6tTWKQ=
-github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
+github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f h1:ff2D57RBjWtyQ2wVwJOxOgXAXOe/J2lJWtSX0Bz/BRk=
+github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
diff --git a/proxy/cmd/proxy/cmd/debug.go b/proxy/cmd/proxy/cmd/debug.go
index 49afc7638..360c7a516 100644
--- a/proxy/cmd/proxy/cmd/debug.go
+++ b/proxy/cmd/proxy/cmd/debug.go
@@ -109,6 +109,22 @@ var debugStopCmd = &cobra.Command{
SilenceUsage: true,
}
+var debugPerfCmd = &cobra.Command{
+ Use: "perf ",
+ Short: "Live-retune the tunnel buffer pool cap on all running clients",
+ Args: cobra.ExactArgs(1),
+ RunE: runDebugPerfSet,
+ SilenceUsage: true,
+}
+
+var debugRuntimeCmd = &cobra.Command{
+ Use: "runtime",
+ Short: "Show runtime stats (heap, goroutines, RSS)",
+ Args: cobra.NoArgs,
+ RunE: runDebugRuntime,
+ SilenceUsage: true,
+}
+
var debugCaptureCmd = &cobra.Command{
Use: "capture [filter expression]",
Short: "Capture packets on a client's WireGuard interface",
@@ -159,6 +175,8 @@ func init() {
debugCmd.AddCommand(debugLogCmd)
debugCmd.AddCommand(debugStartCmd)
debugCmd.AddCommand(debugStopCmd)
+ debugCmd.AddCommand(debugPerfCmd)
+ debugCmd.AddCommand(debugRuntimeCmd)
debugCmd.AddCommand(debugCaptureCmd)
rootCmd.AddCommand(debugCmd)
@@ -220,6 +238,18 @@ func runDebugStop(cmd *cobra.Command, args []string) error {
return getDebugClient(cmd).StopClient(cmd.Context(), args[0])
}
+func runDebugPerfSet(cmd *cobra.Command, args []string) error {
+ n, err := strconv.ParseUint(args[0], 10, 32)
+ if err != nil {
+ return fmt.Errorf("invalid value %q: %w", args[0], err)
+ }
+ return getDebugClient(cmd).PerfSet(cmd.Context(), uint32(n))
+}
+
+func runDebugRuntime(cmd *cobra.Command, _ []string) error {
+ return getDebugClient(cmd).Runtime(cmd.Context())
+}
+
func runDebugCapture(cmd *cobra.Command, args []string) error {
duration, _ := cmd.Flags().GetDuration("duration")
forcePcap, _ := cmd.Flags().GetBool("pcap")
diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go
index 5970886da..405fa2789 100644
--- a/proxy/cmd/proxy/cmd/root.go
+++ b/proxy/cmd/proxy/cmd/root.go
@@ -15,11 +15,22 @@ import (
"github.com/netbirdio/netbird/shared/management/domain"
+ "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy"
nbacme "github.com/netbirdio/netbird/proxy/internal/acme"
"github.com/netbirdio/netbird/util"
)
+const (
+ // envPreallocatedBuffers caps the per-tunnel buffer pool. Zero (unset)
+ // keeps the upstream uncapped default.
+ envPreallocatedBuffers = "NB_PROXY_PREALLOCATED_BUFFERS"
+ // envMaxBatchSize overrides the per-tunnel batch size, which controls
+ // how many buffers each receive/TUN worker eagerly allocates. Zero
+ // (unset) keeps the platform default.
+ envMaxBatchSize = "NB_PROXY_MAX_BATCH_SIZE"
+)
+
const DefaultManagementURL = "https://api.netbird.io:443"
// envProxyToken is the environment variable name for the proxy access token.
@@ -148,6 +159,45 @@ func runServer(cmd *cobra.Command, args []string) error {
logger.Infof("configured log level: %s", level)
+ var wgPool, wgBatch uint64
+ var perf embed.Performance
+ if raw := os.Getenv(envPreallocatedBuffers); raw != "" {
+ n, err := strconv.ParseUint(raw, 10, 32)
+ if err != nil {
+ return fmt.Errorf("invalid %s %q: %w", envPreallocatedBuffers, raw, err)
+ }
+ wgPool = n
+ v := uint32(n)
+ perf.PreallocatedBuffersPerPool = &v
+ logger.Infof("tunnel preallocated buffers per pool: %d", n)
+ }
+ if raw := os.Getenv(envMaxBatchSize); raw != "" {
+ n, err := strconv.ParseUint(raw, 10, 32)
+ if err != nil {
+ return fmt.Errorf("invalid %s %q: %w", envMaxBatchSize, raw, err)
+ }
+ wgBatch = n
+ v := uint32(n)
+ perf.MaxBatchSize = &v
+ logger.Infof("tunnel max batch size override: %d", n)
+ }
+ if wgPool > 0 {
+ // Each bind recv goroutine (IPv4 + IPv6 + ICE relay) plus
+ // RoutineReadFromTUN eagerly reserves `batch` message buffers for
+ // the lifetime of the Device. A pool cap below that floor blocks
+ // the receive pipeline at startup.
+ batch := wgBatch
+ if batch == 0 {
+ batch = 128
+ }
+ const recvGoroutines = 4
+ floor := batch * recvGoroutines
+ if wgPool < floor {
+ logger.Warnf("%s=%d is below the eager-allocation floor (~%d for batch=%d); startup may deadlock",
+ envPreallocatedBuffers, wgPool, floor, batch)
+ }
+ }
+
switch forwardedProto {
case "auto", "http", "https":
default:
@@ -188,6 +238,7 @@ func runServer(cmd *cobra.Command, args []string) error {
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
WildcardCertDir: wildcardCertDir,
WireguardPort: wgPort,
+ Performance: perf,
ProxyProtocol: proxyProtocol,
PreSharedKey: preSharedKey,
SupportsCustomPorts: supportsCustomPorts,
diff --git a/proxy/internal/debug/client.go b/proxy/internal/debug/client.go
index 736781652..77772637c 100644
--- a/proxy/internal/debug/client.go
+++ b/proxy/internal/debug/client.go
@@ -333,6 +333,63 @@ func (c *Client) printLogLevelResult(data map[string]any) {
}
}
+// PerfSet live-retunes the tunnel buffer pool cap on all running embedded
+// clients. Batch size is not live-tunable; configure it at proxy startup.
+func (c *Client) PerfSet(ctx context.Context, value uint32) error {
+ path := fmt.Sprintf("/debug/perf?value=%d", value)
+ return c.fetchAndPrint(ctx, path, c.printPerfSet)
+}
+
+func (c *Client) printPerfSet(data map[string]any) {
+ if errMsg, ok := data["error"].(string); ok && errMsg != "" {
+ c.printError(data)
+ return
+ }
+ val, _ := data["value"].(float64)
+ applied, _ := data["applied"].(float64)
+ _, _ = fmt.Fprintf(c.out, "Pool cap set to: %d\n", uint32(val))
+ _, _ = fmt.Fprintf(c.out, "Applied to %d live clients\n", int(applied))
+ if failed, ok := data["failed"].(map[string]any); ok && len(failed) > 0 {
+ _, _ = fmt.Fprintln(c.out, "Failed:")
+ for k, v := range failed {
+ _, _ = fmt.Fprintf(c.out, " %s: %v\n", k, v)
+ }
+ }
+}
+
+// Runtime fetches runtime stats (heap, goroutines, RSS).
+func (c *Client) Runtime(ctx context.Context) error {
+ return c.fetchAndPrint(ctx, "/debug/runtime", c.printRuntime)
+}
+
+func (c *Client) printRuntime(data map[string]any) {
+ i := func(k string) uint64 {
+ v, _ := data[k].(float64)
+ return uint64(v)
+ }
+ mb := func(n uint64) string { return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) }
+
+ _, _ = fmt.Fprintf(c.out, "Uptime: %v\n", data["uptime"])
+ _, _ = fmt.Fprintf(c.out, "Go: %v on %d CPU (GOMAXPROCS=%d)\n", data["go_version"], uint32(i("num_cpu")), uint32(i("gomaxprocs")))
+ _, _ = fmt.Fprintf(c.out, "Goroutines: %d\n", i("goroutines"))
+ _, _ = fmt.Fprintf(c.out, "Live objects: %d\n", i("live_objects"))
+ _, _ = fmt.Fprintf(c.out, "GC: %d cycles, %v pause total\n", i("num_gc"), time.Duration(i("pause_total_ns")))
+ _, _ = fmt.Fprintln(c.out, "Heap:")
+ _, _ = fmt.Fprintf(c.out, " alloc: %s\n", mb(i("heap_alloc")))
+ _, _ = fmt.Fprintf(c.out, " in-use: %s\n", mb(i("heap_inuse")))
+ _, _ = fmt.Fprintf(c.out, " idle: %s\n", mb(i("heap_idle")))
+ _, _ = fmt.Fprintf(c.out, " released: %s\n", mb(i("heap_released")))
+ _, _ = fmt.Fprintf(c.out, " sys: %s\n", mb(i("heap_sys")))
+ _, _ = fmt.Fprintf(c.out, "Total sys: %s\n", mb(i("sys")))
+ if _, ok := data["vm_rss"]; ok {
+ _, _ = fmt.Fprintln(c.out, "Process:")
+ _, _ = fmt.Fprintf(c.out, " VmRSS: %s\n", mb(i("vm_rss")))
+ _, _ = fmt.Fprintf(c.out, " VmSize: %s\n", mb(i("vm_size")))
+ _, _ = fmt.Fprintf(c.out, " VmData: %s\n", mb(i("vm_data")))
+ }
+ _, _ = fmt.Fprintf(c.out, "Clients: %d (%d started)\n", i("clients"), i("started"))
+}
+
// StartClient starts a specific client.
func (c *Client) StartClient(ctx context.Context, accountID string) error {
path := "/debug/clients/" + url.PathEscape(accountID) + "/start"
diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go
index 1dbfe1522..826c6817f 100644
--- a/proxy/internal/debug/handler.go
+++ b/proxy/internal/debug/handler.go
@@ -11,6 +11,8 @@ import (
"maps"
"net"
"net/http"
+ "os"
+ "runtime"
"slices"
"strconv"
"strings"
@@ -59,6 +61,7 @@ func sortedAccountIDs(m map[types.AccountID]roundtrip.ClientDebugInfo) []types.A
type clientProvider interface {
GetClient(accountID types.AccountID) (*nbembed.Client, bool)
ListClientsForDebug() map[types.AccountID]roundtrip.ClientDebugInfo
+ ListClientsForStartup() map[types.AccountID]*nbembed.Client
}
// InboundListenerInfo describes a per-account inbound listener as
@@ -165,6 +168,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handleListClients(w, r, wantJSON)
case "/debug/health":
h.handleHealth(w, r, wantJSON)
+ case "/debug/perf":
+ h.handlePerf(w, r)
+ case "/debug/runtime":
+ h.handleRuntime(w, r)
default:
if h.handleClientRoutes(w, r, path, wantJSON) {
return
@@ -258,10 +265,10 @@ func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON b
}
if wantJSON {
- clientsJSON := make([]map[string]interface{}, 0, len(clients))
+ clientsJSON := make([]map[string]any, 0, len(clients))
for _, id := range sortedIDs {
info := clients[id]
- clientsJSON = append(clientsJSON, map[string]interface{}{
+ clientsJSON = append(clientsJSON, map[string]any{
"account_id": info.AccountID,
"service_count": info.ServiceCount,
"service_keys": info.ServiceKeys,
@@ -270,7 +277,7 @@ func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON b
"age": time.Since(info.CreatedAt).Round(time.Second).String(),
})
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"version": version.NetbirdVersion(),
"uptime": time.Since(h.startTime).Round(time.Second).String(),
"client_count": len(clients),
@@ -352,10 +359,10 @@ func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, want
if h.inbound != nil {
inboundAll = h.inbound.InboundListeners()
}
- clientsJSON := make([]map[string]interface{}, 0, len(clients))
+ clientsJSON := make([]map[string]any, 0, len(clients))
for _, id := range sortedIDs {
info := clients[id]
- row := map[string]interface{}{
+ row := map[string]any{
"account_id": info.AccountID,
"service_count": info.ServiceCount,
"service_keys": info.ServiceKeys,
@@ -368,7 +375,7 @@ func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, want
}
clientsJSON = append(clientsJSON, row)
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"uptime": time.Since(h.startTime).Round(time.Second).String(),
"client_count": len(clients),
"clients": clientsJSON,
@@ -458,7 +465,7 @@ func (h *Handler) handleClientStatus(w http.ResponseWriter, r *http.Request, acc
})
if wantJSON {
- resp := map[string]interface{}{
+ resp := map[string]any{
"account_id": accountID,
"status": overview.FullDetailSummary(),
}
@@ -557,20 +564,20 @@ func (h *Handler) handleClientTools(w http.ResponseWriter, _ *http.Request, acco
func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
client, ok := h.provider.GetClient(accountID)
if !ok {
- h.writeJSON(w, map[string]interface{}{"error": "client not found"})
+ h.writeJSON(w, map[string]any{"error": "client not found"})
return
}
host := r.URL.Query().Get("host")
portStr := r.URL.Query().Get("port")
if host == "" || portStr == "" {
- h.writeJSON(w, map[string]interface{}{"error": "host and port parameters required"})
+ h.writeJSON(w, map[string]any{"error": "host and port parameters required"})
return
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
- h.writeJSON(w, map[string]interface{}{"error": "invalid port"})
+ h.writeJSON(w, map[string]any{"error": "invalid port"})
return
}
@@ -594,7 +601,7 @@ func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountI
conn, err := client.Dial(ctx, network, address)
if err != nil {
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": false,
"host": host,
"port": port,
@@ -609,39 +616,38 @@ func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountI
}
latency := time.Since(start)
- resp := map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": true,
"host": host,
"port": port,
"remote": remote,
"latency_ms": latency.Milliseconds(),
"latency": formatDuration(latency),
- }
- h.writeJSON(w, resp)
+ })
}
func (h *Handler) handleLogLevel(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
client, ok := h.provider.GetClient(accountID)
if !ok {
- h.writeJSON(w, map[string]interface{}{"error": "client not found"})
+ h.writeJSON(w, map[string]any{"error": "client not found"})
return
}
level := r.URL.Query().Get("level")
if level == "" {
- h.writeJSON(w, map[string]interface{}{"error": "level parameter required (trace, debug, info, warn, error)"})
+ h.writeJSON(w, map[string]any{"error": "level parameter required (trace, debug, info, warn, error)"})
return
}
if err := client.SetLogLevel(level); err != nil {
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": true,
"level": level,
})
@@ -652,7 +658,7 @@ const clientActionTimeout = 30 * time.Second
func (h *Handler) handleClientStart(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
client, ok := h.provider.GetClient(accountID)
if !ok {
- h.writeJSON(w, map[string]interface{}{"error": "client not found"})
+ h.writeJSON(w, map[string]any{"error": "client not found"})
return
}
@@ -660,14 +666,14 @@ func (h *Handler) handleClientStart(w http.ResponseWriter, r *http.Request, acco
defer cancel()
if err := client.Start(ctx); err != nil {
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": true,
"message": "client started",
})
@@ -676,7 +682,7 @@ func (h *Handler) handleClientStart(w http.ResponseWriter, r *http.Request, acco
func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
client, ok := h.provider.GetClient(accountID)
if !ok {
- h.writeJSON(w, map[string]interface{}{"error": "client not found"})
+ h.writeJSON(w, map[string]any{"error": "client not found"})
return
}
@@ -684,19 +690,125 @@ func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accou
defer cancel()
if err := client.Stop(ctx); err != nil {
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": true,
"message": "client stopped",
})
}
+func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) {
+ raw := r.URL.Query().Get("value")
+ if raw == "" {
+ http.Error(w, "value parameter is required", http.StatusBadRequest)
+ return
+ }
+ n, err := strconv.ParseUint(raw, 10, 32)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("invalid value %q: %v", raw, err), http.StatusBadRequest)
+ return
+ }
+
+ capN := uint32(n)
+ applied := 0
+ failed := map[string]string{}
+ for accountID, client := range h.provider.ListClientsForStartup() {
+ if err := client.SetPerformance(nbembed.Performance{PreallocatedBuffersPerPool: &capN}); err != nil {
+ failed[string(accountID)] = err.Error()
+ continue
+ }
+ applied++
+ }
+
+ resp := map[string]any{
+ "success": true,
+ "value": capN,
+ "applied": applied,
+ }
+ if len(failed) > 0 {
+ resp["failed"] = failed
+ }
+ h.writeJSON(w, resp)
+}
+
+// handleRuntime returns cheap runtime and process stats. Safe to hit on a
+// running proxy; does not read pprof profiles.
+func (h *Handler) handleRuntime(w http.ResponseWriter, _ *http.Request) {
+ var m runtime.MemStats
+ runtime.ReadMemStats(&m)
+
+ clients := h.provider.ListClientsForDebug()
+ started := 0
+ for _, c := range clients {
+ if c.HasClient {
+ started++
+ }
+ }
+
+ resp := map[string]any{
+ "uptime": time.Since(h.startTime).Round(time.Second).String(),
+ "goroutines": runtime.NumGoroutine(),
+ "num_cpu": runtime.NumCPU(),
+ "gomaxprocs": runtime.GOMAXPROCS(0),
+ "go_version": runtime.Version(),
+ "heap_alloc": m.HeapAlloc,
+ "heap_inuse": m.HeapInuse,
+ "heap_idle": m.HeapIdle,
+ "heap_released": m.HeapReleased,
+ "heap_sys": m.HeapSys,
+ "sys": m.Sys,
+ "live_objects": m.Mallocs - m.Frees,
+ "num_gc": m.NumGC,
+ "pause_total_ns": m.PauseTotalNs,
+ "clients": len(clients),
+ "started": started,
+ }
+
+ if proc := readProcStatus(); proc != nil {
+ resp["vm_rss"] = proc["VmRSS"]
+ resp["vm_size"] = proc["VmSize"]
+ resp["vm_data"] = proc["VmData"]
+ }
+
+ h.writeJSON(w, resp)
+}
+
+// readProcStatus parses /proc/self/status on Linux and returns size fields
+// in bytes. Returns nil on non-Linux or read failure.
+func readProcStatus() map[string]uint64 {
+ raw, err := os.ReadFile("/proc/self/status")
+ if err != nil {
+ return nil
+ }
+ out := map[string]uint64{}
+ for _, line := range strings.Split(string(raw), "\n") {
+ k, v, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ if k != "VmRSS" && k != "VmSize" && k != "VmData" {
+ continue
+ }
+ fields := strings.Fields(v)
+ if len(fields) < 1 {
+ continue
+ }
+ n, err := strconv.ParseUint(fields[0], 10, 64)
+ if err != nil {
+ continue
+ }
+ // Values are reported in kB.
+ out[k] = n * 1024
+ }
+ return out
+}
+
const maxCaptureDuration = 30 * time.Minute
// handleCapture streams a pcap or text packet capture for the given client.
@@ -825,7 +937,7 @@ func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request, wantJSON
h.writeJSON(w, resp)
}
-func (h *Handler) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
+func (h *Handler) renderTemplate(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := h.getTemplates()
if tmpl == nil {
@@ -838,7 +950,7 @@ func (h *Handler) renderTemplate(w http.ResponseWriter, name string, data interf
}
}
-func (h *Handler) writeJSON(w http.ResponseWriter, v interface{}) {
+func (h *Handler) writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go
index 133e86f05..11bca22e3 100644
--- a/proxy/internal/roundtrip/netbird.go
+++ b/proxy/internal/roundtrip/netbird.go
@@ -131,6 +131,7 @@ type ClientConfig struct {
MgmtAddr string
WGPort uint16
PreSharedKey string
+ Performance embed.Performance
// BlockInbound mirrors embed.Options.BlockInbound. Set to true on the
// standalone proxy where the embedded client never accepts inbound;
// set to false on the private/embedded proxy so the engine creates
@@ -306,7 +307,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
ManagementURL: n.clientCfg.MgmtAddr,
PrivateKey: privateKey.String(),
LogLevel: log.WarnLevel.String(),
- BlockInbound: n.clientCfg.BlockInbound,
+ BlockInbound: n.clientCfg.BlockInbound,
// The embedded proxy peer must never be a stepping stone into
// the proxy host's LAN: it only exists to reach NetBird mesh
// targets or, when direct_upstream is set, the host network
@@ -315,6 +316,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
BlockLANAccess: true,
WireguardPort: &wgPort,
PreSharedKey: n.clientCfg.PreSharedKey,
+ Performance: n.clientCfg.Performance,
})
if err != nil {
return nil, fmt.Errorf("create netbird client: %w", err)
diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go
index 6cb420722..9787f237e 100644
--- a/proxy/lifecycle.go
+++ b/proxy/lifecycle.go
@@ -6,6 +6,7 @@ import (
log "github.com/sirupsen/logrus"
+ "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/acme"
)
@@ -89,6 +90,10 @@ type Config struct {
// PreSharedKey is the WireGuard pre-shared key used between the
// proxy's embedded clients and peers.
PreSharedKey string
+ // Performance configures the tunnel pool/batch sizes for every
+ // embedded client this proxy creates. Zero values fall back to
+ // upstream defaults.
+ Performance embed.Performance
// SupportsCustomPorts indicates whether the proxy can bind arbitrary
// ports for TCP/UDP/TLS services.
@@ -148,6 +153,7 @@ func New(cfg Config) *Server {
WireguardPort: cfg.WireguardPort,
ProxyProtocol: cfg.ProxyProtocol,
PreSharedKey: cfg.PreSharedKey,
+ Performance: cfg.Performance,
SupportsCustomPorts: cfg.SupportsCustomPorts,
RequireSubdomain: cfg.RequireSubdomain,
Private: cfg.Private,
diff --git a/proxy/server.go b/proxy/server.go
index 63a0c577a..037da925c 100644
--- a/proxy/server.go
+++ b/proxy/server.go
@@ -41,6 +41,7 @@ import (
goproto "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
+ "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/accesslog"
"github.com/netbirdio/netbird/proxy/internal/acme"
"github.com/netbirdio/netbird/proxy/internal/auth"
@@ -185,6 +186,9 @@ type Server struct {
// single-account deployments; multiple accounts will fail to bind
// the same port.
WireguardPort uint16
+ // Performance configures the tunnel pool/batch sizes for every
+ // embedded client this proxy spawns.
+ Performance embed.Performance
// ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners.
// When enabled, the real client IP is extracted from the PROXY header
// sent by upstream L4 proxies that support PROXY protocol.
@@ -333,6 +337,8 @@ func (s *Server) Start(ctx context.Context) error {
s.runCancel = runCancel
s.initNetBirdClient()
+ // Create health checker before the mapping worker so it can track
+ // management connectivity from the first stream connection.
s.healthChecker = health.NewChecker(s.Logger, s.netbird)
s.crowdsecRegistry = crowdsec.NewRegistry(s.CrowdSecAPIURL, s.CrowdSecAPIKey, log.NewEntry(s.Logger))
@@ -529,6 +535,7 @@ func (s *Server) initNetBirdClient() {
MgmtAddr: s.ManagementAddress,
WGPort: s.WireguardPort,
PreSharedKey: s.PreSharedKey,
+ Performance: s.Performance,
// On --private the embedded client serves per-account inbound
// listeners and must apply management's ACL: keep BlockInbound off
// so the engine creates the ACL manager. On the standalone proxy
From 6771e35d57ae2411123aa0e4a762f437a6d4c0bb Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 26 May 2026 21:32:39 +0900
Subject: [PATCH 11/44] [client] Release js.FuncOf callbacks in wasm ssh and
rdp to prevent leaks (#5982)
---
client/wasm/internal/rdp/cert_validation.go | 31 ++++--
client/wasm/internal/rdp/rdcleanpath.go | 105 +++++++++++++++-----
client/wasm/internal/ssh/handlers.go | 27 +++--
3 files changed, 121 insertions(+), 42 deletions(-)
diff --git a/client/wasm/internal/rdp/cert_validation.go b/client/wasm/internal/rdp/cert_validation.go
index 1678c3996..47e30b6e6 100644
--- a/client/wasm/internal/rdp/cert_validation.go
+++ b/client/wasm/internal/rdp/cert_validation.go
@@ -6,6 +6,7 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
+ "sync"
"syscall/js"
"time"
@@ -13,7 +14,7 @@ import (
)
const (
- certValidationTimeout = 60 * time.Second
+ certValidationTimeout = 5 * time.Minute
)
func (p *RDCleanPathProxy) validateCertificateWithJS(conn *proxyConnection, certChain [][]byte) (bool, error) {
@@ -46,17 +47,31 @@ func (p *RDCleanPathProxy) validateCertificateWithJS(conn *proxyConnection, cert
promise := conn.wsHandlers.Call("onCertificateRequest", certInfo)
- resultChan := make(chan bool)
- errorChan := make(chan error)
+ resultChan := make(chan bool, 1)
+ errorChan := make(chan error, 1)
- promise.Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
- result := args[0].Bool()
- resultChan <- result
+ // Release from inside the callbacks so a post-timeout promise resolution
+ // does not invoke an already-released func.
+ var thenFn, catchFn js.Func
+ var releaseOnce sync.Once
+ release := func() {
+ releaseOnce.Do(func() {
+ thenFn.Release()
+ catchFn.Release()
+ })
+ }
+ thenFn = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
+ defer release()
+ resultChan <- args[0].Bool()
return nil
- })).Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
+ })
+ catchFn = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
+ defer release()
errorChan <- fmt.Errorf("certificate validation failed")
return nil
- }))
+ })
+
+ promise.Call("then", thenFn).Call("catch", catchFn)
select {
case result := <-resultChan:
diff --git a/client/wasm/internal/rdp/rdcleanpath.go b/client/wasm/internal/rdp/rdcleanpath.go
index 6c36fdec6..ee420dca4 100644
--- a/client/wasm/internal/rdp/rdcleanpath.go
+++ b/client/wasm/internal/rdp/rdcleanpath.go
@@ -11,6 +11,7 @@ import (
"io"
"net"
"sync"
+ "sync/atomic"
"syscall/js"
"time"
@@ -57,6 +58,8 @@ type RDCleanPathProxy struct {
}
activeConnections map[string]*proxyConnection
destinations map[string]string
+ pendingHandlers map[string]js.Func
+ nextID atomic.Uint64
mu sync.Mutex
}
@@ -66,8 +69,15 @@ type proxyConnection struct {
rdpConn net.Conn
tlsConn *tls.Conn
wsHandlers js.Value
- ctx context.Context
- cancel context.CancelFunc
+ // Go-side callbacks exposed to JS. js.FuncOf pins the Go closure in a
+ // global handle map and MUST be released, otherwise every connection
+ // leaks the Go memory the closure captures.
+ wsHandlerFn js.Func
+ onMessageFn js.Func
+ onCloseFn js.Func
+ cleanupOnce sync.Once
+ ctx context.Context
+ cancel context.CancelFunc
}
// NewRDCleanPathProxy creates a new RDCleanPath proxy
@@ -80,7 +90,11 @@ func NewRDCleanPathProxy(client interface {
}
}
-// CreateProxy creates a new proxy endpoint for the given destination
+// CreateProxy creates a new proxy endpoint for the given destination.
+// The registered handler fn and its destinations/pendingHandlers entries are
+// only released once a connection is established and cleanupConnection runs.
+// If a caller invokes CreateProxy but never connects to the returned URL,
+// those entries stay pinned for the lifetime of the page.
func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value {
destination := net.JoinHostPort(hostname, port)
@@ -88,7 +102,7 @@ func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value {
resolve := args[0]
go func() {
- proxyID := fmt.Sprintf("proxy_%d", len(p.activeConnections))
+ proxyID := fmt.Sprintf("proxy_%d", p.nextID.Add(1))
p.mu.Lock()
if p.destinations == nil {
@@ -100,7 +114,7 @@ func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value {
proxyURL := fmt.Sprintf("%s://%s/%s", RDCleanPathProxyScheme, RDCleanPathProxyHost, proxyID)
// Register the WebSocket handler for this specific proxy
- js.Global().Set(fmt.Sprintf("handleRDCleanPathWebSocket_%s", proxyID), js.FuncOf(func(_ js.Value, args []js.Value) any {
+ handlerFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return js.ValueOf("error: requires WebSocket argument")
}
@@ -108,7 +122,14 @@ func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value {
ws := args[0]
p.HandleWebSocketConnection(ws, proxyID)
return nil
- }))
+ })
+ p.mu.Lock()
+ if p.pendingHandlers == nil {
+ p.pendingHandlers = make(map[string]js.Func)
+ }
+ p.pendingHandlers[proxyID] = handlerFn
+ p.mu.Unlock()
+ js.Global().Set(fmt.Sprintf("handleRDCleanPathWebSocket_%s", proxyID), handlerFn)
log.Infof("Created RDCleanPath proxy endpoint: %s for destination: %s", proxyURL, destination)
resolve.Invoke(proxyURL)
@@ -142,6 +163,10 @@ func (p *RDCleanPathProxy) HandleWebSocketConnection(ws js.Value, proxyID string
p.mu.Lock()
p.activeConnections[proxyID] = conn
+ if fn, ok := p.pendingHandlers[proxyID]; ok {
+ conn.wsHandlerFn = fn
+ delete(p.pendingHandlers, proxyID)
+ }
p.mu.Unlock()
p.setupWebSocketHandlers(ws, conn)
@@ -150,7 +175,7 @@ func (p *RDCleanPathProxy) HandleWebSocketConnection(ws js.Value, proxyID string
}
func (p *RDCleanPathProxy) setupWebSocketHandlers(ws js.Value, conn *proxyConnection) {
- ws.Set("onGoMessage", js.FuncOf(func(this js.Value, args []js.Value) any {
+ conn.onMessageFn = js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
@@ -158,13 +183,15 @@ func (p *RDCleanPathProxy) setupWebSocketHandlers(ws js.Value, conn *proxyConnec
data := args[0]
go p.handleWebSocketMessage(conn, data)
return nil
- }))
+ })
+ ws.Set("onGoMessage", conn.onMessageFn)
- ws.Set("onGoClose", js.FuncOf(func(_ js.Value, args []js.Value) any {
+ conn.onCloseFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
log.Debug("WebSocket closed by JavaScript")
conn.cancel()
return nil
- }))
+ })
+ ws.Set("onGoClose", conn.onCloseFn)
}
func (p *RDCleanPathProxy) handleWebSocketMessage(conn *proxyConnection, data js.Value) {
@@ -261,25 +288,49 @@ func (p *RDCleanPathProxy) handleDirectRDP(conn *proxyConnection, firstPacket []
}
func (p *RDCleanPathProxy) cleanupConnection(conn *proxyConnection) {
- log.Debugf("Cleaning up connection %s", conn.id)
- conn.cancel()
- if conn.tlsConn != nil {
- log.Debug("Closing TLS connection")
- if err := conn.tlsConn.Close(); err != nil {
- log.Debugf("Error closing TLS connection: %v", err)
+ conn.cleanupOnce.Do(func() {
+ log.Debugf("Cleaning up connection %s", conn.id)
+ conn.cancel()
+ if conn.tlsConn != nil {
+ log.Debug("Closing TLS connection")
+ if err := conn.tlsConn.Close(); err != nil {
+ log.Debugf("Error closing TLS connection: %v", err)
+ }
+ conn.tlsConn = nil
}
- conn.tlsConn = nil
- }
- if conn.rdpConn != nil {
- log.Debug("Closing TCP connection")
- if err := conn.rdpConn.Close(); err != nil {
- log.Debugf("Error closing TCP connection: %v", err)
+ if conn.rdpConn != nil {
+ log.Debug("Closing TCP connection")
+ if err := conn.rdpConn.Close(); err != nil {
+ log.Debugf("Error closing TCP connection: %v", err)
+ }
+ conn.rdpConn = nil
}
- conn.rdpConn = nil
- }
- p.mu.Lock()
- delete(p.activeConnections, conn.id)
- p.mu.Unlock()
+ js.Global().Delete(fmt.Sprintf("handleRDCleanPathWebSocket_%s", conn.id))
+
+ // Detach before releasing so late JS calls surface as TypeError instead
+ // of silent "call to released function".
+ if conn.wsHandlers.Truthy() {
+ conn.wsHandlers.Set("onGoMessage", js.Undefined())
+ conn.wsHandlers.Set("onGoClose", js.Undefined())
+ }
+
+ // wsHandlerFn may be zero-value if the pending handler lookup missed.
+ if conn.wsHandlerFn.Truthy() {
+ conn.wsHandlerFn.Release()
+ }
+ if conn.onMessageFn.Truthy() {
+ conn.onMessageFn.Release()
+ }
+ if conn.onCloseFn.Truthy() {
+ conn.onCloseFn.Release()
+ }
+
+ p.mu.Lock()
+ delete(p.activeConnections, conn.id)
+ delete(p.destinations, conn.id)
+ delete(p.pendingHandlers, conn.id)
+ p.mu.Unlock()
+ })
}
func (p *RDCleanPathProxy) sendToWebSocket(conn *proxyConnection, data []byte) {
diff --git a/client/wasm/internal/ssh/handlers.go b/client/wasm/internal/ssh/handlers.go
index ea64eb0aa..6d33916a5 100644
--- a/client/wasm/internal/ssh/handlers.go
+++ b/client/wasm/internal/ssh/handlers.go
@@ -13,7 +13,7 @@ import (
func CreateJSInterface(client *Client) js.Value {
jsInterface := js.Global().Get("Object").Call("create", js.Null())
- jsInterface.Set("write", js.FuncOf(func(this js.Value, args []js.Value) any {
+ writeFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
return js.ValueOf(false)
}
@@ -32,9 +32,10 @@ func CreateJSInterface(client *Client) js.Value {
_, err := client.Write(bytes)
return js.ValueOf(err == nil)
- }))
+ })
+ jsInterface.Set("write", writeFunc)
- jsInterface.Set("resize", js.FuncOf(func(this js.Value, args []js.Value) any {
+ resizeFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 2 {
return js.ValueOf(false)
}
@@ -42,14 +43,26 @@ func CreateJSInterface(client *Client) js.Value {
rows := args[1].Int()
err := client.Resize(cols, rows)
return js.ValueOf(err == nil)
- }))
+ })
+ jsInterface.Set("resize", resizeFunc)
- jsInterface.Set("close", js.FuncOf(func(this js.Value, args []js.Value) any {
+ closeFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
client.Close()
return js.Undefined()
- }))
+ })
+ jsInterface.Set("close", closeFunc)
- go readLoop(client, jsInterface)
+ go func() {
+ readLoop(client, jsInterface)
+ // Detach before releasing so late JS calls surface as TypeError instead
+ // of silent "call to released function".
+ jsInterface.Set("write", js.Undefined())
+ jsInterface.Set("resize", js.Undefined())
+ jsInterface.Set("close", js.Undefined())
+ writeFunc.Release()
+ resizeFunc.Release()
+ closeFunc.Release()
+ }()
return jsInterface
}
From 1fbb5e6d5d11233fd9dc6324eff9fe66c1d6becb Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Tue, 26 May 2026 16:37:58 +0200
Subject: [PATCH 12/44] [management] fix owner role update (#6264)
---
management/server/user.go | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/management/server/user.go b/management/server/user.go
index 892d982e7..60571a702 100644
--- a/management/server/user.go
+++ b/management/server/user.go
@@ -762,7 +762,7 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
}
// Ensure the initiator still has admin privileges
- if initiatorUser.HasAdminPower() && !freshInitiator.HasAdminPower() {
+ if !freshInitiator.HasAdminPower() {
return false, nil, nil, nil, status.Errorf(status.PermissionDenied, "initiator role was changed during request processing")
}
initiatorUser = freshInitiator
@@ -906,19 +906,23 @@ func validateUserUpdate(groupsMap map[string]*types.Group, initiatorUser, oldUse
return nil
}
+ if !initiatorUser.HasAdminPower() {
+ return status.Errorf(status.PermissionDenied, "only admins and owners can update users")
+ }
+
if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && oldUser.Blocked != update.Blocked {
return status.Errorf(status.PermissionDenied, "admins can't block or unblock themselves")
}
if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && update.Role != initiatorUser.Role {
return status.Errorf(status.PermissionDenied, "admins can't change their role")
}
- if initiatorUser.Role == types.UserRoleAdmin && oldUser.Role == types.UserRoleOwner && update.Role != oldUser.Role {
+ if initiatorUser.Role != types.UserRoleOwner && oldUser.Role == types.UserRoleOwner && update.Role != oldUser.Role {
return status.Errorf(status.PermissionDenied, "only owners can remove owner role from their user")
}
- if initiatorUser.Role == types.UserRoleAdmin && oldUser.Role == types.UserRoleOwner && update.IsBlocked() && !oldUser.IsBlocked() {
+ if oldUser.Role == types.UserRoleOwner && update.IsBlocked() && !oldUser.IsBlocked() {
return status.Errorf(status.PermissionDenied, "unable to block owner user")
}
- if initiatorUser.Role == types.UserRoleAdmin && update.Role == types.UserRoleOwner && update.Role != oldUser.Role {
+ if initiatorUser.Role != types.UserRoleOwner && update.Role == types.UserRoleOwner && update.Role != oldUser.Role {
return status.Errorf(status.PermissionDenied, "only owners can add owner role to other users")
}
if oldUser.IsServiceUser && update.Role == types.UserRoleOwner {
From 14af1795567b91c0d4ceb7e5166eacb5015daee7 Mon Sep 17 00:00:00 2001
From: Bethuel Mmbaga
Date: Tue, 26 May 2026 17:44:28 +0300
Subject: [PATCH 13/44] [management] Refactor management server bootstrap
(#6256)
---
client/cmd/testutil_test.go | 4 +-
client/internal/engine_test.go | 6 +-
client/server/server_test.go | 4 +-
combined/cmd/root.go | 8 ++-
management/internals/server/boot.go | 38 ++++++++++--
management/internals/server/controllers.go | 3 +-
management/internals/server/modules.go | 13 ++--
management/internals/server/server.go | 6 +-
management/server/http/handler.go | 31 ++--------
.../server/http/middleware/auth_middleware.go | 11 ++--
.../http/middleware/auth_middleware_test.go | 9 +++
.../testing/testing_tools/channel/channel.go | 7 ++-
.../validator/validator.go | 62 +++++++++++++++++++
shared/management/client/client_test.go | 4 +-
14 files changed, 146 insertions(+), 60 deletions(-)
create mode 100644 management/server/integrations/integrated_validator/validator/validator.go
diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go
index c24965e8d..205327ef5 100644
--- a/client/cmd/testutil_test.go
+++ b/client/cmd/testutil_test.go
@@ -11,7 +11,7 @@ import (
"go.opentelemetry.io/otel"
"google.golang.org/grpc"
- "github.com/netbirdio/management-integrations/integrations"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
nbcache "github.com/netbirdio/netbird/management/server/cache"
@@ -109,7 +109,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
t.Fatal(err)
}
- iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
+ iv, _ := validator.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
require.NoError(t, err)
diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go
index 834a49a09..289f1906f 100644
--- a/client/internal/engine_test.go
+++ b/client/internal/engine_test.go
@@ -27,7 +27,7 @@ import (
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/management/server/job"
- "github.com/netbirdio/management-integrations/integrations"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
@@ -66,8 +66,8 @@ import (
"github.com/netbirdio/netbird/route"
mgmt "github.com/netbirdio/netbird/shared/management/client"
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
- relayClient "github.com/netbirdio/netbird/shared/relay/client"
"github.com/netbirdio/netbird/shared/netiputil"
+ relayClient "github.com/netbirdio/netbird/shared/relay/client"
signal "github.com/netbirdio/netbird/shared/signal/client"
"github.com/netbirdio/netbird/shared/signal/proto"
signalServer "github.com/netbirdio/netbird/signal/server"
@@ -1641,7 +1641,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
return nil, "", err
}
- ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
require.NoError(t, err)
diff --git a/client/server/server_test.go b/client/server/server_test.go
index 641cd85fe..66e0fcc4c 100644
--- a/client/server/server_test.go
+++ b/client/server/server_test.go
@@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
- "github.com/netbirdio/management-integrations/integrations"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
@@ -315,7 +315,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
return nil, "", err
}
- ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
require.NoError(t, err)
diff --git a/combined/cmd/root.go b/combined/cmd/root.go
index db986b4d4..78290388b 100644
--- a/combined/cmd/root.go
+++ b/combined/cmd/root.go
@@ -332,7 +332,7 @@ func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) {
log.Infof("Signal server registered on port %s", cfg.Server.ListenAddress)
}
- s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg))
+ s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), s.IDPHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg))
if servers.relaySrv != nil {
log.Infof("Relay WebSocket handler added (path: /relay)")
}
@@ -521,7 +521,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*
}
// createCombinedHandler creates an HTTP handler that multiplexes Management, Signal (via wsproxy), and Relay WebSocket traffic
-func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler {
+func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, idpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler {
wsProxy := wsproxyserver.New(grpcServer, wsproxyserver.WithOTelMeter(meter))
var relayAcceptFn func(conn listener.Conn)
@@ -556,6 +556,10 @@ func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, re
http.Error(w, "Relay service not enabled", http.StatusNotFound)
}
+ // Embedded IdP (Dex)
+ case idpHandler != nil && strings.HasPrefix(r.URL.Path, "/oauth2"):
+ idpHandler.ServeHTTP(w, r)
+
// Management HTTP API (default)
default:
httpHandler.ServeHTTP(w, r)
diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go
index 7c655f020..46e475143 100644
--- a/management/internals/server/boot.go
+++ b/management/internals/server/boot.go
@@ -10,8 +10,10 @@ import (
"slices"
"time"
+ "github.com/gorilla/mux"
grpcMiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip"
+ "github.com/rs/cors"
"github.com/rs/xid"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
@@ -19,7 +21,6 @@ import (
"google.golang.org/grpc/keepalive"
cachestore "github.com/eko/gocache/lib/v4/store"
- "github.com/netbirdio/management-integrations/integrations"
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/formatter/hook"
@@ -27,16 +28,20 @@ import (
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/activity"
+ activitystore "github.com/netbirdio/netbird/management/server/activity/store"
nbcache "github.com/netbirdio/netbird/management/server/cache"
nbContext "github.com/netbirdio/netbird/management/server/context"
nbhttp "github.com/netbirdio/netbird/management/server/http"
"github.com/netbirdio/netbird/management/server/http/middleware"
+ "github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/telemetry"
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/util/crypt"
)
+const apiPrefix = "/api"
+
var (
kaep = keepalive.EnforcementPolicy{
MinTime: 15 * time.Second,
@@ -94,12 +99,17 @@ func (s *BaseServer) Store() store.Store {
func (s *BaseServer) EventStore() activity.Store {
return Create(s, func() activity.Store {
- integrationMetrics, err := integrations.InitIntegrationMetrics(context.Background(), s.Metrics())
- if err != nil {
- log.Fatalf("failed to initialize integration metrics: %v", err)
+ var err error
+ key := s.Config.DataStoreEncryptionKey
+ if key == "" {
+ log.Debugf("generate new activity store encryption key")
+ key, err = crypt.GenerateKey()
+ if err != nil {
+ log.Fatalf("failed to generate event store encryption key: %v", err)
+ }
}
- eventStore, _, err := integrations.InitEventStore(context.Background(), s.Config.Datadir, s.Config.DataStoreEncryptionKey, integrationMetrics)
+ eventStore, err := activitystore.NewSqlStore(context.Background(), s.Config.Datadir, key)
if err != nil {
log.Fatalf("failed to initialize event store: %v", err)
}
@@ -110,7 +120,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.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter())
+ httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount)
if err != nil {
log.Fatalf("failed to create API handler: %v", err)
}
@@ -118,6 +128,22 @@ func (s *BaseServer) APIHandler() http.Handler {
})
}
+// IDPHandler returns the HTTP handler for the embedded IdP (Dex), or nil if
+// the deployment isn't using the embedded variant.
+func (s *BaseServer) IDPHandler() http.Handler {
+ embeddedIdP, ok := s.IdpManager().(*idp.EmbeddedIdPManager)
+ if !ok || embeddedIdP == nil {
+ return nil
+ }
+ return cors.AllowAll().Handler(embeddedIdP.Handler())
+}
+
+func (s *BaseServer) Router() *mux.Router {
+ return Create(s, func() *mux.Router {
+ return mux.NewRouter().PathPrefix(apiPrefix).Subrouter()
+ })
+}
+
func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter {
return Create(s, func() *middleware.APIRateLimiter {
cfg, enabled := middleware.RateLimiterConfigFromEnv()
diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go
index 794c3ebe0..1b2556809 100644
--- a/management/internals/server/controllers.go
+++ b/management/internals/server/controllers.go
@@ -19,6 +19,7 @@ import (
"github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/auth"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
"github.com/netbirdio/netbird/management/server/job"
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
@@ -38,7 +39,7 @@ func (s *BaseServer) JobManager() *job.Manager {
func (s *BaseServer) IntegratedValidator() integrated_validator.IntegratedValidator {
return Create(s, func() integrated_validator.IntegratedValidator {
- integratedPeerValidator, err := integrations.NewIntegratedValidator(
+ integratedPeerValidator, err := validator.NewIntegratedValidator(
context.Background(),
s.PeersManager(),
s.SettingsManager(),
diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go
index ea94245d5..a70da855a 100644
--- a/management/internals/server/modules.go
+++ b/management/internals/server/modules.go
@@ -57,13 +57,7 @@ func (s *BaseServer) GeoLocationManager() geolocation.Geolocation {
func (s *BaseServer) PermissionsManager() permissions.Manager {
return Create(s, func() permissions.Manager {
- manager := integrations.InitPermissionsManager(s.Store(), s.Metrics().GetMeter())
-
- s.AfterInit(func(s *BaseServer) {
- manager.SetAccountManager(s.AccountManager())
- })
-
- return manager
+ return permissions.NewManager(s.Store())
})
}
@@ -153,7 +147,6 @@ func (s *BaseServer) IdpManager() idp.Manager {
return idpManager
}
-
return nil
})
}
@@ -235,3 +228,7 @@ func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager {
return &m
})
}
+
+func (s *BaseServer) IsValidChildAccount(_ context.Context, _, _, _ string) bool {
+ return false
+}
diff --git a/management/internals/server/server.go b/management/internals/server/server.go
index 9b8716da1..63d13baab 100644
--- a/management/internals/server/server.go
+++ b/management/internals/server/server.go
@@ -188,7 +188,7 @@ func (s *BaseServer) Start(ctx context.Context) error {
log.WithContext(srvCtx).Infof("running gRPC backward compatibility server: %s", compatListener.Addr().String())
}
- rootHandler := s.handlerFunc(srvCtx, s.GRPCServer(), s.APIHandler(), s.Metrics().GetMeter())
+ rootHandler := s.handlerFunc(srvCtx, s.GRPCServer(), s.APIHandler(), s.IDPHandler(), s.Metrics().GetMeter())
switch {
case s.certManager != nil:
// a call to certManager.Listener() always creates a new listener so we do it once
@@ -299,7 +299,7 @@ func (s *BaseServer) SetHandlerFunc(handler http.Handler) {
log.Tracef("custom handler set successfully")
}
-func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, httpHandler http.Handler, meter metric.Meter) http.Handler {
+func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, httpHandler http.Handler, idpHandler http.Handler, meter metric.Meter) http.Handler {
// Check if a custom handler was set (for multiplexing additional services)
if customHandler, ok := s.GetContainer("customHandler"); ok {
if handler, ok := customHandler.(http.Handler); ok {
@@ -318,6 +318,8 @@ func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, ht
gRPCHandler.ServeHTTP(writer, request)
case request.URL.Path == wsproxy.ProxyPath+wsproxy.ManagementComponent:
wsProxy.Handler().ServeHTTP(writer, request)
+ case idpHandler != nil && strings.HasPrefix(request.URL.Path, "/oauth2"):
+ idpHandler.ServeHTTP(writer, request)
default:
httpHandler.ServeHTTP(writer, request)
}
diff --git a/management/server/http/handler.go b/management/server/http/handler.go
index 1e2c710db..0abdb854d 100644
--- a/management/server/http/handler.go
+++ b/management/server/http/handler.go
@@ -15,15 +15,13 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
- "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxytoken"
+ "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
idpmanager "github.com/netbirdio/netbird/management/server/idp"
- "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"
@@ -32,12 +30,10 @@ import (
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/settings"
- "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/http/handlers/proxy"
- nbpeers "github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/server/auth"
"github.com/netbirdio/netbird/management/server/geolocation"
nbgroups "github.com/netbirdio/netbird/management/server/groups"
@@ -56,17 +52,14 @@ import (
"github.com/netbirdio/netbird/management/server/http/middleware"
"github.com/netbirdio/netbird/management/server/http/middleware/bypass"
nbinstance "github.com/netbirdio/netbird/management/server/instance"
- "github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
nbnetworks "github.com/netbirdio/netbird/management/server/networks"
"github.com/netbirdio/netbird/management/server/networks/resources"
"github.com/netbirdio/netbird/management/server/networks/routers"
"github.com/netbirdio/netbird/management/server/telemetry"
)
-const apiPrefix = "/api"
-
// 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, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter) (http.Handler, error) {
+func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc) (http.Handler, error) {
// Register bypass paths for unauthenticated endpoints
if err := bypass.AddBypassPath("/api/instance"); err != nil {
@@ -100,25 +93,16 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
accountManager.GetUserFromUserAuth,
rateLimiter,
appMetrics.GetMeter(),
+ isValidChildAccount,
)
corsMiddleware := cors.AllowAll()
- rootRouter := mux.NewRouter()
metricsMiddleware := appMetrics.HTTPMiddleware()
- prefix := apiPrefix
- router := rootRouter.PathPrefix(prefix).Subrouter()
-
router.Use(metricsMiddleware.Handler, corsMiddleware.Handler, authMiddleware.Handler)
- if _, err := integrations.RegisterHandlers(ctx, prefix, router, accountManager, integratedValidator, appMetrics.GetMeter(), permissionsManager, peersManager, proxyController, settingsManager); err != nil {
- return nil, fmt.Errorf("register integrations endpoints: %w", err)
- }
-
- // Check if embedded IdP is enabled for instance manager
- embeddedIdP, embeddedIdpEnabled := idpManager.(*idpmanager.EmbeddedIdPManager)
- instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), embeddedIdP)
+ instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), idpManager)
if err != nil {
return nil, fmt.Errorf("failed to create instance manager: %w", err)
}
@@ -154,10 +138,5 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
oauthHandler.RegisterEndpoints(router)
}
- // Mount embedded IdP handler at /oauth2 path if configured
- if embeddedIdpEnabled {
- rootRouter.PathPrefix("/oauth2").Handler(corsMiddleware.Handler(embeddedIdP.Handler()))
- }
-
- return rootRouter, nil
+ return router, nil
}
diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go
index 6d075d9c2..34df0de23 100644
--- a/management/server/http/middleware/auth_middleware.go
+++ b/management/server/http/middleware/auth_middleware.go
@@ -11,8 +11,6 @@ import (
log "github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/metric"
- "github.com/netbirdio/management-integrations/integrations"
-
serverauth "github.com/netbirdio/netbird/management/server/auth"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/http/middleware/bypass"
@@ -27,6 +25,8 @@ type SyncUserJWTGroupsFunc func(ctx context.Context, userAuth auth.UserAuth) err
type GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
+type IsValidChildAccountFunc func(ctx context.Context, userID, accountID, childAccountID string) bool
+
// AuthMiddleware middleware to verify personal access tokens (PAT) and JWT tokens
type AuthMiddleware struct {
authManager serverauth.Manager
@@ -35,6 +35,7 @@ type AuthMiddleware struct {
syncUserJWTGroups SyncUserJWTGroupsFunc
rateLimiter *APIRateLimiter
patUsageTracker *PATUsageTracker
+ isValidChildAccount IsValidChildAccountFunc
}
// NewAuthMiddleware instance constructor
@@ -45,6 +46,7 @@ func NewAuthMiddleware(
getUserFromUserAuth GetUserFromUserAuthFunc,
rateLimiter *APIRateLimiter,
meter metric.Meter,
+ isValidChildAccount IsValidChildAccountFunc,
) *AuthMiddleware {
var patUsageTracker *PATUsageTracker
if meter != nil {
@@ -62,6 +64,7 @@ func NewAuthMiddleware(
getUserFromUserAuth: getUserFromUserAuth,
rateLimiter: rateLimiter,
patUsageTracker: patUsageTracker,
+ isValidChildAccount: isValidChildAccount,
}
}
@@ -124,7 +127,7 @@ func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, authHeaderParts []
}
if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 {
- if integrations.IsValidChildAccount(ctx, userAuth.UserId, userAuth.AccountId, impersonate[0]) {
+ if m.isValidChildAccount(ctx, userAuth.UserId, userAuth.AccountId, impersonate[0]) {
userAuth.AccountId = impersonate[0]
userAuth.IsChild = true
}
@@ -203,7 +206,7 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts []
}
if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 {
- if integrations.IsValidChildAccount(r.Context(), userAuth.UserId, userAuth.AccountId, impersonate[0]) {
+ if m.isValidChildAccount(r.Context(), userAuth.UserId, userAuth.AccountId, impersonate[0]) {
userAuth.AccountId = impersonate[0]
userAuth.IsChild = true
}
diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go
index 8f736fbfd..24cf8fce5 100644
--- a/management/server/http/middleware/auth_middleware_test.go
+++ b/management/server/http/middleware/auth_middleware_test.go
@@ -211,6 +211,7 @@ func TestAuthMiddleware_Handler(t *testing.T) {
},
disabledLimiter,
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handlerToTest := authMiddleware.Handler(nextHandler)
@@ -270,6 +271,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -322,6 +324,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -365,6 +368,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -409,6 +413,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -473,6 +478,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -532,6 +538,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -587,6 +594,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -687,6 +695,7 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) {
},
disabledLimiter,
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
for _, tc := range tt {
diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go
index 3c4ea98d0..8da9c7ad4 100644
--- a/management/server/http/testing/testing_tools/channel/channel.go
+++ b/management/server/http/testing/testing_tools/channel/channel.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
+ "github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"go.opentelemetry.io/otel/metric/noop"
@@ -135,7 +136,8 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "")
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
- apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil)
+ apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
+ apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil)
if err != nil {
t.Fatalf("Failed to create API handler: %v", err)
}
@@ -264,7 +266,8 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "")
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
- apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil)
+ apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
+ apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil)
if err != nil {
t.Fatalf("Failed to create API handler: %v", err)
}
diff --git a/management/server/integrations/integrated_validator/validator/validator.go b/management/server/integrations/integrated_validator/validator/validator.go
new file mode 100644
index 000000000..db1d34373
--- /dev/null
+++ b/management/server/integrations/integrated_validator/validator/validator.go
@@ -0,0 +1,62 @@
+package validator
+
+import (
+ "context"
+
+ cachestore "github.com/eko/gocache/lib/v4/store"
+
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbpeer "github.com/netbirdio/netbird/management/server/peer"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+type IntegratedValidatorImpl struct{}
+
+func NewIntegratedValidator(_ context.Context, _ peers.Manager, _ settings.Manager, _ activity.Store, _ cachestore.StoreInterface) (*IntegratedValidatorImpl, error) {
+ return &IntegratedValidatorImpl{}, nil
+}
+
+func (v *IntegratedValidatorImpl) ValidateExtraSettings(context.Context, *types.ExtraSettings, *types.ExtraSettings, string, string) error {
+ return nil
+}
+
+func (v *IntegratedValidatorImpl) ValidatePeer(_ context.Context, update *nbpeer.Peer, _ *nbpeer.Peer, _ string, _ string, _ string, _ []string, _ *types.ExtraSettings) (*nbpeer.Peer, bool, error) {
+ return update, false, nil
+}
+
+func (v *IntegratedValidatorImpl) PreparePeer(_ context.Context, _ string, peer *nbpeer.Peer, _ []string, _ *types.ExtraSettings, _ bool) *nbpeer.Peer {
+ return peer.Copy()
+}
+
+func (v *IntegratedValidatorImpl) IsNotValidPeer(_ context.Context, _ string, _ *nbpeer.Peer, _ []string, _ *types.ExtraSettings) (bool, bool, error) {
+ return false, false, nil
+}
+
+func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*types.Group, peers []*nbpeer.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) {
+ validatedPeers := make(map[string]struct{})
+ for _, p := range peers {
+ validatedPeers[p.ID] = struct{}{}
+ }
+ return validatedPeers, nil
+}
+
+func (v *IntegratedValidatorImpl) GetInvalidPeers(_ context.Context, _ string, _ *types.ExtraSettings) (map[string]string, error) {
+ return make(map[string]string), nil
+}
+
+func (v *IntegratedValidatorImpl) PeerDeleted(_ context.Context, _, _ string, _ *types.ExtraSettings) error {
+ return nil
+}
+
+func (v *IntegratedValidatorImpl) SetPeerInvalidationListener(_ func(accountID string, peerIDs []string)) {
+}
+
+func (v *IntegratedValidatorImpl) Stop(_ context.Context) {
+}
+
+func (v *IntegratedValidatorImpl) ValidateFlowResponse(_ context.Context, _ string, flowResponse *proto.PKCEAuthorizationFlow) *proto.PKCEAuthorizationFlow {
+ return flowResponse
+}
diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go
index a8e8172dc..be2c009ad 100644
--- a/shared/management/client/client_test.go
+++ b/shared/management/client/client_test.go
@@ -17,7 +17,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
- "github.com/netbirdio/management-integrations/integrations"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
@@ -103,7 +103,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
t.Fatal(err)
}
- ia, _ := integrations.NewIntegratedValidator(ctx, peersManger, settingsManagerMock, eventStore, cacheStore)
+ ia, _ := validator.NewIntegratedValidator(ctx, peersManger, settingsManagerMock, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
require.NoError(t, err)
From 1f9a829f2c453397eb8692f4dd828fa59f61f844 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Wed, 27 May 2026 11:43:49 +0200
Subject: [PATCH 14/44] [management] update log levels (#6266)
---
.../controllers/network_map/update_channel/updatechannel.go | 2 +-
management/internals/shared/grpc/server.go | 4 ++--
management/server/posture/nb_version.go | 4 ----
management/server/posture/os_version.go | 4 ----
4 files changed, 3 insertions(+), 11 deletions(-)
diff --git a/management/internals/controllers/network_map/update_channel/updatechannel.go b/management/internals/controllers/network_map/update_channel/updatechannel.go
index 5f7db5300..91627bf15 100644
--- a/management/internals/controllers/network_map/update_channel/updatechannel.go
+++ b/management/internals/controllers/network_map/update_channel/updatechannel.go
@@ -51,7 +51,7 @@ func (p *PeersUpdateManager) SendUpdate(ctx context.Context, peerID string, upda
found = true
select {
case channel <- update:
- log.WithContext(ctx).Debugf("update was sent to channel for peer %s", peerID)
+ log.WithContext(ctx).Tracef("update was sent to channel for peer %s", peerID)
default:
dropped = true
log.WithContext(ctx).Warnf("channel for peer %s is %d full or closed", peerID, len(channel))
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index 1d8234304..d36e72045 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -437,7 +437,7 @@ 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())
+ log.WithContext(ctx).Tracef("received an update for peer %s", peerKey.String())
if debouncer.ProcessUpdate(update) {
// Send immediately (first update or after quiet period)
if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv, streamStartTime); err != nil {
@@ -492,7 +492,7 @@ func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtyp
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
return status.Errorf(codes.Internal, "failed sending update message")
}
- log.WithContext(ctx).Debugf("sent an update to peer %s", peerKey.String())
+ log.WithContext(ctx).Tracef("sent an update to peer %s", peerKey.String())
return nil
}
diff --git a/management/server/posture/nb_version.go b/management/server/posture/nb_version.go
index 33bf01ad1..6e4757021 100644
--- a/management/server/posture/nb_version.go
+++ b/management/server/posture/nb_version.go
@@ -6,7 +6,6 @@ import (
"strings"
"github.com/hashicorp/go-version"
- log "github.com/sirupsen/logrus"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
)
@@ -33,9 +32,6 @@ func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, err
return true, nil
}
- log.WithContext(ctx).Debugf("peer %s NB version %s is older than minimum allowed version %s",
- peer.ID, peer.Meta.WtVersion, n.MinVersion)
-
return false, nil
}
diff --git a/management/server/posture/os_version.go b/management/server/posture/os_version.go
index 2ef97a066..724bebfcc 100644
--- a/management/server/posture/os_version.go
+++ b/management/server/posture/os_version.go
@@ -100,8 +100,6 @@ func checkMinVersion(ctx context.Context, peerGoOS, peerVersion string, check *M
return true, nil
}
- log.WithContext(ctx).Debugf("peer %s OS version %s is older than minimum allowed version %s", peerGoOS, peerVersion, check.MinVersion)
-
return false, nil
}
@@ -125,7 +123,5 @@ func checkMinKernelVersion(ctx context.Context, peerGoOS, peerVersion string, ch
return true, nil
}
- log.WithContext(ctx).Debugf("peer %s kernel version %s is older than minimum allowed version %s", peerGoOS, peerVersion, check.MinKernelVersion)
-
return false, nil
}
From 944a258459628c60aaa15b8cc5dca2403afcebed Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Wed, 27 May 2026 16:56:02 +0200
Subject: [PATCH 15/44] [management] extend nmap monitoring (#6271)
---
.../network_map/controller/controller.go | 16 ++++++++++------
.../telemetry/accountmanager_metrics.go | 19 +++++++++++++++++++
2 files changed, 29 insertions(+), 6 deletions(-)
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 590773dda..4199b2b27 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -112,7 +112,7 @@ func (c *Controller) CountStreams() int {
return c.peersUpdateManager.CountStreams()
}
-func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string) error {
+func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName())
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
@@ -175,6 +175,10 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
continue
}
+ if c.accountManagerMetrics != nil {
+ c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
+ }
+
wg.Add(1)
semaphore <- struct{}{}
go func(p *nbpeer.Peer) {
@@ -242,14 +246,14 @@ func (c *Controller) bufferSendUpdateAccountPeers(ctx context.Context, accountID
go func() {
defer b.mu.Unlock()
- _ = c.sendUpdateAccountPeers(ctx, accountID)
+ _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
if !b.update.Load() {
return
}
b.update.Store(false)
if b.next == nil {
b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
- _ = c.sendUpdateAccountPeers(ctx, accountID)
+ _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
})
return
}
@@ -265,7 +269,7 @@ func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, r
if c.accountManagerMetrics != nil {
c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
}
- return c.sendUpdateAccountPeers(ctx, accountID)
+ return c.sendUpdateAccountPeers(ctx, accountID, reason)
}
func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error {
@@ -359,14 +363,14 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str
go func() {
defer b.mu.Unlock()
- _ = c.sendUpdateAccountPeers(ctx, accountID)
+ _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
if !b.update.Load() {
return
}
b.update.Store(false)
if b.next == nil {
b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
- _ = c.sendUpdateAccountPeers(ctx, accountID)
+ _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
})
return
}
diff --git a/management/server/telemetry/accountmanager_metrics.go b/management/server/telemetry/accountmanager_metrics.go
index bb6fb7e12..4b370bde3 100644
--- a/management/server/telemetry/accountmanager_metrics.go
+++ b/management/server/telemetry/accountmanager_metrics.go
@@ -13,6 +13,7 @@ type AccountManagerMetrics struct {
ctx context.Context
updateAccountPeersDurationMs metric.Float64Histogram
updateAccountPeersCounter metric.Int64Counter
+ nmapCounter metric.Int64Counter
getPeerNetworkMapDurationMs metric.Float64Histogram
networkMapObjectCount metric.Int64Histogram
peerMetaUpdateCount metric.Int64Counter
@@ -59,6 +60,13 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account
return nil, err
}
+ nmapCounter, err := meter.Int64Counter("management.network.map.counter",
+ metric.WithUnit("1"),
+ metric.WithDescription("Number of network maps computed, labeled by resource and operation trigger"))
+ if err != nil {
+ return nil, err
+ }
+
peerMetaUpdateCount, err := meter.Int64Counter("management.account.peer.meta.update.counter",
metric.WithUnit("1"),
metric.WithDescription("Number of updates with new meta data from the peers"))
@@ -93,6 +101,7 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account
peerMetaUpdateCount: peerMetaUpdateCount,
peerStatusUpdateCounter: peerStatusUpdateCounter,
peerStatusUpdateDurationMs: peerStatusUpdateDurationMs,
+ nmapCounter: nmapCounter,
}, nil
}
@@ -145,6 +154,16 @@ func (metrics *AccountManagerMetrics) CountUpdateAccountPeersTriggered(resource,
)
}
+// CountNmapTriggered increments the counter for calculated network maps with resource and operation labels.
+func (metrics *AccountManagerMetrics) CountNmapTriggered(resource, operation string) {
+ metrics.nmapCounter.Add(metrics.ctx, 1,
+ metric.WithAttributes(
+ attribute.String("resource", resource),
+ attribute.String("operation", operation),
+ ),
+ )
+}
+
// CountPeerMetUpdate counts the number of peer meta updates
func (metrics *AccountManagerMetrics) CountPeerMetUpdate() {
metrics.peerMetaUpdateCount.Add(metrics.ctx, 1)
From 9d7ef9b255ba52b0b41514cf4a3b9f725aa5cebf Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Thu, 28 May 2026 08:54:15 +0200
Subject: [PATCH 16/44] [client] Fix statemanager possible deadlock (#6228)
1. Stop() takes m.mu.Lock() and defers m.mu.Unlock()
2. <-m.done under lock
3. periodicStateSave defers close(m.done)
4. periodicStateSave calls PersistState() (line 256) which does m.mu.Lock()
Double Stop() remains idempotent: second cancel() on dead ctx
(no-op) and reads done already closed (immediate return).
---
client/internal/statemanager/manager.go | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/client/internal/statemanager/manager.go b/client/internal/statemanager/manager.go
index 2c9e46290..566905985 100644
--- a/client/internal/statemanager/manager.go
+++ b/client/internal/statemanager/manager.go
@@ -96,17 +96,19 @@ func (m *Manager) Stop(ctx context.Context) error {
}
m.mu.Lock()
- defer m.mu.Unlock()
+ cancel := m.cancel
+ done := m.done
+ m.mu.Unlock()
- if m.cancel == nil {
+ if cancel == nil {
return nil
}
- m.cancel()
+ cancel()
select {
case <-ctx.Done():
return ctx.Err()
- case <-m.done:
+ case <-done:
}
return nil
From 7ea5e37dd4d97a7f6b1fa0be94e80442463efa8b Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Thu, 28 May 2026 09:01:18 +0200
Subject: [PATCH 17/44] [client] Improve rosenpass support (#6136)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Updates rosenpass version
go-rosenpass v0.4.0 โ v0.5.42 bump โ detailed findings
Change summary
cunicu.li/go-rosenpass v0.4.0 โ v0.5.42 (target)
cilium/ebpf v0.15.0 โ v0.19.0 (transitive)
gopacket/gopacket v1.1.1 โ v1.4.0 (transitive)
wireguard 2023-07 โ 2023-12 (transitive)
wireguard/wgctrl 2023-04 โ 2024-12 (transitive)
Wire interop
v0.4.0 (in v0.70.5) <-> v0.5.42 OK
v0.5.42 <-> v0.5.42 OK
Quantum resistance: true both ends
---
**Replay error eliminated.**
Before (on v0.4.0):
`ERROR Failed to handle message: failed to load biscuit (ICR1): detected replay`
Recurring every ~50ms for minutes at a time. Gone entirely after both ends upgraded to v0.5.42. Upstream fix in biscuit/replay handling between v0.4.x and v0.5.x series.
* Fixup [::]:port socket trying to send to v4
* Adds more tests on netbird<->rosenpass interactions
* Anticipates rp handler creation before generateConfig
* [client] Moves deterministic key gen into rosenpass
* go mod tidy
* Adds reminder to reason about rosenpass surface area
* Apply code rabbit suggestions
---
client/internal/peer/conn.go | 23 +-
client/internal/rosenpass/manager.go | 71 +++-
client/internal/rosenpass/manager_test.go | 398 ++++++++++++++++++++++
client/internal/rosenpass/seed.go | 42 +++
client/internal/rosenpass/seed_test.go | 44 +++
go.mod | 10 +-
go.sum | 22 +-
7 files changed, 564 insertions(+), 46 deletions(-)
create mode 100644 client/internal/rosenpass/seed.go
create mode 100644 client/internal/rosenpass/seed_test.go
diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go
index 1e416bfe7..79a513956 100644
--- a/client/internal/peer/conn.go
+++ b/client/internal/peer/conn.go
@@ -23,6 +23,7 @@ import (
"github.com/netbirdio/netbird/client/internal/peer/id"
"github.com/netbirdio/netbird/client/internal/peer/worker"
"github.com/netbirdio/netbird/client/internal/portforward"
+ "github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/route"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
@@ -899,7 +900,7 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
}
// Fallback to deterministic key if no NetBird PSK is configured
- determKey, err := conn.rosenpassDetermKey()
+ determKey, err := rosenpass.DeterministicSeedKey(conn.config.LocalKey, conn.config.Key)
if err != nil {
conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err)
return nil
@@ -908,26 +909,6 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
return determKey
}
-// todo: move this logic into Rosenpass package
-func (conn *Conn) rosenpassDetermKey() (*wgtypes.Key, error) {
- lk := []byte(conn.config.LocalKey)
- rk := []byte(conn.config.Key) // remote key
- var keyInput []byte
- if string(lk) > string(rk) {
- //nolint:gocritic
- keyInput = append(lk[:16], rk[:16]...)
- } else {
- //nolint:gocritic
- keyInput = append(rk[:16], lk[:16]...)
- }
-
- key, err := wgtypes.NewKey(keyInput)
- if err != nil {
- return nil, err
- }
- return &key, nil
-}
-
func isController(config ConnConfig) bool {
return config.LocalKey > config.Key
}
diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go
index 11cda8dbc..903753753 100644
--- a/client/internal/rosenpass/manager.go
+++ b/client/internal/rosenpass/manager.go
@@ -28,6 +28,15 @@ func hashRosenpassKey(key []byte) string {
return hex.EncodeToString(hasher.Sum(nil))
}
+// rpServer is the subset of rp.Server used by Manager. Defined as an interface
+// so tests can substitute a mock without spinning up a real UDP server.
+type rpServer interface {
+ AddPeer(rp.PeerConfig) (rp.PeerID, error)
+ RemovePeer(rp.PeerID) error
+ Run() error
+ Close() error
+}
+
type Manager struct {
ifaceName string
spk []byte
@@ -36,7 +45,7 @@ type Manager struct {
preSharedKey *[32]byte
rpPeerIDs map[string]*rp.PeerID
rpWgHandler *NetbirdHandler
- server *rp.Server
+ server rpServer
lock sync.Mutex
port int
wgIface PresharedKeySetter
@@ -51,7 +60,22 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
rpKeyHash := hashRosenpassKey(public)
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
- return &Manager{ifaceName: wgIfaceName, rpKeyHash: rpKeyHash, spk: public, ssk: secret, preSharedKey: (*[32]byte)(preSharedKey), rpPeerIDs: make(map[string]*rp.PeerID), lock: sync.Mutex{}}, nil
+ return &Manager{
+ ifaceName: wgIfaceName,
+ rpKeyHash: rpKeyHash,
+ spk: public,
+ ssk: secret,
+ preSharedKey: (*[32]byte)(preSharedKey),
+ rpPeerIDs: make(map[string]*rp.PeerID),
+ // rpWgHandler is created here (instead of only in generateConfig) so it
+ // is never nil between NewManager and Run(). Otherwise an early
+ // OnConnected call (race observed on Android, issue #4341) panics on
+ // nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
+ // replace it with a fresh handler on each Run() to clear stale peer
+ // state from previous engine sessions.
+ rpWgHandler: NewNetbirdHandler(),
+ lock: sync.Mutex{},
+ }, nil
}
func (m *Manager) GetPubKey() []byte {
@@ -65,6 +89,16 @@ func (m *Manager) GetAddress() *net.UDPAddr {
// addPeer adds a new peer to the Rosenpass server
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
+ // Defense in depth against issue #4341 (Android crash): if Run() has not
+ // completed yet, m.server / m.rpWgHandler may be nil. Return an explicit
+ // error instead of panicking on nil-receiver dereference.
+ if m.server == nil {
+ return fmt.Errorf("rosenpass server not initialized")
+ }
+ if m.rpWgHandler == nil {
+ return fmt.Errorf("rosenpass wg handler not initialized")
+ }
+
var err error
pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey}
if m.preSharedKey != nil {
@@ -79,6 +113,16 @@ func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuar
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil {
return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
}
+ // Our local Rosenpass UDP server binds on the IPv6 wildcard ([::]) โ see
+ // GetAddress(). The remote peer's endpoint (pcfg.Endpoint) is the destination
+ // our server will sendto when initiating handshakes. ResolveUDPAddr returns a
+ // 4-byte IPv4 for IPv4 hosts, which the kernel rejects (EDESTADDRREQ) when
+ // sent from an AF_INET6 socket. Normalize the remote endpoint to IPv4-mapped
+ // IPv6 so its address family matches our listening socket.
+ // TODO: maybe bind the Rosenpass UDP server to the peer wg IP addr
+ if v4 := pcfg.Endpoint.IP.To4(); v4 != nil {
+ pcfg.Endpoint.IP = v4.To16()
+ }
}
peerID, err := m.server.AddPeer(pcfg)
if err != nil {
@@ -182,24 +226,31 @@ func (m *Manager) Run() error {
return err
}
- m.server, err = rp.NewUDPServer(conf)
+ server, err := rp.NewUDPServer(conf)
if err != nil {
return err
}
+ m.lock.Lock()
+ m.server = server
+ m.lock.Unlock()
+
log.Infof("starting rosenpass server on port %d", m.port)
- return m.server.Run()
+ return server.Run()
}
// Close closes the Rosenpass server
func (m *Manager) Close() error {
- if m.server != nil {
- err := m.server.Close()
- if err != nil {
- log.Errorf("failed closing local rosenpass server")
- }
- m.server = nil
+ m.lock.Lock()
+ server := m.server
+ m.server = nil
+ m.lock.Unlock()
+ if server == nil {
+ return nil
+ }
+ if err := server.Close(); err != nil {
+ log.Errorf("failed closing local rosenpass server: %v", err)
}
return nil
}
diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go
index 90bbdda59..ace6f88da 100644
--- a/client/internal/rosenpass/manager_test.go
+++ b/client/internal/rosenpass/manager_test.go
@@ -1,14 +1,412 @@
package rosenpass
import (
+ "errors"
+ "os"
+ "sync"
"testing"
+ rp "cunicu.li/go-rosenpass"
"github.com/stretchr/testify/require"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
+// --- test doubles -----------------------------------------------------------
+
+type addPeerCall struct {
+ cfg rp.PeerConfig
+}
+
+type removePeerCall struct {
+ id rp.PeerID
+}
+
+type mockServer struct {
+ mu sync.Mutex
+ addCalls []addPeerCall
+ removed []removePeerCall
+ nextID rp.PeerID
+ addErr error
+ removeErr error
+ closed bool
+ ran bool
+}
+
+func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.addCalls = append(m.addCalls, addPeerCall{cfg: cfg})
+ if m.addErr != nil {
+ return rp.PeerID{}, m.addErr
+ }
+ // Increment a byte in nextID so distinct peers get distinct IDs.
+ m.nextID[0]++
+ return m.nextID, nil
+}
+
+func (m *mockServer) RemovePeer(id rp.PeerID) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.removed = append(m.removed, removePeerCall{id: id})
+ return m.removeErr
+}
+
+func (m *mockServer) Run() error { m.ran = true; return nil }
+func (m *mockServer) Close() error { m.closed = true; return nil }
+
+type setPSKCall struct {
+ peerKey string
+ psk wgtypes.Key
+ updateOnly bool
+}
+
+type mockIface struct {
+ mu sync.Mutex
+ calls []setPSKCall
+ err error
+}
+
+func (m *mockIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.calls = append(m.calls, setPSKCall{peerKey: peerKey, psk: psk, updateOnly: updateOnly})
+ return m.err
+}
+
+// newTestManager builds a Manager with deterministic spk so tie-break
+// against a peer pubkey is controllable from tests. The provided spk byte
+// becomes the first byte; remaining bytes are zero.
+func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
+ spk := make([]byte, 32)
+ spk[0] = spkFirstByte
+ return &Manager{
+ ifaceName: "wt0",
+ spk: spk,
+ ssk: make([]byte, 32),
+ rpKeyHash: "test-hash",
+ rpPeerIDs: make(map[string]*rp.PeerID),
+ rpWgHandler: NewNetbirdHandler(),
+ server: mock,
+ }
+}
+
+// validWGKey returns a deterministic 32-byte wireguard public key (base64).
+func validWGKey(t *testing.T, lastByte byte) string {
+ t.Helper()
+ var k wgtypes.Key
+ k[31] = lastByte
+ return k.String()
+}
+
+// --- pure helpers ----------------------------------------------------------
+
+func TestHashRosenpassKey_Deterministic(t *testing.T) {
+ key := []byte("hello-rosenpass")
+ require.Equal(t, hashRosenpassKey(key), hashRosenpassKey(key))
+ require.Len(t, hashRosenpassKey(key), 64) // sha256 hex
+}
+
+func TestHashRosenpassKey_DifferentInputsDifferOutputs(t *testing.T) {
+ require.NotEqual(t, hashRosenpassKey([]byte("a")), hashRosenpassKey([]byte("b")))
+}
+
+func TestGetLogLevel_DefaultWhenUnset(t *testing.T) {
+ // Snapshot + unset to exercise the LookupEnv ok=false branch. t.Setenv
+ // can only set, not delete, so do it manually with restore via t.Cleanup.
+ prev, hadPrev := os.LookupEnv(defaultLogLevelVar)
+ require.NoError(t, os.Unsetenv(defaultLogLevelVar))
+ t.Cleanup(func() {
+ if hadPrev {
+ _ = os.Setenv(defaultLogLevelVar, prev)
+ } else {
+ _ = os.Unsetenv(defaultLogLevelVar)
+ }
+ })
+ require.Equal(t, defaultLog.String(), getLogLevel().String())
+}
+
+func TestGetLogLevel_Cases(t *testing.T) {
+ cases := map[string]string{
+ "debug": "DEBUG",
+ "info": "INFO",
+ "warn": "WARN",
+ "error": "ERROR",
+ "unknown": "INFO", // default fallback
+ }
+ for input, wantStr := range cases {
+ input, wantStr := input, wantStr
+ t.Run(input, func(t *testing.T) {
+ t.Setenv(defaultLogLevelVar, input)
+ require.Equal(t, wantStr, getLogLevel().String())
+ })
+ }
+}
+
func TestFindRandomAvailableUDPPort(t *testing.T) {
port, err := findRandomAvailableUDPPort()
require.NoError(t, err)
require.Greater(t, port, 0)
require.LessOrEqual(t, port, 65535)
}
+
+// --- addPeer ---------------------------------------------------------------
+
+func TestAddPeer_HigherLocalPubkey_SetsEndpoint(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv) // local spk lexicographically larger
+
+ remotePubKey := make([]byte, 32) // remote spk = all zeros (smaller)
+ err := m.addPeer(remotePubKey, "rosenpass-host:7000", "100.1.1.1", validWGKey(t, 1))
+ require.NoError(t, err)
+ require.Len(t, srv.addCalls, 1)
+
+ ep := srv.addCalls[0].cfg.Endpoint
+ require.NotNil(t, ep, "initiator side must set Endpoint")
+ require.Equal(t, 7000, ep.Port)
+ require.Equal(t, "100.1.1.1", ep.IP.String())
+}
+
+func TestAddPeer_HigherLocalPubkey_EndpointIPIsIPv4Mapped(t *testing.T) {
+ // Regression guard for the EDESTADDRREQ fix: Endpoint.IP must be 16-byte
+ // (IPv4-mapped IPv6) so it matches the AF_INET6 listening socket family.
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.NoError(t, err)
+
+ ep := srv.addCalls[0].cfg.Endpoint
+ require.NotNil(t, ep)
+ require.Len(t, ep.IP, 16, "IPv4 endpoint must be normalized to 16-byte v4-mapped form")
+ require.True(t, ep.IP.To4() != nil, "Endpoint must still be detected as IPv4")
+}
+
+func TestAddPeer_LowerLocalPubkey_LeavesEndpointNil(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0x00, srv) // local spk smaller
+
+ remotePubKey := make([]byte, 32)
+ remotePubKey[0] = 0xFF
+ err := m.addPeer(remotePubKey, "rp:5000", "100.1.1.1", validWGKey(t, 2))
+ require.NoError(t, err)
+
+ require.Nil(t, srv.addCalls[0].cfg.Endpoint, "responder side must NOT set Endpoint")
+}
+
+func TestAddPeer_PresharedKeyPropagated(t *testing.T) {
+ srv := &mockServer{}
+ psk := &wgtypes.Key{0x42}
+ m := newTestManager(0xFF, srv)
+ m.preSharedKey = (*[32]byte)(psk)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 3))
+ require.NoError(t, err)
+ require.Equal(t, [32]byte(*psk), [32]byte(srv.addCalls[0].cfg.PresharedKey))
+}
+
+func TestAddPeer_InvalidRosenpassAddr_ReturnsError(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv) // initiator path โ parses rosenpassAddr
+
+ err := m.addPeer(make([]byte, 32), "not-a-host-port", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+ require.Empty(t, srv.addCalls, "server.AddPeer must not run when address parse fails")
+}
+
+func TestAddPeer_InvalidWireGuardPubKey_ReturnsError(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", "not-a-valid-key")
+ require.Error(t, err)
+}
+
+func TestAddPeer_ServerError_Propagates(t *testing.T) {
+ srv := &mockServer{addErr: errors.New("boom")}
+ m := newTestManager(0xFF, srv)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+}
+
+// Regression guard for issue #4341 (Android crash). If Run() has not completed
+// before OnConnected fires, m.rpWgHandler or m.server may be nil. Without the
+// nil guards, m.rpWgHandler.AddPeer panics on nil receiver.
+func TestAddPeer_NilHandler_ReturnsErrorNoCrash(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+ m.rpWgHandler = nil // simulate Run() not yet completed
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "wg handler not initialized")
+}
+
+func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
+ m := newTestManager(0xFF, nil)
+ m.server = nil // simulate Run() not yet completed
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "server not initialized")
+}
+
+// NewManager must pre-initialize rpWgHandler so the nil-receiver crash from
+// issue #4341 cannot occur in the window between NewManager and Run().
+func TestNewManager_PreInitializesHandler(t *testing.T) {
+ psk := wgtypes.Key{}
+ m, err := NewManager(&psk, "wt0")
+ require.NoError(t, err)
+ require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
+}
+
+func TestAddPeer_RecordsPeerID(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 5)
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey)
+ require.NoError(t, err)
+ require.Contains(t, m.rpPeerIDs, wgKey)
+}
+
+// --- OnConnected / OnDisconnected ------------------------------------------
+
+func TestOnConnected_NilRemotePubKey_NoAddPeer(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ m.OnConnected(validWGKey(t, 1), nil, "100.1.1.1", "rp:5000")
+ require.Empty(t, srv.addCalls, "nil remote rosenpass pubkey must skip AddPeer")
+ require.Empty(t, m.rpPeerIDs)
+}
+
+func TestOnConnected_ValidPubKey_CallsAddPeer(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 1)
+ m.OnConnected(wgKey, make([]byte, 32), "100.1.1.1", "rp:5000")
+ require.Len(t, srv.addCalls, 1)
+ require.Contains(t, m.rpPeerIDs, wgKey)
+}
+
+func TestOnDisconnected_UnknownPeer_NoOp(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ m.OnDisconnected(validWGKey(t, 99))
+ require.Empty(t, srv.removed, "unknown peer key must not call RemovePeer")
+}
+
+func TestOnDisconnected_KnownPeer_CallsRemoveAndForgets(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 1)
+ require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
+ require.Contains(t, m.rpPeerIDs, wgKey)
+
+ m.OnDisconnected(wgKey)
+ require.Len(t, srv.removed, 1)
+ require.NotContains(t, m.rpPeerIDs, wgKey, "peer must be forgotten after disconnect")
+}
+
+// --- IsPresharedKeyInitialized ---------------------------------------------
+
+func TestIsPresharedKeyInitialized_UnknownPeer_ReturnsFalse(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+ require.False(t, m.IsPresharedKeyInitialized(validWGKey(t, 1)))
+}
+
+func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 2)
+ require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
+ require.False(t, m.IsPresharedKeyInitialized(wgKey))
+}
+
+// --- NetbirdHandler.outputKey ----------------------------------------------
+
+func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
+ h := NewNetbirdHandler()
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ pid := rp.PeerID{0x01}
+ wgKey := wgtypes.Key{0xAA}
+ h.AddPeer(pid, "wt0", rp.Key(wgKey))
+
+ psk := rp.Key{0xBB}
+ h.HandshakeCompleted(pid, psk)
+
+ require.Len(t, iface.calls, 1)
+ require.False(t, iface.calls[0].updateOnly, "first PSK rotation must use updateOnly=false")
+ require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
+}
+
+func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
+ h := NewNetbirdHandler()
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ pid := rp.PeerID{0x02}
+ h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xCC}))
+
+ h.HandshakeCompleted(pid, rp.Key{0x01}) // first
+ h.HandshakeCompleted(pid, rp.Key{0x02}) // second
+
+ require.Len(t, iface.calls, 2)
+ require.False(t, iface.calls[0].updateOnly)
+ require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
+}
+
+func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
+ h := NewNetbirdHandler()
+ // no SetInterface โ iface remains nil
+ pid := rp.PeerID{0x03}
+ h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
+
+ // Must not panic.
+ h.HandshakeCompleted(pid, rp.Key{})
+}
+
+func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
+ h := NewNetbirdHandler()
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ h.HandshakeCompleted(rp.PeerID{0xFF}, rp.Key{})
+ require.Empty(t, iface.calls, "unknown peer id must not trigger SetPresharedKey")
+}
+
+func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
+ h := NewNetbirdHandler()
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ pid := rp.PeerID{0x04}
+ h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xDD}))
+ h.HandshakeCompleted(pid, rp.Key{0x01})
+ require.True(t, h.IsPeerInitialized(pid))
+
+ h.RemovePeer(pid)
+ require.False(t, h.IsPeerInitialized(pid), "RemovePeer must clear initialized flag")
+}
+
+func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
+ h := NewNetbirdHandler()
+ pid := rp.PeerID{0x05}
+ wgKey := wgtypes.Key{0xEE}
+ h.AddPeer(pid, "wt0", rp.Key(wgKey))
+
+ iface := &mockIface{}
+ h.SetInterface(iface) // set after AddPeer
+
+ h.HandshakeCompleted(pid, rp.Key{0x42})
+ require.Len(t, iface.calls, 1)
+ require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
+}
diff --git a/client/internal/rosenpass/seed.go b/client/internal/rosenpass/seed.go
new file mode 100644
index 000000000..83aba1e0e
--- /dev/null
+++ b/client/internal/rosenpass/seed.go
@@ -0,0 +1,42 @@
+package rosenpass
+
+import (
+ "fmt"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
+// of peer public keys. Both peers, given the same key pair, produce the same
+// output regardless of which side runs the function: the inputs are ordered
+// lexicographically before concatenation.
+//
+// NetBird uses this value as the initial Rosenpass-side preshared key when no
+// explicit account-level PSK is configured, so both peers converge on the same
+// PSK before the first post-quantum handshake completes.
+//
+// The resulting key MUST NOT be treated as quantum-safe: it is deterministic
+// from public keys and exists only to seed WireGuard until Rosenpass rotates
+// in a real post-quantum PSK.
+func DeterministicSeedKey(localKey, remoteKey string) (*wgtypes.Key, error) {
+ lk := []byte(localKey)
+ rk := []byte(remoteKey)
+ if len(lk) < 16 || len(rk) < 16 {
+ return nil, fmt.Errorf("rosenpass: peer keys must be at least 16 bytes (got local=%d, remote=%d)", len(lk), len(rk))
+ }
+
+ var keyInput []byte
+ if localKey > remoteKey {
+ keyInput = append(keyInput, lk[:16]...)
+ keyInput = append(keyInput, rk[:16]...)
+ } else {
+ keyInput = append(keyInput, rk[:16]...)
+ keyInput = append(keyInput, lk[:16]...)
+ }
+
+ key, err := wgtypes.NewKey(keyInput)
+ if err != nil {
+ return nil, fmt.Errorf("rosenpass: deterministic seed key: %w", err)
+ }
+ return &key, nil
+}
diff --git a/client/internal/rosenpass/seed_test.go b/client/internal/rosenpass/seed_test.go
new file mode 100644
index 000000000..0dfa478c7
--- /dev/null
+++ b/client/internal/rosenpass/seed_test.go
@@ -0,0 +1,44 @@
+package rosenpass
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeterministicSeedKey_SameForBothSides(t *testing.T) {
+ // Peer A and peer B must derive the same PSK regardless of which side
+ // computes it: the function orders inputs internally.
+ a := strings.Repeat("a", 32)
+ b := strings.Repeat("b", 32)
+
+ keyAB, err := DeterministicSeedKey(a, b)
+ require.NoError(t, err)
+ keyBA, err := DeterministicSeedKey(b, a)
+ require.NoError(t, err)
+ require.Equal(t, keyAB.String(), keyBA.String(), "swapping arguments must yield identical key")
+}
+
+func TestDeterministicSeedKey_ChangesWithKeys(t *testing.T) {
+ a := strings.Repeat("a", 32)
+ b := strings.Repeat("b", 32)
+ c := strings.Repeat("c", 32)
+
+ keyAB, err := DeterministicSeedKey(a, b)
+ require.NoError(t, err)
+ keyAC, err := DeterministicSeedKey(a, c)
+ require.NoError(t, err)
+ require.NotEqual(t, keyAB.String(), keyAC.String(), "different peer pair must yield different key")
+}
+
+func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) {
+ short := "short" // < 16 bytes
+ long := strings.Repeat("x", 32)
+
+ _, err := DeterministicSeedKey(short, long)
+ require.Error(t, err)
+ _, err = DeterministicSeedKey(long, short)
+ require.Error(t, err)
+}
+
diff --git a/go.mod b/go.mod
index ea0d8d73d..caf9cb689 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/netbirdio/netbird
go 1.25.5
require (
- cunicu.li/go-rosenpass v0.4.0
+ cunicu.li/go-rosenpass v0.5.42
github.com/cenkalti/backoff/v4 v4.3.0
github.com/cloudflare/circl v1.3.3 // indirect
github.com/golang/protobuf v1.5.4
@@ -19,8 +19,8 @@ require (
github.com/vishvananda/netlink v1.3.1
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
- golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1
- golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
+ golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
+ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/windows v0.5.3
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
@@ -38,7 +38,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
github.com/c-robinson/iplib v1.0.3
github.com/caddyserver/certmagic v0.21.3
- github.com/cilium/ebpf v0.15.0
+ github.com/cilium/ebpf v0.19.0
github.com/coder/websocket v1.8.14
github.com/coreos/go-iptables v0.7.0
github.com/coreos/go-oidc/v3 v3.18.0
@@ -60,7 +60,7 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/gopacket v1.1.19
github.com/google/nftables v0.3.0
- github.com/gopacket/gopacket v1.1.1
+ github.com/gopacket/gopacket v1.4.0
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
diff --git a/go.sum b/go.sum
index f95efefa6..7f0081425 100644
--- a/go.sum
+++ b/go.sum
@@ -7,8 +7,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:b8xUw3004wk+3ipBhu0VU4RtUJsegMIiqjxSK4++lzA=
codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw=
-cunicu.li/go-rosenpass v0.4.0 h1:LtPtBgFWY/9emfgC4glKLEqS0MJTylzV6+ChRhiZERw=
-cunicu.li/go-rosenpass v0.4.0/go.mod h1:MPbjH9nxV4l3vEagKVdFNwHOketqgS5/To1VYJplf/M=
+cunicu.li/go-rosenpass v0.5.42 h1:fRDsGwCxd7DhDgZI1Pxeo8GtNyq8BESZJ7w2/BGGJtU=
+cunicu.li/go-rosenpass v0.5.42/go.mod h1:YRBeyKOe/gWpSX2kpDUec5p9t0XOLsshTguId5gTGVg=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
@@ -111,8 +111,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
-github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
+github.com/cilium/ebpf v0.19.0 h1:Ro/rE64RmFBeA9FGjcTc+KmCeY6jXmryu6FfnzPRIao=
+github.com/cilium/ebpf v0.19.0/go.mod h1:fLCgMo3l8tZmAdM3B2XqdFzXBpwkcSTroaVqN08OWVY=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
@@ -225,8 +225,8 @@ github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3Bum
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
-github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
-github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
+github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
@@ -307,8 +307,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
-github.com/gopacket/gopacket v1.1.1 h1:zbx9F9d6A7sWNkFKrvMBZTfGgxFoY4NgUudFVVHMfcw=
-github.com/gopacket/gopacket v1.1.1/go.mod h1:HavMeONEl7W9036of9LbSWoonqhH7HA1+ZRO+rMIvFs=
+github.com/gopacket/gopacket v1.4.0 h1:cr1OlFpzksCkZHNO0eLjaSSOrMQnpPXg0j6qHIY3y2U=
+github.com/gopacket/gopacket v1.4.0/go.mod h1:EpvsxINeehp5qj4YMKMLf2/dekdhKn2IIAO/ZOifS7o=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -390,6 +390,8 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM=
+github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE=
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=
@@ -900,8 +902,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
-golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE=
-golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
+golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
+golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
From 174dc24867178e55ff1aba3f35048e680dde371d Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Thu, 28 May 2026 19:14:14 +0200
Subject: [PATCH 18/44] [management] Add SSO session extend flow (management)
(#6197)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* add SSO session extend flow (management)
Adds the management-server half of the SSO session-extension feature:
- New ExtendAuthSession gRPC RPC that refreshes a peer's session expiry
using a fresh JWT, validated through the same pipeline as Login but
without tearing down the tunnel or redoing the NetworkMap sync.
- Per-peer SessionExpiresAt timestamp on every LoginResponse and
SyncResponse so connected clients learn the deadline on the existing
long-lived stream, and admin-side changes (toggling expiration,
changing the expiration window) reach every peer within seconds.
- SessionExpiresAt(...) helper on Peer that derives the absolute UTC
deadline from LastLogin + the account-level PeerLoginExpiration
setting, returning zero when the peer is not SSO-tracked or expiration
is disabled.
The matching client-side consumer of these fields lands separately.
* encode SessionExpiresAt as 3-state on the wire
Previously the `sessionExpiresAt` field on LoginResponse, SyncResponse
and ExtendAuthSessionResponse was 2-state: a valid timestamp meant
"new deadline", and nil meant "clear". That conflated two distinct
meanings โ "no info in this snapshot" vs "expiry is explicitly off /
peer is not SSO-tracked" โ so a Sync push that legitimately couldn't
compute the deadline (settings lookup failed) would silently clear the
client's anchor and lose the warning window.
Three states now, encoded on the same field number (no .proto schema
churn โ only comments and the server-side encoder change):
- nil pointer (field absent) โ "no info"; client preserves anchor
- &Timestamp{} (seconds=0, nanos=0) โ explicit "disabled / not SSO"
sentinel; client clears
- valid timestamp โ new absolute UTC deadline
A new encodeSessionExpiresAt helper centralises the zero/non-zero
encoding and is shared by the Sync, Login and ExtendAuthSession
builders. The Sync builder still emits nil when settings are missing.
Login and ExtendAuthSession always carry an authoritative value.
The matching client-side decoder lands on feature/session-extend.
* add UserExtendedPeerSession activity event
ExtendAuthSession previously reused UserLoggedInPeer for its audit
record, which conflated two distinct user actions: a full interactive
SSO login (tunnel re-established, network map resync) versus an
in-place deadline refresh (tunnel untouched). Auditors reading the log
couldn't tell which one happened, and downstream dashboards/alerts on
"login" volume were polluted by routine extends.
Adds a dedicated UserExtendedPeerSession Activity (code 125,
"user.peer.session.extend") and switches ExtendPeerSession over to it.
The peer-extend audit trail is now distinguishable from interactive
logins.
* make ExtendAuthSession JWT-retry backoff cancellable
Skip the retry log and 200ms wait on the final attempt, and replace the
uncancellable time.Sleep with a select on time.After/ctx.Done so an
upstream cancellation aborts the wait instead of running it to
completion.
---
.../internals/shared/grpc/conversion.go | 31 +
.../internals/shared/grpc/conversion_test.go | 27 +
management/internals/shared/grpc/server.go | 80 +
management/server/account.go | 12 +-
management/server/account/manager.go | 1 +
management/server/account/manager_mock.go | 15 +
management/server/activity/codes.go | 6 +
management/server/mock_server/account_mock.go | 9 +
management/server/peer.go | 73 +
management/server/peer/peer.go | 16 +
shared/management/client/client.go | 4 +
shared/management/client/grpc.go | 55 +
shared/management/client/mock.go | 8 +
shared/management/proto/management.pb.go | 1992 +++++++++--------
shared/management/proto/management.proto | 42 +
shared/management/proto/management_grpc.pb.go | 48 +
16 files changed, 1517 insertions(+), 902 deletions(-)
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index 12402b420..b4a0d8b28 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -6,9 +6,11 @@ import (
"net/netip"
"net/url"
"strings"
+ "time"
log "github.com/sirupsen/logrus"
goproto "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/timestamppb"
integrationsConfig "github.com/netbirdio/management-integrations/integrations/config"
@@ -185,9 +187,38 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
response.NetworkMap.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim}
}
+ // settings == nil โ field stays nil โ "no info in this snapshot", client
+ // preserves the deadline it already had. settings non-nil โ emit either a
+ // valid deadline or the explicit-zero "disabled" sentinel via
+ // encodeSessionExpiresAt.
+ if settings != nil {
+ response.SessionExpiresAt = encodeSessionExpiresAt(
+ peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration),
+ )
+ }
+
return response
}
+// encodeSessionExpiresAt encodes a server-side deadline into the 3-state wire
+// representation used on LoginResponse, SyncResponse and
+// ExtendAuthSessionResponse. See the proto comments on those messages.
+//
+// - deadline.IsZero() โ returns &Timestamp{} (seconds=0, nanos=0): the
+// "expiry disabled or peer is not SSO-tracked" sentinel; the client clears
+// its anchor.
+// - deadline non-zero โ returns timestamppb.New(deadline): the new absolute
+// UTC deadline.
+//
+// Returning nil ("no info, preserve client's anchor") is the caller's job โ
+// only meaningful on Sync builds where settings were not resolved.
+func encodeSessionExpiresAt(deadline time.Time) *timestamppb.Timestamp {
+ if deadline.IsZero() {
+ return ×tamppb.Timestamp{}
+ }
+ return timestamppb.New(deadline)
+}
+
func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]map[string]struct{}) ([][]byte, map[string]*proto.MachineUserIndexes) {
userIDToIndex := make(map[string]uint32)
var hashedUsers [][]byte
diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go
index 1e75caf95..5efb24319 100644
--- a/management/internals/shared/grpc/conversion_test.go
+++ b/management/internals/shared/grpc/conversion_test.go
@@ -5,6 +5,7 @@ import (
"net/netip"
"reflect"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
@@ -200,3 +201,29 @@ func TestBuildJWTConfig_Audiences(t *testing.T) {
})
}
}
+
+// TestEncodeSessionExpiresAt pins the wire encoding the client's
+// applySessionDeadline depends on:
+//
+// - zero deadline โ &Timestamp{} (seconds=0, nanos=0): the explicit
+// "expiry disabled or peer is not SSO-tracked" sentinel.
+// - non-zero โ timestamppb.New(deadline): the absolute UTC deadline.
+//
+// The third state (nil pointer = "no info in this snapshot") is the caller's
+// responsibility on the Sync path when settings could not be resolved; the
+// helper itself never returns nil.
+func TestEncodeSessionExpiresAt(t *testing.T) {
+ t.Run("zero deadline encodes as explicit-zero sentinel", func(t *testing.T) {
+ got := encodeSessionExpiresAt(time.Time{})
+ assert.NotNil(t, got, "must not return nil; nil means 'no info', not 'disabled'")
+ assert.Equal(t, int64(0), got.GetSeconds())
+ assert.Equal(t, int32(0), got.GetNanos())
+ })
+
+ t.Run("non-zero deadline round-trips", func(t *testing.T) {
+ deadline := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
+ got := encodeSessionExpiresAt(deadline)
+ assert.NotNil(t, got)
+ assert.True(t, got.AsTime().Equal(deadline))
+ })
+}
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index d36e72045..2d19ca32b 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -821,6 +821,80 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
}, nil
}
+// ExtendAuthSession refreshes the peer's SSO session expiry deadline using a
+// fresh JWT. The same JWT validation pipeline as Login is used. The tunnel
+// stays up; no network map sync is performed. The new deadline is returned
+// in ExtendAuthSessionResponse.SessionExpiresAt.
+func (s *Server) ExtendAuthSession(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) {
+ extendReq := &proto.ExtendAuthSessionRequest{}
+ peerKey, err := s.parseRequest(ctx, req, extendReq)
+ if err != nil {
+ return nil, err
+ }
+
+ //nolint
+ ctx = context.WithValue(ctx, nbContext.PeerIDKey, peerKey.String())
+ if accountID, accErr := s.accountManager.GetAccountIDForPeerKey(ctx, peerKey.String()); accErr == nil {
+ //nolint
+ ctx = context.WithValue(ctx, nbContext.AccountIDKey, accountID)
+ }
+
+ jwt := extendReq.GetJwtToken()
+ if jwt == "" {
+ return nil, status.Errorf(codes.InvalidArgument, "jwt token is required")
+ }
+
+ var userID string
+ const attempts = 3
+ for i := 0; i < attempts; i++ {
+ userID, err = s.validateToken(ctx, peerKey.String(), jwt)
+ if err == nil {
+ break
+ }
+ if i == attempts-1 {
+ break
+ }
+ log.WithContext(ctx).Warnf("failed validating JWT token while extending session for peer %s: %v. Retrying (idP cache).", peerKey.String(), err)
+ select {
+ case <-time.After(200 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ if userID == "" {
+ return nil, status.Errorf(codes.Unauthenticated, "jwt token did not yield a user id")
+ }
+
+ deadline, err := s.accountManager.ExtendPeerSession(ctx, peerKey.String(), userID)
+ if err != nil {
+ log.WithContext(ctx).Warnf("failed extending session for peer %s: %v", peerKey.String(), err)
+ return nil, mapError(ctx, err)
+ }
+
+ // Success path normally returns a non-zero deadline. A defensive zero
+ // would still encode as the explicit "disabled" sentinel rather than nil,
+ // so the client clears any stale anchor instead of preserving it.
+ resp := &proto.ExtendAuthSessionResponse{
+ SessionExpiresAt: encodeSessionExpiresAt(deadline),
+ }
+
+ wgKey, err := s.secretsManager.GetWGKey()
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed processing request")
+ }
+ encrypted, err := encryption.EncryptMessage(peerKey, wgKey, resp)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed encrypting response")
+ }
+ return &proto.EncryptedMessage{
+ WgPubKey: wgKey.PublicKey().String(),
+ Body: encrypted,
+ }, nil
+}
+
func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, netMap *types.NetworkMap, postureChecks []*posture.Checks) (*proto.LoginResponse, error) {
var relayToken *Token
var err error
@@ -844,6 +918,12 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne
Checks: toProtocolChecks(ctx, postureChecks),
}
+ // settings is always non-nil here, so we never emit nil โ encoder returns
+ // either a valid deadline or the explicit-zero "disabled" sentinel.
+ loginResp.SessionExpiresAt = encodeSessionExpiresAt(
+ peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration),
+ )
+
return loginResp, nil
}
diff --git a/management/server/account.go b/management/server/account.go
index 8e4e595f0..d61380d91 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -355,7 +355,17 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
oldSettings.LazyConnectionEnabled != newSettings.LazyConnectionEnabled ||
oldSettings.DNSDomain != newSettings.DNSDomain ||
oldSettings.AutoUpdateVersion != newSettings.AutoUpdateVersion ||
- oldSettings.AutoUpdateAlways != newSettings.AutoUpdateAlways {
+ oldSettings.AutoUpdateAlways != newSettings.AutoUpdateAlways ||
+ oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled ||
+ oldSettings.PeerLoginExpiration != newSettings.PeerLoginExpiration {
+ // Session deadline is derived from LastLogin + PeerLoginExpiration
+ // on every Login/Sync response. Without a fan-out push, connected
+ // peers keep the deadline they received at login time and only see
+ // the new value after the next unrelated NetworkMap change. Add
+ // these two fields to the trigger list so admin-side expiry tweaks
+ // (e.g. shortening from 24h to 1h) reach every connected peer
+ // within seconds, which is what the proactive-warning feature
+ // relies on (see client/internal/auth/sessionwatch).
updateAccountPeers = true
}
diff --git a/management/server/account/manager.go b/management/server/account/manager.go
index ae3de8d79..b7b159915 100644
--- a/management/server/account/manager.go
+++ b/management/server/account/manager.go
@@ -109,6 +109,7 @@ type Manager interface {
UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error)
LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) // used by peer gRPC API
+ ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession
SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API
GetExternalCacheManager() ExternalCacheManager
GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error)
diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go
index 0486e63ec..81127a6b4 100644
--- a/management/server/account/manager_mock.go
+++ b/management/server/account/manager_mock.go
@@ -1304,6 +1304,21 @@ func (mr *MockManagerMockRecorder) LoginPeer(ctx, login interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoginPeer", reflect.TypeOf((*MockManager)(nil).LoginPeer), ctx, login)
}
+// ExtendPeerSession mocks base method.
+func (m *MockManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ExtendPeerSession", ctx, peerPubKey, userID)
+ ret0, _ := ret[0].(time.Time)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// ExtendPeerSession indicates an expected call of ExtendPeerSession.
+func (mr *MockManagerMockRecorder) ExtendPeerSession(ctx, peerPubKey, userID interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendPeerSession", reflect.TypeOf((*MockManager)(nil).ExtendPeerSession), ctx, peerPubKey, userID)
+}
+
// MarkPeerConnected mocks base method.
func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error {
m.ctrl.T.Helper()
diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go
index 6c781a952..852193a3b 100644
--- a/management/server/activity/codes.go
+++ b/management/server/activity/codes.go
@@ -240,6 +240,10 @@ const (
AccountLocalMfaEnabled Activity = 123
// AccountLocalMfaDisabled indicates that a user disabled TOTP MFA for local users
AccountLocalMfaDisabled Activity = 124
+ // UserExtendedPeerSession indicates that a user refreshed their peer's
+ // SSO session deadline via ExtendAuthSession without re-establishing the
+ // tunnel. Distinct from UserLoggedInPeer (full interactive login).
+ UserExtendedPeerSession Activity = 125
AccountDeleted Activity = 99999
)
@@ -394,6 +398,8 @@ var activityMap = map[Activity]Code{
AccountLocalMfaEnabled: {"Account local MFA enabled", "account.setting.local.mfa.enable"},
AccountLocalMfaDisabled: {"Account local MFA disabled", "account.setting.local.mfa.disable"},
+ UserExtendedPeerSession: {"User extended peer session", "user.peer.session.extend"},
+
DomainAdded: {"Domain added", "domain.add"},
DomainDeleted: {"Domain deleted", "domain.delete"},
DomainValidated: {"Domain validated", "domain.validate"},
diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go
index aba408184..32549a521 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -98,6 +98,7 @@ type MockAccountManager struct {
GetPeerFunc func(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
UpdateAccountSettingsFunc func(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error)
+ ExtendPeerSessionFunc func(ctx context.Context, peerPubKey, userID string) (time.Time, error)
SyncPeerFunc func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
InviteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserEmail string) error
ApproveUserFunc func(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error)
@@ -860,6 +861,14 @@ func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLog
return nil, nil, nil, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented")
}
+// ExtendPeerSession mocks ExtendPeerSession of the AccountManager interface
+func (am *MockAccountManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) {
+ if am.ExtendPeerSessionFunc != nil {
+ return am.ExtendPeerSessionFunc(ctx, peerPubKey, userID)
+ }
+ return time.Time{}, status.Errorf(codes.Unimplemented, "method ExtendPeerSession is not implemented")
+}
+
// SyncPeer mocks SyncPeer of the AccountManager interface
func (am *MockAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
if am.SyncPeerFunc != nil {
diff --git a/management/server/peer.go b/management/server/peer.go
index 37cacee41..7066bf307 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -1151,6 +1151,79 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return p, nmap, pc, err
}
+// ExtendPeerSession refreshes the peer's SSO session deadline by updating
+// LastLogin after a successful JWT validation. The tunnel is untouched: no
+// network map sync, no peer reconnect.
+//
+// Preconditions enforced here:
+// - userID must be present (caller validated the JWT and extracted the user ID).
+// - The peer must exist and be SSO-registered (AddedWithSSOLogin) with
+// LoginExpirationEnabled.
+// - Account-level PeerLoginExpirationEnabled must be true.
+// - The JWT user must match peer.UserID (mirrors LoginPeer at peer.go ~1028).
+//
+// Returns the new absolute UTC deadline.
+func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) {
+ if userID == "" {
+ return time.Time{}, status.Errorf(status.PermissionDenied, "session extend requires a JWT")
+ }
+
+ accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, peerPubKey)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if !settings.PeerLoginExpirationEnabled {
+ return time.Time{}, status.Errorf(status.PreconditionFailed, "peer login expiration is disabled for the account")
+ }
+
+ var refreshed *nbpeer.Peer
+ err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ peer, err := transaction.GetPeerByPeerPubKey(ctx, store.LockingStrengthUpdate, peerPubKey)
+ if err != nil {
+ return err
+ }
+
+ if !peer.AddedWithSSOLogin() || !peer.LoginExpirationEnabled {
+ return status.Errorf(status.PreconditionFailed, "peer is not eligible for session extension")
+ }
+
+ if peer.UserID != userID {
+ log.WithContext(ctx).Warnf("user mismatch when extending session for peer %s: peer user %s, jwt user %s", peer.ID, peer.UserID, userID)
+ return status.NewPeerLoginMismatchError()
+ }
+
+ peer = peer.UpdateLastLogin()
+ if err := transaction.SavePeer(ctx, accountID, peer); err != nil {
+ return err
+ }
+
+ if err := transaction.SaveUserLastLogin(ctx, accountID, userID, peer.GetLastLogin()); err != nil {
+ log.WithContext(ctx).Debugf("failed to update user last login during session extend: %v", err)
+ }
+
+ am.StoreEvent(ctx, userID, peer.ID, accountID, activity.UserExtendedPeerSession, peer.EventMeta(am.networkMapController.GetDNSDomain(settings)))
+ refreshed = peer
+ return nil
+ })
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ // Reschedule the per-account expiration job. schedulePeerLoginExpiration
+ // is a no-op when a job is already running, but the running job will pick
+ // up the new LastLogin on its next tick. Calling it here is harmless and
+ // guarantees a job is in flight even if a prior one ended right before
+ // the extend.
+ am.schedulePeerLoginExpiration(ctx, accountID)
+
+ return refreshed.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), nil
+}
+
// getPeerPostureChecks returns the posture checks for the peer.
func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string) ([]*posture.Checks, error) {
policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go
index 6294d1c0a..e5475c07d 100644
--- a/management/server/peer/peer.go
+++ b/management/server/peer/peer.go
@@ -367,6 +367,22 @@ func (p *Peer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) {
return timeLeft <= 0, timeLeft
}
+// SessionExpiresAt returns the absolute UTC instant at which the peer's SSO
+// session expires, derived from LastLogin and the account-level
+// PeerLoginExpiration setting. Returns the zero value when login expiration
+// does not apply (peer not SSO-registered, peer-level toggle off, or account
+// expiry disabled). Callers should treat the zero value as "no deadline".
+func (p *Peer) SessionExpiresAt(accountExpirationEnabled bool, expiresIn time.Duration) time.Time {
+ if !accountExpirationEnabled || !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled {
+ return time.Time{}
+ }
+ last := p.GetLastLogin()
+ if last.IsZero() {
+ return time.Time{}
+ }
+ return last.Add(expiresIn).UTC()
+}
+
// FQDN returns peers FQDN combined of the peer's DNS label and the system's DNS domain
func (p *Peer) FQDN(dnsDomain string) string {
if dnsDomain == "" {
diff --git a/shared/management/client/client.go b/shared/management/client/client.go
index 18efba87b..8205e3a4f 100644
--- a/shared/management/client/client.go
+++ b/shared/management/client/client.go
@@ -16,6 +16,10 @@ type Client interface {
Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error
Register(setupKey string, jwtToken string, sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
Login(sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
+ // ExtendAuthSession refreshes the peer's SSO session deadline using a fresh JWT.
+ // Returns the new absolute deadline; zero time when the server reports the peer
+ // is not eligible for session extension.
+ ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error)
GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error)
GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error)
diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go
index 58895b7c2..016cde68a 100644
--- a/shared/management/client/grpc.go
+++ b/shared/management/client/grpc.go
@@ -607,6 +607,61 @@ func (c *GrpcClient) Login(sysInfo *system.Info, pubSSHKey []byte, dnsLabels dom
return c.login(&proto.LoginRequest{Meta: infoToMetaData(sysInfo), PeerKeys: keys, DnsLabels: dnsLabels.ToPunycodeList()})
}
+// ExtendAuthSession refreshes the peer's SSO session deadline on the management
+// server using a freshly issued JWT. The tunnel is untouched: no network map
+// sync, no peer reconnect. Returns the new absolute UTC deadline (zero time
+// when the server reports the field empty).
+func (c *GrpcClient) ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error) {
+ if !c.ready() {
+ return nil, errors.New(errMsgNoMgmtConnection)
+ }
+
+ serverKey, err := c.getServerPublicKey()
+ if err != nil {
+ return nil, err
+ }
+
+ reqBody, err := encryption.EncryptMessage(*serverKey, c.key, &proto.ExtendAuthSessionRequest{
+ JwtToken: jwtToken,
+ Meta: infoToMetaData(sysInfo),
+ })
+ if err != nil {
+ log.Errorf("failed to encrypt extend auth session message: %s", err)
+ return nil, err
+ }
+
+ var resp *proto.EncryptedMessage
+ operation := func() error {
+ mgmCtx, cancel := context.WithTimeout(context.Background(), ConnectTimeout)
+ defer cancel()
+
+ var err error
+ resp, err = c.realClient.ExtendAuthSession(mgmCtx, &proto.EncryptedMessage{
+ WgPubKey: c.key.PublicKey().String(),
+ Body: reqBody,
+ })
+ if err != nil {
+ if s, ok := gstatus.FromError(err); ok && s.Code() == codes.Canceled {
+ return err
+ }
+ return backoff.Permanent(err)
+ }
+ return nil
+ }
+
+ if err := backoff.Retry(operation, nbgrpc.Backoff(c.ctx)); err != nil {
+ log.Errorf("failed to extend auth session on Management Service: %v", err)
+ return nil, err
+ }
+
+ out := &proto.ExtendAuthSessionResponse{}
+ if err := encryption.DecryptMessage(*serverKey, c.key, resp.Body, out); err != nil {
+ log.Errorf("failed to decrypt extend auth session response: %s", err)
+ return nil, err
+ }
+ return out, nil
+}
+
// GetDeviceAuthorizationFlow returns a device authorization flow information.
// It also takes care of encrypting and decrypting messages.
func (c *GrpcClient) GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error) {
diff --git a/shared/management/client/mock.go b/shared/management/client/mock.go
index 361e8ffad..ba156a225 100644
--- a/shared/management/client/mock.go
+++ b/shared/management/client/mock.go
@@ -14,6 +14,7 @@ type MockClient struct {
SyncFunc func(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error
RegisterFunc func(setupKey string, jwtToken string, info *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
LoginFunc func(info *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
+ ExtendAuthSessionFunc func(info *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
GetDeviceAuthorizationFlowFunc func() (*proto.DeviceAuthorizationFlow, error)
GetPKCEAuthorizationFlowFunc func() (*proto.PKCEAuthorizationFlow, error)
GetServerURLFunc func() string
@@ -65,6 +66,13 @@ func (m *MockClient) Login(info *system.Info, sshKey []byte, dnsLabels domain.Li
return m.LoginFunc(info, sshKey, dnsLabels)
}
+func (m *MockClient) ExtendAuthSession(info *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error) {
+ if m.ExtendAuthSessionFunc == nil {
+ return nil, nil
+ }
+ return m.ExtendAuthSessionFunc(info, jwtToken)
+}
+
func (m *MockClient) GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error) {
if m.GetDeviceAuthorizationFlowFunc == nil {
return nil, nil
diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go
index 13f4fbc8d..5dd529407 100644
--- a/shared/management/proto/management.pb.go
+++ b/shared/management/proto/management.pb.go
@@ -381,7 +381,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{18, 0}
+ return file_management_proto_rawDescGZIP(), []int{20, 0}
}
type DeviceAuthorizationFlowProvider int32
@@ -424,7 +424,7 @@ func (x DeviceAuthorizationFlowProvider) Number() protoreflect.EnumNumber {
// Deprecated: Use DeviceAuthorizationFlowProvider.Descriptor instead.
func (DeviceAuthorizationFlowProvider) EnumDescriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{31, 0}
+ return file_management_proto_rawDescGZIP(), []int{33, 0}
}
type EncryptedMessage struct {
@@ -843,6 +843,11 @@ type SyncResponse struct {
NetworkMap *NetworkMap `protobuf:"bytes,5,opt,name=NetworkMap,proto3" json:"NetworkMap,omitempty"`
// Posture checks to be evaluated by client
Checks []*Checks `protobuf:"bytes,6,rep,name=Checks,proto3" json:"Checks,omitempty"`
+ // Absolute UTC instant at which the peer's SSO session expires.
+ // Unset when the peer is not SSO-registered or login expiration is disabled.
+ // Carried on every Sync snapshot so admin-side changes propagate live without
+ // a client reconnect.
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
}
func (x *SyncResponse) Reset() {
@@ -919,6 +924,13 @@ func (x *SyncResponse) GetChecks() []*Checks {
return nil
}
+func (x *SyncResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
type SyncMetaRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -1596,6 +1608,9 @@ type LoginResponse struct {
PeerConfig *PeerConfig `protobuf:"bytes,2,opt,name=peerConfig,proto3" json:"peerConfig,omitempty"`
// Posture checks to be evaluated by client
Checks []*Checks `protobuf:"bytes,3,rep,name=Checks,proto3" json:"Checks,omitempty"`
+ // Absolute UTC instant at which the peer's SSO session expires.
+ // Unset when the peer is not SSO-registered or login expiration is disabled.
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
}
func (x *LoginResponse) Reset() {
@@ -1651,6 +1666,122 @@ func (x *LoginResponse) GetChecks() []*Checks {
return nil
}
+func (x *LoginResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
+// ExtendAuthSessionRequest carries a fresh JWT to refresh the peer's session deadline.
+// The encrypted body of an EncryptedMessage with this payload is sent to the
+// ExtendAuthSession RPC.
+type ExtendAuthSessionRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // SSO token (must be a fresh, valid JWT for the peer's owning user)
+ JwtToken string `protobuf:"bytes,1,opt,name=jwtToken,proto3" json:"jwtToken,omitempty"`
+ // Meta data of the peer (used for IdP user info refresh consistent with Login)
+ Meta *PeerSystemMeta `protobuf:"bytes,2,opt,name=meta,proto3" json:"meta,omitempty"`
+}
+
+func (x *ExtendAuthSessionRequest) Reset() {
+ *x = ExtendAuthSessionRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_management_proto_msgTypes[15]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ExtendAuthSessionRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ExtendAuthSessionRequest) ProtoMessage() {}
+
+func (x *ExtendAuthSessionRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_management_proto_msgTypes[15]
+ 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 ExtendAuthSessionRequest.ProtoReflect.Descriptor instead.
+func (*ExtendAuthSessionRequest) Descriptor() ([]byte, []int) {
+ return file_management_proto_rawDescGZIP(), []int{15}
+}
+
+func (x *ExtendAuthSessionRequest) GetJwtToken() string {
+ if x != nil {
+ return x.JwtToken
+ }
+ return ""
+}
+
+func (x *ExtendAuthSessionRequest) GetMeta() *PeerSystemMeta {
+ if x != nil {
+ return x.Meta
+ }
+ return nil
+}
+
+// ExtendAuthSessionResponse contains the refreshed session deadline.
+type ExtendAuthSessionResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // Absolute UTC instant at which the peer's SSO session now expires.
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
+}
+
+func (x *ExtendAuthSessionResponse) Reset() {
+ *x = ExtendAuthSessionResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_management_proto_msgTypes[16]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ExtendAuthSessionResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ExtendAuthSessionResponse) ProtoMessage() {}
+
+func (x *ExtendAuthSessionResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_management_proto_msgTypes[16]
+ 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 ExtendAuthSessionResponse.ProtoReflect.Descriptor instead.
+func (*ExtendAuthSessionResponse) Descriptor() ([]byte, []int) {
+ return file_management_proto_rawDescGZIP(), []int{16}
+}
+
+func (x *ExtendAuthSessionResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
type ServerKeyResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -1667,7 +1798,7 @@ type ServerKeyResponse struct {
func (x *ServerKeyResponse) Reset() {
*x = ServerKeyResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[15]
+ mi := &file_management_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1680,7 +1811,7 @@ func (x *ServerKeyResponse) String() string {
func (*ServerKeyResponse) ProtoMessage() {}
func (x *ServerKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[15]
+ mi := &file_management_proto_msgTypes[17]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1693,7 +1824,7 @@ func (x *ServerKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ServerKeyResponse.ProtoReflect.Descriptor instead.
func (*ServerKeyResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{15}
+ return file_management_proto_rawDescGZIP(), []int{17}
}
func (x *ServerKeyResponse) GetKey() string {
@@ -1726,7 +1857,7 @@ type Empty struct {
func (x *Empty) Reset() {
*x = Empty{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[16]
+ mi := &file_management_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1739,7 +1870,7 @@ func (x *Empty) String() string {
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[16]
+ mi := &file_management_proto_msgTypes[18]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1752,7 +1883,7 @@ func (x *Empty) ProtoReflect() protoreflect.Message {
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{16}
+ return file_management_proto_rawDescGZIP(), []int{18}
}
// NetbirdConfig is a common configuration of any Netbird peer. It contains STUN, TURN, Signal and Management servers configurations
@@ -1774,7 +1905,7 @@ type NetbirdConfig struct {
func (x *NetbirdConfig) Reset() {
*x = NetbirdConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[17]
+ mi := &file_management_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1787,7 +1918,7 @@ func (x *NetbirdConfig) String() string {
func (*NetbirdConfig) ProtoMessage() {}
func (x *NetbirdConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[17]
+ mi := &file_management_proto_msgTypes[19]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1800,7 +1931,7 @@ func (x *NetbirdConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetbirdConfig.ProtoReflect.Descriptor instead.
func (*NetbirdConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{17}
+ return file_management_proto_rawDescGZIP(), []int{19}
}
func (x *NetbirdConfig) GetStuns() []*HostConfig {
@@ -1852,7 +1983,7 @@ type HostConfig struct {
func (x *HostConfig) Reset() {
*x = HostConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[18]
+ mi := &file_management_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1865,7 +1996,7 @@ func (x *HostConfig) String() string {
func (*HostConfig) ProtoMessage() {}
func (x *HostConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[18]
+ mi := &file_management_proto_msgTypes[20]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1878,7 +2009,7 @@ func (x *HostConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use HostConfig.ProtoReflect.Descriptor instead.
func (*HostConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{18}
+ return file_management_proto_rawDescGZIP(), []int{20}
}
func (x *HostConfig) GetUri() string {
@@ -1908,7 +2039,7 @@ type RelayConfig struct {
func (x *RelayConfig) Reset() {
*x = RelayConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[19]
+ mi := &file_management_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1921,7 +2052,7 @@ func (x *RelayConfig) String() string {
func (*RelayConfig) ProtoMessage() {}
func (x *RelayConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[19]
+ mi := &file_management_proto_msgTypes[21]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1934,7 +2065,7 @@ func (x *RelayConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use RelayConfig.ProtoReflect.Descriptor instead.
func (*RelayConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{19}
+ return file_management_proto_rawDescGZIP(), []int{21}
}
func (x *RelayConfig) GetUrls() []string {
@@ -1979,7 +2110,7 @@ type FlowConfig struct {
func (x *FlowConfig) Reset() {
*x = FlowConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[20]
+ mi := &file_management_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1992,7 +2123,7 @@ func (x *FlowConfig) String() string {
func (*FlowConfig) ProtoMessage() {}
func (x *FlowConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[20]
+ mi := &file_management_proto_msgTypes[22]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2005,7 +2136,7 @@ func (x *FlowConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use FlowConfig.ProtoReflect.Descriptor instead.
func (*FlowConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{20}
+ return file_management_proto_rawDescGZIP(), []int{22}
}
func (x *FlowConfig) GetUrl() string {
@@ -2083,7 +2214,7 @@ type JWTConfig struct {
func (x *JWTConfig) Reset() {
*x = JWTConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[21]
+ mi := &file_management_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2096,7 +2227,7 @@ func (x *JWTConfig) String() string {
func (*JWTConfig) ProtoMessage() {}
func (x *JWTConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[21]
+ mi := &file_management_proto_msgTypes[23]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2109,7 +2240,7 @@ func (x *JWTConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use JWTConfig.ProtoReflect.Descriptor instead.
func (*JWTConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{21}
+ return file_management_proto_rawDescGZIP(), []int{23}
}
func (x *JWTConfig) GetIssuer() string {
@@ -2162,7 +2293,7 @@ type ProtectedHostConfig struct {
func (x *ProtectedHostConfig) Reset() {
*x = ProtectedHostConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[22]
+ mi := &file_management_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2175,7 +2306,7 @@ func (x *ProtectedHostConfig) String() string {
func (*ProtectedHostConfig) ProtoMessage() {}
func (x *ProtectedHostConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[22]
+ mi := &file_management_proto_msgTypes[24]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2188,7 +2319,7 @@ func (x *ProtectedHostConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use ProtectedHostConfig.ProtoReflect.Descriptor instead.
func (*ProtectedHostConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{22}
+ return file_management_proto_rawDescGZIP(), []int{24}
}
func (x *ProtectedHostConfig) GetHostConfig() *HostConfig {
@@ -2239,7 +2370,7 @@ type PeerConfig struct {
func (x *PeerConfig) Reset() {
*x = PeerConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[23]
+ mi := &file_management_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2252,7 +2383,7 @@ func (x *PeerConfig) String() string {
func (*PeerConfig) ProtoMessage() {}
func (x *PeerConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[23]
+ mi := &file_management_proto_msgTypes[25]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2265,7 +2396,7 @@ func (x *PeerConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use PeerConfig.ProtoReflect.Descriptor instead.
func (*PeerConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{23}
+ return file_management_proto_rawDescGZIP(), []int{25}
}
func (x *PeerConfig) GetAddress() string {
@@ -2345,7 +2476,7 @@ type AutoUpdateSettings struct {
func (x *AutoUpdateSettings) Reset() {
*x = AutoUpdateSettings{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[24]
+ mi := &file_management_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2358,7 +2489,7 @@ func (x *AutoUpdateSettings) String() string {
func (*AutoUpdateSettings) ProtoMessage() {}
func (x *AutoUpdateSettings) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[24]
+ mi := &file_management_proto_msgTypes[26]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2371,7 +2502,7 @@ func (x *AutoUpdateSettings) ProtoReflect() protoreflect.Message {
// Deprecated: Use AutoUpdateSettings.ProtoReflect.Descriptor instead.
func (*AutoUpdateSettings) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{24}
+ return file_management_proto_rawDescGZIP(), []int{26}
}
func (x *AutoUpdateSettings) GetVersion() string {
@@ -2426,7 +2557,7 @@ type NetworkMap struct {
func (x *NetworkMap) Reset() {
*x = NetworkMap{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[25]
+ mi := &file_management_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2439,7 +2570,7 @@ func (x *NetworkMap) String() string {
func (*NetworkMap) ProtoMessage() {}
func (x *NetworkMap) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[25]
+ mi := &file_management_proto_msgTypes[27]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2452,7 +2583,7 @@ func (x *NetworkMap) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetworkMap.ProtoReflect.Descriptor instead.
func (*NetworkMap) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{25}
+ return file_management_proto_rawDescGZIP(), []int{27}
}
func (x *NetworkMap) GetSerial() uint64 {
@@ -2562,7 +2693,7 @@ type SSHAuth struct {
func (x *SSHAuth) Reset() {
*x = SSHAuth{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[26]
+ mi := &file_management_proto_msgTypes[28]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2575,7 +2706,7 @@ func (x *SSHAuth) String() string {
func (*SSHAuth) ProtoMessage() {}
func (x *SSHAuth) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[26]
+ mi := &file_management_proto_msgTypes[28]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2588,7 +2719,7 @@ func (x *SSHAuth) ProtoReflect() protoreflect.Message {
// Deprecated: Use SSHAuth.ProtoReflect.Descriptor instead.
func (*SSHAuth) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{26}
+ return file_management_proto_rawDescGZIP(), []int{28}
}
func (x *SSHAuth) GetUserIDClaim() string {
@@ -2623,7 +2754,7 @@ type MachineUserIndexes struct {
func (x *MachineUserIndexes) Reset() {
*x = MachineUserIndexes{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[27]
+ mi := &file_management_proto_msgTypes[29]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2636,7 +2767,7 @@ func (x *MachineUserIndexes) String() string {
func (*MachineUserIndexes) ProtoMessage() {}
func (x *MachineUserIndexes) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[27]
+ mi := &file_management_proto_msgTypes[29]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2649,7 +2780,7 @@ func (x *MachineUserIndexes) ProtoReflect() protoreflect.Message {
// Deprecated: Use MachineUserIndexes.ProtoReflect.Descriptor instead.
func (*MachineUserIndexes) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{27}
+ return file_management_proto_rawDescGZIP(), []int{29}
}
func (x *MachineUserIndexes) GetIndexes() []uint32 {
@@ -2680,7 +2811,7 @@ type RemotePeerConfig struct {
func (x *RemotePeerConfig) Reset() {
*x = RemotePeerConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[28]
+ mi := &file_management_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2693,7 +2824,7 @@ func (x *RemotePeerConfig) String() string {
func (*RemotePeerConfig) ProtoMessage() {}
func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[28]
+ mi := &file_management_proto_msgTypes[30]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2706,7 +2837,7 @@ func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemotePeerConfig.ProtoReflect.Descriptor instead.
func (*RemotePeerConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{28}
+ return file_management_proto_rawDescGZIP(), []int{30}
}
func (x *RemotePeerConfig) GetWgPubKey() string {
@@ -2761,7 +2892,7 @@ type SSHConfig struct {
func (x *SSHConfig) Reset() {
*x = SSHConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[29]
+ mi := &file_management_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2774,7 +2905,7 @@ func (x *SSHConfig) String() string {
func (*SSHConfig) ProtoMessage() {}
func (x *SSHConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[29]
+ mi := &file_management_proto_msgTypes[31]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2787,7 +2918,7 @@ func (x *SSHConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use SSHConfig.ProtoReflect.Descriptor instead.
func (*SSHConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{29}
+ return file_management_proto_rawDescGZIP(), []int{31}
}
func (x *SSHConfig) GetSshEnabled() bool {
@@ -2821,7 +2952,7 @@ type DeviceAuthorizationFlowRequest struct {
func (x *DeviceAuthorizationFlowRequest) Reset() {
*x = DeviceAuthorizationFlowRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[30]
+ mi := &file_management_proto_msgTypes[32]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2834,7 +2965,7 @@ func (x *DeviceAuthorizationFlowRequest) String() string {
func (*DeviceAuthorizationFlowRequest) ProtoMessage() {}
func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[30]
+ mi := &file_management_proto_msgTypes[32]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2847,7 +2978,7 @@ func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeviceAuthorizationFlowRequest.ProtoReflect.Descriptor instead.
func (*DeviceAuthorizationFlowRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{30}
+ return file_management_proto_rawDescGZIP(), []int{32}
}
// DeviceAuthorizationFlow represents Device Authorization Flow information
@@ -2866,7 +2997,7 @@ type DeviceAuthorizationFlow struct {
func (x *DeviceAuthorizationFlow) Reset() {
*x = DeviceAuthorizationFlow{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[31]
+ mi := &file_management_proto_msgTypes[33]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2879,7 +3010,7 @@ func (x *DeviceAuthorizationFlow) String() string {
func (*DeviceAuthorizationFlow) ProtoMessage() {}
func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[31]
+ mi := &file_management_proto_msgTypes[33]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2892,7 +3023,7 @@ func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeviceAuthorizationFlow.ProtoReflect.Descriptor instead.
func (*DeviceAuthorizationFlow) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{31}
+ return file_management_proto_rawDescGZIP(), []int{33}
}
func (x *DeviceAuthorizationFlow) GetProvider() DeviceAuthorizationFlowProvider {
@@ -2919,7 +3050,7 @@ type PKCEAuthorizationFlowRequest struct {
func (x *PKCEAuthorizationFlowRequest) Reset() {
*x = PKCEAuthorizationFlowRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[32]
+ mi := &file_management_proto_msgTypes[34]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2932,7 +3063,7 @@ func (x *PKCEAuthorizationFlowRequest) String() string {
func (*PKCEAuthorizationFlowRequest) ProtoMessage() {}
func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[32]
+ mi := &file_management_proto_msgTypes[34]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2945,7 +3076,7 @@ func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PKCEAuthorizationFlowRequest.ProtoReflect.Descriptor instead.
func (*PKCEAuthorizationFlowRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{32}
+ return file_management_proto_rawDescGZIP(), []int{34}
}
// PKCEAuthorizationFlow represents Authorization Code Flow information
@@ -2962,7 +3093,7 @@ type PKCEAuthorizationFlow struct {
func (x *PKCEAuthorizationFlow) Reset() {
*x = PKCEAuthorizationFlow{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[33]
+ mi := &file_management_proto_msgTypes[35]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2975,7 +3106,7 @@ func (x *PKCEAuthorizationFlow) String() string {
func (*PKCEAuthorizationFlow) ProtoMessage() {}
func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[33]
+ mi := &file_management_proto_msgTypes[35]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2988,7 +3119,7 @@ func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message {
// Deprecated: Use PKCEAuthorizationFlow.ProtoReflect.Descriptor instead.
func (*PKCEAuthorizationFlow) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{33}
+ return file_management_proto_rawDescGZIP(), []int{35}
}
func (x *PKCEAuthorizationFlow) GetProviderConfig() *ProviderConfig {
@@ -3036,7 +3167,7 @@ type ProviderConfig struct {
func (x *ProviderConfig) Reset() {
*x = ProviderConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[34]
+ mi := &file_management_proto_msgTypes[36]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3049,7 +3180,7 @@ func (x *ProviderConfig) String() string {
func (*ProviderConfig) ProtoMessage() {}
func (x *ProviderConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[34]
+ mi := &file_management_proto_msgTypes[36]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3062,7 +3193,7 @@ func (x *ProviderConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use ProviderConfig.ProtoReflect.Descriptor instead.
func (*ProviderConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{34}
+ return file_management_proto_rawDescGZIP(), []int{36}
}
func (x *ProviderConfig) GetClientID() string {
@@ -3171,7 +3302,7 @@ type Route struct {
func (x *Route) Reset() {
*x = Route{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[35]
+ mi := &file_management_proto_msgTypes[37]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3184,7 +3315,7 @@ func (x *Route) String() string {
func (*Route) ProtoMessage() {}
func (x *Route) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[35]
+ mi := &file_management_proto_msgTypes[37]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3197,7 +3328,7 @@ func (x *Route) ProtoReflect() protoreflect.Message {
// Deprecated: Use Route.ProtoReflect.Descriptor instead.
func (*Route) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{35}
+ return file_management_proto_rawDescGZIP(), []int{37}
}
func (x *Route) GetID() string {
@@ -3286,7 +3417,7 @@ type DNSConfig struct {
func (x *DNSConfig) Reset() {
*x = DNSConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[36]
+ mi := &file_management_proto_msgTypes[38]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3299,7 +3430,7 @@ func (x *DNSConfig) String() string {
func (*DNSConfig) ProtoMessage() {}
func (x *DNSConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[36]
+ mi := &file_management_proto_msgTypes[38]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3312,7 +3443,7 @@ func (x *DNSConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use DNSConfig.ProtoReflect.Descriptor instead.
func (*DNSConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{36}
+ return file_management_proto_rawDescGZIP(), []int{38}
}
func (x *DNSConfig) GetServiceEnable() bool {
@@ -3361,7 +3492,7 @@ type CustomZone struct {
func (x *CustomZone) Reset() {
*x = CustomZone{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[37]
+ mi := &file_management_proto_msgTypes[39]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3374,7 +3505,7 @@ func (x *CustomZone) String() string {
func (*CustomZone) ProtoMessage() {}
func (x *CustomZone) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[37]
+ mi := &file_management_proto_msgTypes[39]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3387,7 +3518,7 @@ func (x *CustomZone) ProtoReflect() protoreflect.Message {
// Deprecated: Use CustomZone.ProtoReflect.Descriptor instead.
func (*CustomZone) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{37}
+ return file_management_proto_rawDescGZIP(), []int{39}
}
func (x *CustomZone) GetDomain() string {
@@ -3434,7 +3565,7 @@ type SimpleRecord struct {
func (x *SimpleRecord) Reset() {
*x = SimpleRecord{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[38]
+ mi := &file_management_proto_msgTypes[40]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3447,7 +3578,7 @@ func (x *SimpleRecord) String() string {
func (*SimpleRecord) ProtoMessage() {}
func (x *SimpleRecord) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[38]
+ mi := &file_management_proto_msgTypes[40]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3460,7 +3591,7 @@ func (x *SimpleRecord) ProtoReflect() protoreflect.Message {
// Deprecated: Use SimpleRecord.ProtoReflect.Descriptor instead.
func (*SimpleRecord) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{38}
+ return file_management_proto_rawDescGZIP(), []int{40}
}
func (x *SimpleRecord) GetName() string {
@@ -3513,7 +3644,7 @@ type NameServerGroup struct {
func (x *NameServerGroup) Reset() {
*x = NameServerGroup{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[39]
+ mi := &file_management_proto_msgTypes[41]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3526,7 +3657,7 @@ func (x *NameServerGroup) String() string {
func (*NameServerGroup) ProtoMessage() {}
func (x *NameServerGroup) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[39]
+ mi := &file_management_proto_msgTypes[41]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3539,7 +3670,7 @@ func (x *NameServerGroup) ProtoReflect() protoreflect.Message {
// Deprecated: Use NameServerGroup.ProtoReflect.Descriptor instead.
func (*NameServerGroup) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{39}
+ return file_management_proto_rawDescGZIP(), []int{41}
}
func (x *NameServerGroup) GetNameServers() []*NameServer {
@@ -3584,7 +3715,7 @@ type NameServer struct {
func (x *NameServer) Reset() {
*x = NameServer{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[40]
+ mi := &file_management_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3597,7 +3728,7 @@ func (x *NameServer) String() string {
func (*NameServer) ProtoMessage() {}
func (x *NameServer) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[40]
+ mi := &file_management_proto_msgTypes[42]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3610,7 +3741,7 @@ func (x *NameServer) ProtoReflect() protoreflect.Message {
// Deprecated: Use NameServer.ProtoReflect.Descriptor instead.
func (*NameServer) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{40}
+ return file_management_proto_rawDescGZIP(), []int{42}
}
func (x *NameServer) GetIP() string {
@@ -3661,7 +3792,7 @@ type FirewallRule struct {
func (x *FirewallRule) Reset() {
*x = FirewallRule{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[41]
+ mi := &file_management_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3674,7 +3805,7 @@ func (x *FirewallRule) String() string {
func (*FirewallRule) ProtoMessage() {}
func (x *FirewallRule) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[41]
+ mi := &file_management_proto_msgTypes[43]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3687,7 +3818,7 @@ func (x *FirewallRule) ProtoReflect() protoreflect.Message {
// Deprecated: Use FirewallRule.ProtoReflect.Descriptor instead.
func (*FirewallRule) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{41}
+ return file_management_proto_rawDescGZIP(), []int{43}
}
// Deprecated: Do not use.
@@ -3766,7 +3897,7 @@ type NetworkAddress struct {
func (x *NetworkAddress) Reset() {
*x = NetworkAddress{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[42]
+ mi := &file_management_proto_msgTypes[44]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3779,7 +3910,7 @@ func (x *NetworkAddress) String() string {
func (*NetworkAddress) ProtoMessage() {}
func (x *NetworkAddress) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[42]
+ mi := &file_management_proto_msgTypes[44]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3792,7 +3923,7 @@ func (x *NetworkAddress) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetworkAddress.ProtoReflect.Descriptor instead.
func (*NetworkAddress) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{42}
+ return file_management_proto_rawDescGZIP(), []int{44}
}
func (x *NetworkAddress) GetNetIP() string {
@@ -3820,7 +3951,7 @@ type Checks struct {
func (x *Checks) Reset() {
*x = Checks{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[43]
+ mi := &file_management_proto_msgTypes[45]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3833,7 +3964,7 @@ func (x *Checks) String() string {
func (*Checks) ProtoMessage() {}
func (x *Checks) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[43]
+ mi := &file_management_proto_msgTypes[45]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3846,7 +3977,7 @@ func (x *Checks) ProtoReflect() protoreflect.Message {
// Deprecated: Use Checks.ProtoReflect.Descriptor instead.
func (*Checks) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{43}
+ return file_management_proto_rawDescGZIP(), []int{45}
}
func (x *Checks) GetFiles() []string {
@@ -3871,7 +4002,7 @@ type PortInfo struct {
func (x *PortInfo) Reset() {
*x = PortInfo{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[44]
+ mi := &file_management_proto_msgTypes[46]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3884,7 +4015,7 @@ func (x *PortInfo) String() string {
func (*PortInfo) ProtoMessage() {}
func (x *PortInfo) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[44]
+ mi := &file_management_proto_msgTypes[46]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3897,7 +4028,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use PortInfo.ProtoReflect.Descriptor instead.
func (*PortInfo) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{44}
+ return file_management_proto_rawDescGZIP(), []int{46}
}
func (m *PortInfo) GetPortSelection() isPortInfo_PortSelection {
@@ -3968,7 +4099,7 @@ type RouteFirewallRule struct {
func (x *RouteFirewallRule) Reset() {
*x = RouteFirewallRule{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[45]
+ mi := &file_management_proto_msgTypes[47]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3981,7 +4112,7 @@ func (x *RouteFirewallRule) String() string {
func (*RouteFirewallRule) ProtoMessage() {}
func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[45]
+ mi := &file_management_proto_msgTypes[47]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3994,7 +4125,7 @@ func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message {
// Deprecated: Use RouteFirewallRule.ProtoReflect.Descriptor instead.
func (*RouteFirewallRule) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{45}
+ return file_management_proto_rawDescGZIP(), []int{47}
}
func (x *RouteFirewallRule) GetSourceRanges() []string {
@@ -4085,7 +4216,7 @@ type ForwardingRule struct {
func (x *ForwardingRule) Reset() {
*x = ForwardingRule{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[46]
+ mi := &file_management_proto_msgTypes[48]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4098,7 +4229,7 @@ func (x *ForwardingRule) String() string {
func (*ForwardingRule) ProtoMessage() {}
func (x *ForwardingRule) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[46]
+ mi := &file_management_proto_msgTypes[48]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4111,7 +4242,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message {
// Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead.
func (*ForwardingRule) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{46}
+ return file_management_proto_rawDescGZIP(), []int{48}
}
func (x *ForwardingRule) GetProtocol() RuleProtocol {
@@ -4160,7 +4291,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[47]
+ mi := &file_management_proto_msgTypes[49]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4173,7 +4304,7 @@ func (x *ExposeServiceRequest) String() string {
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[47]
+ mi := &file_management_proto_msgTypes[49]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4186,7 +4317,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{47}
+ return file_management_proto_rawDescGZIP(), []int{49}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -4259,7 +4390,7 @@ type ExposeServiceResponse struct {
func (x *ExposeServiceResponse) Reset() {
*x = ExposeServiceResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[48]
+ mi := &file_management_proto_msgTypes[50]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4272,7 +4403,7 @@ func (x *ExposeServiceResponse) String() string {
func (*ExposeServiceResponse) ProtoMessage() {}
func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[48]
+ mi := &file_management_proto_msgTypes[50]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4285,7 +4416,7 @@ func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceResponse.ProtoReflect.Descriptor instead.
func (*ExposeServiceResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{48}
+ return file_management_proto_rawDescGZIP(), []int{50}
}
func (x *ExposeServiceResponse) GetServiceName() string {
@@ -4327,7 +4458,7 @@ type RenewExposeRequest struct {
func (x *RenewExposeRequest) Reset() {
*x = RenewExposeRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[49]
+ mi := &file_management_proto_msgTypes[51]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4340,7 +4471,7 @@ func (x *RenewExposeRequest) String() string {
func (*RenewExposeRequest) ProtoMessage() {}
func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[49]
+ mi := &file_management_proto_msgTypes[51]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4353,7 +4484,7 @@ func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenewExposeRequest.ProtoReflect.Descriptor instead.
func (*RenewExposeRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{49}
+ return file_management_proto_rawDescGZIP(), []int{51}
}
func (x *RenewExposeRequest) GetDomain() string {
@@ -4372,7 +4503,7 @@ type RenewExposeResponse struct {
func (x *RenewExposeResponse) Reset() {
*x = RenewExposeResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[50]
+ mi := &file_management_proto_msgTypes[52]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4385,7 +4516,7 @@ func (x *RenewExposeResponse) String() string {
func (*RenewExposeResponse) ProtoMessage() {}
func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[50]
+ mi := &file_management_proto_msgTypes[52]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4398,7 +4529,7 @@ func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenewExposeResponse.ProtoReflect.Descriptor instead.
func (*RenewExposeResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{50}
+ return file_management_proto_rawDescGZIP(), []int{52}
}
type StopExposeRequest struct {
@@ -4412,7 +4543,7 @@ type StopExposeRequest struct {
func (x *StopExposeRequest) Reset() {
*x = StopExposeRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[51]
+ mi := &file_management_proto_msgTypes[53]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4425,7 +4556,7 @@ func (x *StopExposeRequest) String() string {
func (*StopExposeRequest) ProtoMessage() {}
func (x *StopExposeRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[51]
+ mi := &file_management_proto_msgTypes[53]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4438,7 +4569,7 @@ func (x *StopExposeRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopExposeRequest.ProtoReflect.Descriptor instead.
func (*StopExposeRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{51}
+ return file_management_proto_rawDescGZIP(), []int{53}
}
func (x *StopExposeRequest) GetDomain() string {
@@ -4457,7 +4588,7 @@ type StopExposeResponse struct {
func (x *StopExposeResponse) Reset() {
*x = StopExposeResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[52]
+ mi := &file_management_proto_msgTypes[54]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4470,7 +4601,7 @@ func (x *StopExposeResponse) String() string {
func (*StopExposeResponse) ProtoMessage() {}
func (x *StopExposeResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[52]
+ mi := &file_management_proto_msgTypes[54]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4483,7 +4614,7 @@ func (x *StopExposeResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopExposeResponse.ProtoReflect.Descriptor instead.
func (*StopExposeResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{52}
+ return file_management_proto_rawDescGZIP(), []int{54}
}
type PortInfo_Range struct {
@@ -4498,7 +4629,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[54]
+ mi := &file_management_proto_msgTypes[56]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4511,7 +4642,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[54]
+ mi := &file_management_proto_msgTypes[56]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4524,7 +4655,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{44, 0}
+ return file_management_proto_rawDescGZIP(), []int{46, 0}
}
func (x *PortInfo_Range) GetStart() uint32 {
@@ -4590,7 +4721,7 @@ var file_management_proto_rawDesc = []byte{
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,
+ 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xa3, 0x03, 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,
@@ -4612,630 +4743,657 @@ var file_management_proto_rawDesc = []byte{
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, 0xe1, 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, 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, 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, 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,
- 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, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61,
- 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64,
- 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x22, 0xb2, 0x05, 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, 0x12,
- 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18,
- 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
- 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 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, 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,
+ 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
+ 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x07, 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, 0x10, 0x73, 0x65, 0x73,
+ 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 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, 0xe1,
+ 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, 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, 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, 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, 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,
+ 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18,
+ 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50,
+ 0x76, 0x36, 0x22, 0xb2, 0x05, 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, 0x12, 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62,
+ 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43,
+ 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62,
+ 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0xfc, 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, 0x12, 0x46,
+ 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73,
+ 0x41, 0x74, 0x18, 0x04, 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, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70,
+ 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x66, 0x0a, 0x18, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64,
+ 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 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, 0x22, 0x63,
+ 0x0a, 0x19, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73,
+ 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x10, 0x73,
+ 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
+ 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
+ 0x70, 0x52, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65,
+ 0x73, 0x41, 0x74, 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, 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, 0xf2, 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, 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, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18,
- 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36,
- 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,
+ 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, 0xf2, 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, 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, 0x12, 0x1d,
+ 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, 0x09, 0x20, 0x01,
+ 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, 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, 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,
- 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, 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,
+ 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, 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, 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, 0xbc, 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, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72,
- 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 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, 0xfb, 0x02, 0x0a, 0x0c, 0x46,
- 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50,
- 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 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, 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, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78,
- 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 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,
+ 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, 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, 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,
+ 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, 0xbc, 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,
+ 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 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, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65,
+ 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72,
+ 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 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, 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, 0x26, 0x0a,
+ 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18,
+ 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65,
+ 0x66, 0x69, 0x78, 0x65, 0x73, 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, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e,
- 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45,
- 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75,
- 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
- 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f,
- 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f,
- 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12,
- 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69,
- 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a,
- 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03,
- 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16,
- 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
- 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70,
- 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d,
- 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65,
- 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69,
- 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70,
- 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
- 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61,
- 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63,
- 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
- 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76,
- 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c,
- 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69,
- 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74,
- 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12,
- 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65,
- 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52,
- 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
- 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14,
- 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70,
- 0x6f, 0x6e, 0x73, 0x65, 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, 0x6c, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69,
- 0x74, 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69,
- 0x6c, 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a,
- 0x1c, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53,
- 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12,
- 0x1d, 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
- 0x79, 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 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, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74,
- 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48,
- 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f,
- 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53,
- 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53,
- 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53,
- 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xfd, 0x06, 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,
+ 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, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f,
+ 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+ 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04,
+ 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
+ 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03,
+ 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a,
+ 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73,
+ 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52,
+ 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64,
+ 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d,
+ 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66,
+ 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72,
+ 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70,
+ 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65,
+ 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
+ 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
+ 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61,
+ 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72,
+ 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+ 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70,
+ 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65,
+ 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74,
+ 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e,
+ 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
+ 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77,
+ 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b,
+ 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75,
+ 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53,
+ 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+ 0x65, 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, 0x6c, 0x0a,
+ 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x12,
+ 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
+ 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, 0x50, 0x65,
+ 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f, 0x75, 0x72,
+ 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d, 0x0a, 0x19,
+ 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x49, 0x50,
+ 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 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, 0x2a,
+ 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
+ 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50,
+ 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54,
+ 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54,
+ 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55,
+ 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54,
+ 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 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, 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,
+ 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, 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,
+ 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, 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,
+ 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, 0x12, 0x51, 0x0a,
+ 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69,
+ 0x6f, 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, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
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,
@@ -5267,7 +5425,7 @@ func file_management_proto_rawDescGZIP() []byte {
}
var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8)
-var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 55)
+var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 57)
var file_management_proto_goTypes = []interface{}{
(JobStatus)(0), // 0: management.JobStatus
(PeerCapability)(0), // 1: management.PeerCapability
@@ -5292,142 +5450,150 @@ var file_management_proto_goTypes = []interface{}{
(*Flags)(nil), // 20: management.Flags
(*PeerSystemMeta)(nil), // 21: management.PeerSystemMeta
(*LoginResponse)(nil), // 22: management.LoginResponse
- (*ServerKeyResponse)(nil), // 23: management.ServerKeyResponse
- (*Empty)(nil), // 24: management.Empty
- (*NetbirdConfig)(nil), // 25: management.NetbirdConfig
- (*HostConfig)(nil), // 26: management.HostConfig
- (*RelayConfig)(nil), // 27: management.RelayConfig
- (*FlowConfig)(nil), // 28: management.FlowConfig
- (*JWTConfig)(nil), // 29: management.JWTConfig
- (*ProtectedHostConfig)(nil), // 30: management.ProtectedHostConfig
- (*PeerConfig)(nil), // 31: management.PeerConfig
- (*AutoUpdateSettings)(nil), // 32: management.AutoUpdateSettings
- (*NetworkMap)(nil), // 33: management.NetworkMap
- (*SSHAuth)(nil), // 34: management.SSHAuth
- (*MachineUserIndexes)(nil), // 35: management.MachineUserIndexes
- (*RemotePeerConfig)(nil), // 36: management.RemotePeerConfig
- (*SSHConfig)(nil), // 37: management.SSHConfig
- (*DeviceAuthorizationFlowRequest)(nil), // 38: management.DeviceAuthorizationFlowRequest
- (*DeviceAuthorizationFlow)(nil), // 39: management.DeviceAuthorizationFlow
- (*PKCEAuthorizationFlowRequest)(nil), // 40: management.PKCEAuthorizationFlowRequest
- (*PKCEAuthorizationFlow)(nil), // 41: management.PKCEAuthorizationFlow
- (*ProviderConfig)(nil), // 42: management.ProviderConfig
- (*Route)(nil), // 43: management.Route
- (*DNSConfig)(nil), // 44: management.DNSConfig
- (*CustomZone)(nil), // 45: management.CustomZone
- (*SimpleRecord)(nil), // 46: management.SimpleRecord
- (*NameServerGroup)(nil), // 47: management.NameServerGroup
- (*NameServer)(nil), // 48: management.NameServer
- (*FirewallRule)(nil), // 49: management.FirewallRule
- (*NetworkAddress)(nil), // 50: management.NetworkAddress
- (*Checks)(nil), // 51: management.Checks
- (*PortInfo)(nil), // 52: management.PortInfo
- (*RouteFirewallRule)(nil), // 53: management.RouteFirewallRule
- (*ForwardingRule)(nil), // 54: management.ForwardingRule
- (*ExposeServiceRequest)(nil), // 55: management.ExposeServiceRequest
- (*ExposeServiceResponse)(nil), // 56: management.ExposeServiceResponse
- (*RenewExposeRequest)(nil), // 57: management.RenewExposeRequest
- (*RenewExposeResponse)(nil), // 58: management.RenewExposeResponse
- (*StopExposeRequest)(nil), // 59: management.StopExposeRequest
- (*StopExposeResponse)(nil), // 60: management.StopExposeResponse
- nil, // 61: management.SSHAuth.MachineUsersEntry
- (*PortInfo_Range)(nil), // 62: management.PortInfo.Range
- (*timestamppb.Timestamp)(nil), // 63: google.protobuf.Timestamp
- (*durationpb.Duration)(nil), // 64: google.protobuf.Duration
+ (*ExtendAuthSessionRequest)(nil), // 23: management.ExtendAuthSessionRequest
+ (*ExtendAuthSessionResponse)(nil), // 24: management.ExtendAuthSessionResponse
+ (*ServerKeyResponse)(nil), // 25: management.ServerKeyResponse
+ (*Empty)(nil), // 26: management.Empty
+ (*NetbirdConfig)(nil), // 27: management.NetbirdConfig
+ (*HostConfig)(nil), // 28: management.HostConfig
+ (*RelayConfig)(nil), // 29: management.RelayConfig
+ (*FlowConfig)(nil), // 30: management.FlowConfig
+ (*JWTConfig)(nil), // 31: management.JWTConfig
+ (*ProtectedHostConfig)(nil), // 32: management.ProtectedHostConfig
+ (*PeerConfig)(nil), // 33: management.PeerConfig
+ (*AutoUpdateSettings)(nil), // 34: management.AutoUpdateSettings
+ (*NetworkMap)(nil), // 35: management.NetworkMap
+ (*SSHAuth)(nil), // 36: management.SSHAuth
+ (*MachineUserIndexes)(nil), // 37: management.MachineUserIndexes
+ (*RemotePeerConfig)(nil), // 38: management.RemotePeerConfig
+ (*SSHConfig)(nil), // 39: management.SSHConfig
+ (*DeviceAuthorizationFlowRequest)(nil), // 40: management.DeviceAuthorizationFlowRequest
+ (*DeviceAuthorizationFlow)(nil), // 41: management.DeviceAuthorizationFlow
+ (*PKCEAuthorizationFlowRequest)(nil), // 42: management.PKCEAuthorizationFlowRequest
+ (*PKCEAuthorizationFlow)(nil), // 43: management.PKCEAuthorizationFlow
+ (*ProviderConfig)(nil), // 44: management.ProviderConfig
+ (*Route)(nil), // 45: management.Route
+ (*DNSConfig)(nil), // 46: management.DNSConfig
+ (*CustomZone)(nil), // 47: management.CustomZone
+ (*SimpleRecord)(nil), // 48: management.SimpleRecord
+ (*NameServerGroup)(nil), // 49: management.NameServerGroup
+ (*NameServer)(nil), // 50: management.NameServer
+ (*FirewallRule)(nil), // 51: management.FirewallRule
+ (*NetworkAddress)(nil), // 52: management.NetworkAddress
+ (*Checks)(nil), // 53: management.Checks
+ (*PortInfo)(nil), // 54: management.PortInfo
+ (*RouteFirewallRule)(nil), // 55: management.RouteFirewallRule
+ (*ForwardingRule)(nil), // 56: management.ForwardingRule
+ (*ExposeServiceRequest)(nil), // 57: management.ExposeServiceRequest
+ (*ExposeServiceResponse)(nil), // 58: management.ExposeServiceResponse
+ (*RenewExposeRequest)(nil), // 59: management.RenewExposeRequest
+ (*RenewExposeResponse)(nil), // 60: management.RenewExposeResponse
+ (*StopExposeRequest)(nil), // 61: management.StopExposeRequest
+ (*StopExposeResponse)(nil), // 62: management.StopExposeResponse
+ nil, // 63: management.SSHAuth.MachineUsersEntry
+ (*PortInfo_Range)(nil), // 64: management.PortInfo.Range
+ (*timestamppb.Timestamp)(nil), // 65: google.protobuf.Timestamp
+ (*durationpb.Duration)(nil), // 66: google.protobuf.Duration
}
var file_management_proto_depIdxs = []int32{
11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters
0, // 1: management.JobResponse.status:type_name -> management.JobStatus
12, // 2: management.JobResponse.bundle:type_name -> management.BundleResult
21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta
- 25, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig
- 31, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig
- 36, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
- 33, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
- 51, // 8: management.SyncResponse.Checks:type_name -> management.Checks
- 21, // 9: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta
- 21, // 10: management.LoginRequest.meta:type_name -> management.PeerSystemMeta
- 17, // 11: management.LoginRequest.peerKeys:type_name -> management.PeerKeys
- 50, // 12: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress
- 18, // 13: management.PeerSystemMeta.environment:type_name -> management.Environment
- 19, // 14: management.PeerSystemMeta.files:type_name -> management.File
- 20, // 15: management.PeerSystemMeta.flags:type_name -> management.Flags
- 1, // 16: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability
- 25, // 17: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig
- 31, // 18: management.LoginResponse.peerConfig:type_name -> management.PeerConfig
- 51, // 19: management.LoginResponse.Checks:type_name -> management.Checks
- 63, // 20: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
- 26, // 21: management.NetbirdConfig.stuns:type_name -> management.HostConfig
- 30, // 22: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig
- 26, // 23: management.NetbirdConfig.signal:type_name -> management.HostConfig
- 27, // 24: management.NetbirdConfig.relay:type_name -> management.RelayConfig
- 28, // 25: management.NetbirdConfig.flow:type_name -> management.FlowConfig
- 6, // 26: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol
- 64, // 27: management.FlowConfig.interval:type_name -> google.protobuf.Duration
- 26, // 28: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
- 37, // 29: management.PeerConfig.sshConfig:type_name -> management.SSHConfig
- 32, // 30: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings
- 31, // 31: management.NetworkMap.peerConfig:type_name -> management.PeerConfig
- 36, // 32: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
- 43, // 33: management.NetworkMap.Routes:type_name -> management.Route
- 44, // 34: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
- 36, // 35: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
- 49, // 36: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
- 53, // 37: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
- 54, // 38: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
- 34, // 39: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
- 61, // 40: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
- 37, // 41: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
- 29, // 42: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
- 7, // 43: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
- 42, // 44: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
- 42, // 45: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
- 47, // 46: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
- 45, // 47: management.DNSConfig.CustomZones:type_name -> management.CustomZone
- 46, // 48: management.CustomZone.Records:type_name -> management.SimpleRecord
- 48, // 49: management.NameServerGroup.NameServers:type_name -> management.NameServer
- 3, // 50: management.FirewallRule.Direction:type_name -> management.RuleDirection
- 4, // 51: management.FirewallRule.Action:type_name -> management.RuleAction
- 2, // 52: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
- 52, // 53: management.FirewallRule.PortInfo:type_name -> management.PortInfo
- 62, // 54: management.PortInfo.range:type_name -> management.PortInfo.Range
- 4, // 55: management.RouteFirewallRule.action:type_name -> management.RuleAction
- 2, // 56: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
- 52, // 57: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
- 2, // 58: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
- 52, // 59: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
- 52, // 60: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
- 5, // 61: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol
- 35, // 62: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
- 8, // 63: management.ManagementService.Login:input_type -> management.EncryptedMessage
- 8, // 64: management.ManagementService.Sync:input_type -> management.EncryptedMessage
- 24, // 65: management.ManagementService.GetServerKey:input_type -> management.Empty
- 24, // 66: management.ManagementService.isHealthy:input_type -> management.Empty
- 8, // 67: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
- 8, // 68: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
- 8, // 69: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
- 8, // 70: management.ManagementService.Logout:input_type -> management.EncryptedMessage
- 8, // 71: management.ManagementService.Job:input_type -> management.EncryptedMessage
- 8, // 72: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
- 8, // 73: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
- 8, // 74: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
- 8, // 75: management.ManagementService.Login:output_type -> management.EncryptedMessage
- 8, // 76: management.ManagementService.Sync:output_type -> management.EncryptedMessage
- 23, // 77: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
- 24, // 78: management.ManagementService.isHealthy:output_type -> management.Empty
- 8, // 79: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
- 8, // 80: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
- 24, // 81: management.ManagementService.SyncMeta:output_type -> management.Empty
- 24, // 82: management.ManagementService.Logout:output_type -> management.Empty
- 8, // 83: management.ManagementService.Job:output_type -> management.EncryptedMessage
- 8, // 84: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
- 8, // 85: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
- 8, // 86: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
- 75, // [75:87] is the sub-list for method output_type
- 63, // [63:75] is the sub-list for method input_type
- 63, // [63:63] is the sub-list for extension type_name
- 63, // [63:63] is the sub-list for extension extendee
- 0, // [0:63] is the sub-list for field type_name
+ 27, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig
+ 33, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig
+ 38, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
+ 35, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
+ 53, // 8: management.SyncResponse.Checks:type_name -> management.Checks
+ 65, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 21, // 10: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta
+ 21, // 11: management.LoginRequest.meta:type_name -> management.PeerSystemMeta
+ 17, // 12: management.LoginRequest.peerKeys:type_name -> management.PeerKeys
+ 52, // 13: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress
+ 18, // 14: management.PeerSystemMeta.environment:type_name -> management.Environment
+ 19, // 15: management.PeerSystemMeta.files:type_name -> management.File
+ 20, // 16: management.PeerSystemMeta.flags:type_name -> management.Flags
+ 1, // 17: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability
+ 27, // 18: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig
+ 33, // 19: management.LoginResponse.peerConfig:type_name -> management.PeerConfig
+ 53, // 20: management.LoginResponse.Checks:type_name -> management.Checks
+ 65, // 21: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 21, // 22: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta
+ 65, // 23: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 65, // 24: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
+ 28, // 25: management.NetbirdConfig.stuns:type_name -> management.HostConfig
+ 32, // 26: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig
+ 28, // 27: management.NetbirdConfig.signal:type_name -> management.HostConfig
+ 29, // 28: management.NetbirdConfig.relay:type_name -> management.RelayConfig
+ 30, // 29: management.NetbirdConfig.flow:type_name -> management.FlowConfig
+ 6, // 30: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol
+ 66, // 31: management.FlowConfig.interval:type_name -> google.protobuf.Duration
+ 28, // 32: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
+ 39, // 33: management.PeerConfig.sshConfig:type_name -> management.SSHConfig
+ 34, // 34: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings
+ 33, // 35: management.NetworkMap.peerConfig:type_name -> management.PeerConfig
+ 38, // 36: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
+ 45, // 37: management.NetworkMap.Routes:type_name -> management.Route
+ 46, // 38: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
+ 38, // 39: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
+ 51, // 40: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
+ 55, // 41: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
+ 56, // 42: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
+ 36, // 43: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
+ 63, // 44: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
+ 39, // 45: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
+ 31, // 46: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
+ 7, // 47: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
+ 44, // 48: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+ 44, // 49: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+ 49, // 50: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
+ 47, // 51: management.DNSConfig.CustomZones:type_name -> management.CustomZone
+ 48, // 52: management.CustomZone.Records:type_name -> management.SimpleRecord
+ 50, // 53: management.NameServerGroup.NameServers:type_name -> management.NameServer
+ 3, // 54: management.FirewallRule.Direction:type_name -> management.RuleDirection
+ 4, // 55: management.FirewallRule.Action:type_name -> management.RuleAction
+ 2, // 56: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
+ 54, // 57: management.FirewallRule.PortInfo:type_name -> management.PortInfo
+ 64, // 58: management.PortInfo.range:type_name -> management.PortInfo.Range
+ 4, // 59: management.RouteFirewallRule.action:type_name -> management.RuleAction
+ 2, // 60: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
+ 54, // 61: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
+ 2, // 62: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
+ 54, // 63: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
+ 54, // 64: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
+ 5, // 65: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol
+ 37, // 66: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
+ 8, // 67: management.ManagementService.Login:input_type -> management.EncryptedMessage
+ 8, // 68: management.ManagementService.Sync:input_type -> management.EncryptedMessage
+ 26, // 69: management.ManagementService.GetServerKey:input_type -> management.Empty
+ 26, // 70: management.ManagementService.isHealthy:input_type -> management.Empty
+ 8, // 71: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
+ 8, // 72: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
+ 8, // 73: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
+ 8, // 74: management.ManagementService.Logout:input_type -> management.EncryptedMessage
+ 8, // 75: management.ManagementService.Job:input_type -> management.EncryptedMessage
+ 8, // 76: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
+ 8, // 77: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
+ 8, // 78: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
+ 8, // 79: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
+ 8, // 80: management.ManagementService.Login:output_type -> management.EncryptedMessage
+ 8, // 81: management.ManagementService.Sync:output_type -> management.EncryptedMessage
+ 25, // 82: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
+ 26, // 83: management.ManagementService.isHealthy:output_type -> management.Empty
+ 8, // 84: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
+ 8, // 85: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
+ 26, // 86: management.ManagementService.SyncMeta:output_type -> management.Empty
+ 26, // 87: management.ManagementService.Logout:output_type -> management.Empty
+ 8, // 88: management.ManagementService.Job:output_type -> management.EncryptedMessage
+ 8, // 89: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
+ 8, // 90: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
+ 8, // 91: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
+ 8, // 92: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
+ 80, // [80:93] is the sub-list for method output_type
+ 67, // [67:80] is the sub-list for method input_type
+ 67, // [67:67] is the sub-list for extension type_name
+ 67, // [67:67] is the sub-list for extension extendee
+ 0, // [0:67] is the sub-list for field type_name
}
func init() { file_management_proto_init() }
@@ -5617,7 +5783,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ServerKeyResponse); i {
+ switch v := v.(*ExtendAuthSessionRequest); i {
case 0:
return &v.state
case 1:
@@ -5629,7 +5795,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Empty); i {
+ switch v := v.(*ExtendAuthSessionResponse); i {
case 0:
return &v.state
case 1:
@@ -5641,7 +5807,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NetbirdConfig); i {
+ switch v := v.(*ServerKeyResponse); i {
case 0:
return &v.state
case 1:
@@ -5653,7 +5819,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*HostConfig); i {
+ switch v := v.(*Empty); i {
case 0:
return &v.state
case 1:
@@ -5665,7 +5831,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RelayConfig); i {
+ switch v := v.(*NetbirdConfig); i {
case 0:
return &v.state
case 1:
@@ -5677,7 +5843,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*FlowConfig); i {
+ switch v := v.(*HostConfig); i {
case 0:
return &v.state
case 1:
@@ -5689,7 +5855,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*JWTConfig); i {
+ switch v := v.(*RelayConfig); i {
case 0:
return &v.state
case 1:
@@ -5701,7 +5867,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ProtectedHostConfig); i {
+ switch v := v.(*FlowConfig); i {
case 0:
return &v.state
case 1:
@@ -5713,7 +5879,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PeerConfig); i {
+ switch v := v.(*JWTConfig); i {
case 0:
return &v.state
case 1:
@@ -5725,7 +5891,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*AutoUpdateSettings); i {
+ switch v := v.(*ProtectedHostConfig); i {
case 0:
return &v.state
case 1:
@@ -5737,7 +5903,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NetworkMap); i {
+ switch v := v.(*PeerConfig); i {
case 0:
return &v.state
case 1:
@@ -5749,7 +5915,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SSHAuth); i {
+ switch v := v.(*AutoUpdateSettings); i {
case 0:
return &v.state
case 1:
@@ -5761,7 +5927,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*MachineUserIndexes); i {
+ switch v := v.(*NetworkMap); i {
case 0:
return &v.state
case 1:
@@ -5773,7 +5939,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RemotePeerConfig); i {
+ switch v := v.(*SSHAuth); i {
case 0:
return &v.state
case 1:
@@ -5785,7 +5951,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SSHConfig); i {
+ switch v := v.(*MachineUserIndexes); i {
case 0:
return &v.state
case 1:
@@ -5797,7 +5963,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*DeviceAuthorizationFlowRequest); i {
+ switch v := v.(*RemotePeerConfig); i {
case 0:
return &v.state
case 1:
@@ -5809,7 +5975,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*DeviceAuthorizationFlow); i {
+ switch v := v.(*SSHConfig); i {
case 0:
return &v.state
case 1:
@@ -5821,7 +5987,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PKCEAuthorizationFlowRequest); i {
+ switch v := v.(*DeviceAuthorizationFlowRequest); i {
case 0:
return &v.state
case 1:
@@ -5833,7 +5999,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PKCEAuthorizationFlow); i {
+ switch v := v.(*DeviceAuthorizationFlow); i {
case 0:
return &v.state
case 1:
@@ -5845,7 +6011,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ProviderConfig); i {
+ switch v := v.(*PKCEAuthorizationFlowRequest); i {
case 0:
return &v.state
case 1:
@@ -5857,7 +6023,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Route); i {
+ switch v := v.(*PKCEAuthorizationFlow); i {
case 0:
return &v.state
case 1:
@@ -5869,7 +6035,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*DNSConfig); i {
+ switch v := v.(*ProviderConfig); i {
case 0:
return &v.state
case 1:
@@ -5881,7 +6047,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*CustomZone); i {
+ switch v := v.(*Route); i {
case 0:
return &v.state
case 1:
@@ -5893,7 +6059,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SimpleRecord); i {
+ switch v := v.(*DNSConfig); i {
case 0:
return &v.state
case 1:
@@ -5905,7 +6071,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NameServerGroup); i {
+ switch v := v.(*CustomZone); i {
case 0:
return &v.state
case 1:
@@ -5917,7 +6083,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NameServer); i {
+ switch v := v.(*SimpleRecord); i {
case 0:
return &v.state
case 1:
@@ -5929,7 +6095,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*FirewallRule); i {
+ switch v := v.(*NameServerGroup); i {
case 0:
return &v.state
case 1:
@@ -5941,7 +6107,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NetworkAddress); i {
+ switch v := v.(*NameServer); i {
case 0:
return &v.state
case 1:
@@ -5953,7 +6119,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Checks); i {
+ switch v := v.(*FirewallRule); i {
case 0:
return &v.state
case 1:
@@ -5965,7 +6131,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PortInfo); i {
+ switch v := v.(*NetworkAddress); i {
case 0:
return &v.state
case 1:
@@ -5977,7 +6143,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RouteFirewallRule); i {
+ switch v := v.(*Checks); i {
case 0:
return &v.state
case 1:
@@ -5989,7 +6155,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ForwardingRule); i {
+ switch v := v.(*PortInfo); i {
case 0:
return &v.state
case 1:
@@ -6001,7 +6167,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[47].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ExposeServiceRequest); i {
+ switch v := v.(*RouteFirewallRule); i {
case 0:
return &v.state
case 1:
@@ -6013,7 +6179,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[48].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ExposeServiceResponse); i {
+ switch v := v.(*ForwardingRule); i {
case 0:
return &v.state
case 1:
@@ -6025,7 +6191,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[49].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RenewExposeRequest); i {
+ switch v := v.(*ExposeServiceRequest); i {
case 0:
return &v.state
case 1:
@@ -6037,7 +6203,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[50].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RenewExposeResponse); i {
+ switch v := v.(*ExposeServiceResponse); i {
case 0:
return &v.state
case 1:
@@ -6049,7 +6215,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[51].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*StopExposeRequest); i {
+ switch v := v.(*RenewExposeRequest); i {
case 0:
return &v.state
case 1:
@@ -6061,7 +6227,19 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[52].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*StopExposeResponse); i {
+ switch v := v.(*RenewExposeResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_management_proto_msgTypes[53].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*StopExposeRequest); i {
case 0:
return &v.state
case 1:
@@ -6073,6 +6251,18 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[54].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*StopExposeResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_management_proto_msgTypes[56].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*PortInfo_Range); i {
case 0:
return &v.state
@@ -6091,7 +6281,7 @@ func file_management_proto_init() {
file_management_proto_msgTypes[2].OneofWrappers = []interface{}{
(*JobResponse_Bundle)(nil),
}
- file_management_proto_msgTypes[44].OneofWrappers = []interface{}{
+ file_management_proto_msgTypes[46].OneofWrappers = []interface{}{
(*PortInfo_Port)(nil),
(*PortInfo_Range_)(nil),
}
@@ -6101,7 +6291,7 @@ func file_management_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_management_proto_rawDesc,
NumEnums: 8,
- NumMessages: 55,
+ NumMessages: 57,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto
index 461a614fe..990a72a63 100644
--- a/shared/management/proto/management.proto
+++ b/shared/management/proto/management.proto
@@ -52,6 +52,14 @@ service ManagementService {
// Executes a job on a target peer (e.g., debug bundle)
rpc Job(stream EncryptedMessage) returns (stream EncryptedMessage) {}
+ // ExtendAuthSession refreshes the peer's session expiry deadline using a fresh JWT.
+ // Same JWT validation pipeline as Login (including jwt.UserID == peer.UserID check),
+ // but does not redo the network-map sync. Only valid for SSO-registered peers where
+ // login expiration is enabled. The tunnel remains up.
+ // EncryptedMessage of the request has a body of ExtendAuthSessionRequest.
+ // EncryptedMessage of the response has a body of ExtendAuthSessionResponse.
+ rpc ExtendAuthSession(EncryptedMessage) returns (EncryptedMessage) {}
+
// CreateExpose creates a temporary reverse proxy service for a peer
rpc CreateExpose(EncryptedMessage) returns (EncryptedMessage) {}
@@ -133,6 +141,15 @@ message SyncResponse {
// Posture checks to be evaluated by client
repeated Checks Checks = 6;
+
+ // 3-state session deadline. Carried on every Sync snapshot so admin-side
+ // changes propagate live without a client reconnect.
+ // field unset (nil) โ snapshot carries no info; client keeps the
+ // deadline it already had
+ // set, seconds=0 nanos=0 โ explicit "expiry disabled" or peer is not
+ // SSO-registered; client clears its anchor
+ // set, valid timestamp โ new absolute UTC deadline
+ google.protobuf.Timestamp sessionExpiresAt = 7;
}
message SyncMetaRequest {
@@ -244,6 +261,31 @@ message LoginResponse {
PeerConfig peerConfig = 2;
// Posture checks to be evaluated by client
repeated Checks Checks = 3;
+
+ // 3-state session deadline; same encoding as SyncResponse.sessionExpiresAt.
+ // field unset (nil) โ no info; client keeps any deadline it had
+ // set, seconds=0 nanos=0 โ explicit "expiry disabled" / non-SSO peer
+ // set, valid timestamp โ new absolute UTC deadline
+ google.protobuf.Timestamp sessionExpiresAt = 4;
+}
+
+// ExtendAuthSessionRequest carries a fresh JWT to refresh the peer's session deadline.
+// The encrypted body of an EncryptedMessage with this payload is sent to the
+// ExtendAuthSession RPC.
+message ExtendAuthSessionRequest {
+ // SSO token (must be a fresh, valid JWT for the peer's owning user)
+ string jwtToken = 1;
+ // Meta data of the peer (used for IdP user info refresh consistent with Login)
+ PeerSystemMeta meta = 2;
+}
+
+// ExtendAuthSessionResponse contains the refreshed session deadline.
+message ExtendAuthSessionResponse {
+ // 3-state session deadline; same encoding as SyncResponse.sessionExpiresAt.
+ // In practice ExtendAuthSession only succeeds for SSO peers with expiry
+ // enabled, so this carries a valid timestamp on the success path. The
+ // 3-state encoding is documented here for symmetry with Login/Sync.
+ google.protobuf.Timestamp sessionExpiresAt = 1;
}
message ServerKeyResponse {
diff --git a/shared/management/proto/management_grpc.pb.go b/shared/management/proto/management_grpc.pb.go
index 39a342041..ce98e4019 100644
--- a/shared/management/proto/management_grpc.pb.go
+++ b/shared/management/proto/management_grpc.pb.go
@@ -52,6 +52,13 @@ type ManagementServiceClient interface {
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)
+ // ExtendAuthSession refreshes the peer's session expiry deadline using a fresh JWT.
+ // Same JWT validation pipeline as Login (including jwt.UserID == peer.UserID check),
+ // but does not redo the network-map sync. Only valid for SSO-registered peers where
+ // login expiration is enabled. The tunnel remains up.
+ // EncryptedMessage of the request has a body of ExtendAuthSessionRequest.
+ // EncryptedMessage of the response has a body of ExtendAuthSessionResponse.
+ ExtendAuthSession(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error)
// CreateExpose creates a temporary reverse proxy service for a peer
CreateExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error)
// RenewExpose extends the TTL of an active expose session
@@ -194,6 +201,15 @@ func (x *managementServiceJobClient) Recv() (*EncryptedMessage, error) {
return m, nil
}
+func (c *managementServiceClient) ExtendAuthSession(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) {
+ out := new(EncryptedMessage)
+ err := c.cc.Invoke(ctx, "/management.ManagementService/ExtendAuthSession", in, out, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
func (c *managementServiceClient) CreateExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) {
out := new(EncryptedMessage)
err := c.cc.Invoke(ctx, "/management.ManagementService/CreateExpose", in, out, opts...)
@@ -259,6 +275,13 @@ type ManagementServiceServer interface {
Logout(context.Context, *EncryptedMessage) (*Empty, error)
// Executes a job on a target peer (e.g., debug bundle)
Job(ManagementService_JobServer) error
+ // ExtendAuthSession refreshes the peer's session expiry deadline using a fresh JWT.
+ // Same JWT validation pipeline as Login (including jwt.UserID == peer.UserID check),
+ // but does not redo the network-map sync. Only valid for SSO-registered peers where
+ // login expiration is enabled. The tunnel remains up.
+ // EncryptedMessage of the request has a body of ExtendAuthSessionRequest.
+ // EncryptedMessage of the response has a body of ExtendAuthSessionResponse.
+ ExtendAuthSession(context.Context, *EncryptedMessage) (*EncryptedMessage, error)
// CreateExpose creates a temporary reverse proxy service for a peer
CreateExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error)
// RenewExpose extends the TTL of an active expose session
@@ -299,6 +322,9 @@ func (UnimplementedManagementServiceServer) Logout(context.Context, *EncryptedMe
func (UnimplementedManagementServiceServer) Job(ManagementService_JobServer) error {
return status.Errorf(codes.Unimplemented, "method Job not implemented")
}
+func (UnimplementedManagementServiceServer) ExtendAuthSession(context.Context, *EncryptedMessage) (*EncryptedMessage, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method ExtendAuthSession not implemented")
+}
func (UnimplementedManagementServiceServer) CreateExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateExpose not implemented")
}
@@ -494,6 +520,24 @@ func (x *managementServiceJobServer) Recv() (*EncryptedMessage, error) {
return m, nil
}
+func _ManagementService_ExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(EncryptedMessage)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(ManagementServiceServer).ExtendAuthSession(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: "/management.ManagementService/ExtendAuthSession",
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(ManagementServiceServer).ExtendAuthSession(ctx, req.(*EncryptedMessage))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
func _ManagementService_CreateExpose_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EncryptedMessage)
if err := dec(in); err != nil {
@@ -583,6 +627,10 @@ var ManagementService_ServiceDesc = grpc.ServiceDesc{
MethodName: "Logout",
Handler: _ManagementService_Logout_Handler,
},
+ {
+ MethodName: "ExtendAuthSession",
+ Handler: _ManagementService_ExtendAuthSession_Handler,
+ },
{
MethodName: "CreateExpose",
Handler: _ManagementService_CreateExpose_Handler,
From 77e56932002e6c12647713f649590567f955e2fe Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Fri, 29 May 2026 22:14:32 +0900
Subject: [PATCH 19/44] [client] Recognize NetBird DNS forwarder port in
capture text format (#6177)
---
util/capture/text.go | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/util/capture/text.go b/util/capture/text.go
index a6a6dd28b..32229894e 100644
--- a/util/capture/text.go
+++ b/util/capture/text.go
@@ -13,6 +13,8 @@ import (
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/miekg/dns"
+
+ nbdns "github.com/netbirdio/netbird/dns"
)
// TextWriter writes human-readable one-line-per-packet summaries.
@@ -150,7 +152,7 @@ func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo,
plen := len(udp.Payload)
// DNS replaces the entire line format
- if plen > 0 && isDNSPort(info.srcPort, info.dstPort) {
+ if plen > 0 && (isDNSPort(info.srcPort) || isDNSPort(info.dstPort)) {
if s := formatDNSPayload(udp.Payload); s != "" {
var verbose string
if tw.verbose {
@@ -561,8 +563,12 @@ func findSNIExtension(body []byte, pos int) string {
return ""
}
-func isDNSPort(src, dst uint16) bool {
- return src == 53 || dst == 53 || src == 5353 || dst == 5353
+func isDNSPort(p uint16) bool {
+ switch p {
+ case nbdns.DefaultDNSPort, nbdns.ForwarderClientPort, nbdns.ForwarderServerPort:
+ return true
+ }
+ return false
}
// formatDNSPayload parses DNS and returns a tcpdump-style summary.
From 43e041cf9f26a5a6ec78cc3dacbe1bd256c27ea5 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Fri, 29 May 2026 22:15:22 +0900
Subject: [PATCH 20/44] [client] Apply netroute unspecified-destination
workaround on android (#6192)
---
client/internal/portforward/pcp/nat.go | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go
index 6491e7367..0e635b6c8 100644
--- a/client/internal/portforward/pcp/nat.go
+++ b/client/internal/portforward/pcp/nat.go
@@ -179,8 +179,10 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
}
dst := net.IPv4zero
- if runtime.GOOS == "linux" {
- // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux.
+ if runtime.GOOS == "linux" || runtime.GOOS == "android" {
+ // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
+ // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
+ // NWPathMonitor) when netlink-based lookup is restricted or unavailable.
dst = net.IPv4(0, 0, 0, 1)
}
_, gateway, localIP, err = router.Route(dst)
@@ -203,7 +205,7 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
}
dst := net.IPv6zero
- if runtime.GOOS == "linux" {
+ if runtime.GOOS == "linux" || runtime.GOOS == "android" {
// ::2
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
}
From 5a9e9e7bc99717ebc393737fdf067c6e189896f6 Mon Sep 17 00:00:00 2001
From: Theodor Midtlien
Date: Fri, 29 May 2026 15:24:30 +0200
Subject: [PATCH 21/44] [Infrastructure] Pin actions with SHA and improve
workflows (#6249)
* Pin actions with SHA, replace unmaintained, add dependabot for actions
* Update FreeBSD to version 15 for tests
* Use shared actions
* Update sign-pipelines version
---
.github/dependabot.yml | 45 +++++
.../workflows/check-license-dependencies.yml | 109 ++++++------
.github/workflows/docs-ack.yml | 2 +-
.github/workflows/forum.yml | 5 +-
.github/workflows/git-town.yml | 8 +-
.github/workflows/golang-test-darwin.yml | 9 +-
.github/workflows/golang-test-freebsd.yml | 21 ++-
.github/workflows/golang-test-linux.yml | 138 ++++++++------
.github/workflows/golang-test-windows.yml | 19 +-
.github/workflows/golangci-lint.yml | 14 +-
.github/workflows/install-script-test.yml | 4 +-
.github/workflows/mobile-build-validation.yml | 18 +-
.github/workflows/pr-title-check.yml | 2 +-
.github/workflows/proto-version-check.yml | 2 +-
.github/workflows/release.yml | 168 +++++++++---------
.github/workflows/sync-main.yml | 4 +-
.github/workflows/sync-tag.yml | 10 +-
.../workflows/test-infrastructure-files.yml | 26 +--
.github/workflows/update-docs.yml | 8 +-
.github/workflows/wasm-build-validation.yml | 15 +-
client/internal/auth/pkce_flow.go | 8 +-
21 files changed, 375 insertions(+), 260 deletions(-)
create mode 100644 .github/dependabot.yml
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..b78b1417a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,45 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 15
+ groups:
+ actions:
+ patterns:
+ - "*"
+ ignore:
+ # git-town/action v1.3.x crashes on cyclic PR graphs (self-loop main->main
+ # fork PRs) via its topological-sort visualization. Pinned to v1.2.1 in
+ # git-town.yml; block v1.3.x until upstream tolerates cyclic edges.
+ - dependency-name: "git-town/action"
+ update-types:
+ - "version-update:semver-minor"
+ - "version-update:semver-major"
+
+ - package-ecosystem: "gomod"
+ directories:
+ - "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 15
+ groups:
+ aws-sdk:
+ patterns:
+ - "github.com/aws/aws-sdk-go-v2/*"
+ pion:
+ patterns:
+ - "github.com/pion/*"
+ gorm:
+ patterns:
+ - "gorm.io/*"
+ otel:
+ patterns:
+ - "go.opentelemetry.io/*"
+ testcontainers:
+ patterns:
+ - "github.com/testcontainers/testcontainers-go/*"
+ wireguard:
+ patterns:
+ - "golang.zx2c4.com/wireguard*"
diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml
index a721cb516..8acd645e2 100644
--- a/.github/workflows/check-license-dependencies.yml
+++ b/.github/workflows/check-license-dependencies.yml
@@ -2,16 +2,16 @@ name: Check License Dependencies
on:
push:
- branches: [ main ]
+ branches: [main]
paths:
- - 'go.mod'
- - 'go.sum'
- - '.github/workflows/check-license-dependencies.yml'
+ - "go.mod"
+ - "go.sum"
+ - ".github/workflows/check-license-dependencies.yml"
pull_request:
paths:
- - 'go.mod'
- - 'go.sum'
- - '.github/workflows/check-license-dependencies.yml'
+ - "go.mod"
+ - "go.sum"
+ - ".github/workflows/check-license-dependencies.yml"
jobs:
check-internal-dependencies:
@@ -19,7 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Check for problematic license dependencies
run: |
@@ -56,55 +59,57 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: 'go.mod'
- cache: true
+ - name: Set up Go
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: "go.mod"
+ cache: true
- - name: Install go-licenses
- run: go install github.com/google/go-licenses@v1.6.0
+ - name: Install go-licenses
+ run: go install github.com/google/go-licenses@v1.6.0
- - name: Check for GPL/AGPL licensed dependencies
- run: |
- echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
- echo ""
-
- # Check all Go packages for copyleft licenses, excluding internal netbird packages
- COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
-
- if [ -n "$COPYLEFT_DEPS" ]; then
- echo "Found copyleft licensed dependencies:"
- echo "$COPYLEFT_DEPS"
+ - name: Check for GPL/AGPL licensed dependencies
+ run: |
+ echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
echo ""
- # Filter out dependencies that are only pulled in by internal AGPL packages
- INCOMPATIBLE=""
- while IFS=',' read -r package url license; do
- if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
- # Find ALL packages that import this GPL package using go list
- IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
+ # Check all Go packages for copyleft licenses, excluding internal netbird packages
+ COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
- # Check if any importer is NOT in management/signal/relay
- BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
-
- if [ -n "$BSD_IMPORTER" ]; then
- echo "โ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
- INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
- else
- echo "โ $package ($license) is only used by internal AGPL packages - OK"
- fi
- fi
- done <<< "$COPYLEFT_DEPS"
-
- if [ -n "$INCOMPATIBLE" ]; then
+ if [ -n "$COPYLEFT_DEPS" ]; then
+ echo "Found copyleft licensed dependencies:"
+ echo "$COPYLEFT_DEPS"
echo ""
- echo "โ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
- echo -e "$INCOMPATIBLE"
- exit 1
- fi
- fi
- echo "โ
All external license dependencies are compatible with BSD-3-Clause"
+ # Filter out dependencies that are only pulled in by internal AGPL packages
+ INCOMPATIBLE=""
+ while IFS=',' read -r package url license; do
+ if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
+ # Find ALL packages that import this GPL package using go list
+ IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
+
+ # Check if any importer is NOT in management/signal/relay
+ BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
+
+ if [ -n "$BSD_IMPORTER" ]; then
+ echo "โ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
+ INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
+ else
+ echo "โ $package ($license) is only used by internal AGPL packages - OK"
+ fi
+ fi
+ done <<< "$COPYLEFT_DEPS"
+
+ if [ -n "$INCOMPATIBLE" ]; then
+ echo ""
+ echo "โ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
+ echo -e "$INCOMPATIBLE"
+ exit 1
+ fi
+ fi
+
+ echo "โ
All external license dependencies are compatible with BSD-3-Clause"
diff --git a/.github/workflows/docs-ack.yml b/.github/workflows/docs-ack.yml
index f11142a36..7e34e2f8a 100644
--- a/.github/workflows/docs-ack.yml
+++ b/.github/workflows/docs-ack.yml
@@ -83,7 +83,7 @@ jobs:
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
id: verify
with:
pr_number: ${{ steps.extract.outputs.pr_number }}
diff --git a/.github/workflows/forum.yml b/.github/workflows/forum.yml
index a26a72586..75543ef8b 100644
--- a/.github/workflows/forum.yml
+++ b/.github/workflows/forum.yml
@@ -8,11 +8,10 @@ jobs:
post:
runs-on: ubuntu-latest
steps:
- - uses: roots/discourse-topic-github-release-action@main
+ - uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
with:
discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
discourse-base-url: https://forum.netbird.io
discourse-author-username: NetBird
discourse-category: 17
- discourse-tags:
- releases
+ discourse-tags: releases
diff --git a/.github/workflows/git-town.yml b/.github/workflows/git-town.yml
index 699ed7d93..3f145020f 100644
--- a/.github/workflows/git-town.yml
+++ b/.github/workflows/git-town.yml
@@ -3,7 +3,7 @@ name: Git Town
on:
pull_request:
branches:
- - '**'
+ - "**"
jobs:
git-town:
@@ -15,7 +15,9 @@ jobs:
pull-requests: write
steps:
- - uses: actions/checkout@v4
- - uses: git-town/action@v1.2.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
with:
skip-single-stacks: true
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index 0528ed086..200e888ba 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -16,16 +16,18 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: macos-gotest-${{ hashFiles('**/go.sum') }}
@@ -44,4 +46,3 @@ jobs:
- name: Test
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
-
diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml
index 2c029b117..9a81d3e4c 100644
--- a/.github/workflows/golang-test-freebsd.yml
+++ b/.github/workflows/golang-test-freebsd.yml
@@ -15,20 +15,31 @@ jobs:
name: "Client / Unit"
runs-on: ubuntu-22.04
steps:
- - uses: actions/checkout@v4
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Read Go version from go.mod
+ id: goversion
+ run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
+
- name: Test in FreeBSD
id: test
- uses: vmactions/freebsd-vm@v1
+ env:
+ GO_VERSION: ${{ steps.goversion.outputs.version }}
+ uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
with:
usesh: true
copyback: false
- release: "14.2"
+ release: "15.0"
+ envs: "GO_VERSION"
prepare: |
pkg install -y curl pkgconf xorg
- GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz"
+ GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
GO_URL="https://go.dev/dl/$GO_TARBALL"
curl -vLO "$GO_URL"
- tar -C /usr/local -vxzf "$GO_TARBALL"
+ tar -C /usr/local -vxzf "$GO_TARBALL"
# -x - to print all executed commands
# -e - to faile on first error
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index 450c44aea..fc4187b8f 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -18,9 +18,11 @@ jobs:
management: ${{ steps.filter.outputs.management }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- - uses: dorny/paths-filter@v3
+ - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
@@ -28,7 +30,7 @@ jobs:
- 'management/**'
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -36,10 +38,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache
with:
path: |
@@ -113,14 +115,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -128,10 +132,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -158,14 +162,16 @@ jobs:
test_client_on_docker:
name: "Client (Docker) / Unit"
- needs: [ build-cache ]
+ needs: [build-cache]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -177,7 +183,7 @@ jobs:
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-restore
with:
path: |
@@ -231,10 +237,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -246,10 +254,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -277,14 +285,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -298,7 +308,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -324,14 +334,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -343,10 +355,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -370,19 +382,21 @@ jobs:
test_management:
name: "Management / Unit"
- needs: [ build-cache ]
+ needs: [build-cache]
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres', 'mysql' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres", "mysql"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -390,10 +404,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -410,7 +424,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -427,7 +441,7 @@ jobs:
run: docker pull mlsmaycon/warmed-mysql:8
- name: Test
- run: |
+ run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
@@ -437,13 +451,13 @@ jobs:
benchmark:
name: "Management / Benchmark"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Create Docker network
@@ -474,10 +488,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -485,10 +501,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -505,7 +521,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -529,13 +545,13 @@ jobs:
api_benchmark:
name: "Management / Benchmark (API)"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Create Docker network
@@ -566,10 +582,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -577,10 +595,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -597,7 +615,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -623,20 +641,22 @@ jobs:
api_integration_test:
name: "Management / Integration"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres']
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -644,10 +664,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index 8e672043d..8712cc879 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -18,10 +18,12 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
id: go
with:
go-version-file: "go.mod"
@@ -33,7 +35,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -44,16 +46,15 @@ jobs:
${{ runner.os }}-go-
- name: Download wintun
- uses: carlosperate/download-file-action@v2
id: download-wintun
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
- file-name: wintun.zip
- location: ${{ env.downloadPath }}
- sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
+ url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
+ destination: ${{ env.downloadPath }}\wintun.zip
+ sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
- name: Decompressing wintun files
- run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
+ run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 7b7b32ec0..8f6d1ddb0 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -15,9 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: codespell
- uses: codespell-project/actions-codespell@v2
+ uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
with:
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
skip: go.mod,go.sum,**/proxy/web/**
@@ -38,13 +40,15 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Check for duplicate constants
if: matrix.os == 'ubuntu-latest'
run: |
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -52,7 +56,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
- name: golangci-lint
- uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
+ uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
with:
version: latest
skip-cache: true
diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml
index 22d002a48..aec9f6300 100644
--- a/.github/workflows/install-script-test.yml
+++ b/.github/workflows/install-script-test.yml
@@ -22,7 +22,9 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: run install script
env:
diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml
index 8325fbf2d..8e0538104 100644
--- a/.github/workflows/mobile-build-validation.yml
+++ b/.github/workflows/mobile-build-validation.yml
@@ -16,23 +16,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Setup Android SDK
- uses: android-actions/setup-android@v3
+ uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
with:
cmdline-tools-version: 8512546
- name: Setup Java
- uses: actions/setup-java@v4
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
with:
java-version: "11"
distribution: "adopt"
- name: NDK Cache
id: ndk-cache
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: /usr/local/lib/android/sdk/ndk
key: ndk-cache-23.1.7779620
@@ -52,9 +54,11 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: install gomobile
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
index a2e6ce219..67d65356c 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Validate PR title prefix
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = context.payload.pull_request.title;
diff --git a/.github/workflows/proto-version-check.yml b/.github/workflows/proto-version-check.yml
index bec503b36..04793b404 100644
--- a/.github/workflows/proto-version-check.yml
+++ b/.github/workflows/proto-version-check.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check for proto tool version changes
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c1ae01a98..cae6aa873 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,7 @@ on:
pull_request:
env:
- SIGN_PIPE_VER: "v0.1.4"
+ SIGN_PIPE_VER: "v0.1.5"
GORELEASER_VER: "v2.14.3"
PRODUCT_NAME: "NetBird"
COPYRIGHT: "NetBird GmbH"
@@ -24,7 +24,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Generate FreeBSD port diff
run: bash release_files/freebsd-port-diff.sh
@@ -51,19 +53,26 @@ jobs:
echo "Generated files for version: $VERSION"
cat netbird-*.diff
+ - name: Read Go version from go.mod
+ id: goversion
+ run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
+
- name: Test FreeBSD port
if: steps.check_diff.outputs.diff_exists == 'true'
- uses: vmactions/freebsd-vm@v1
+ env:
+ GO_VERSION: ${{ steps.goversion.outputs.version }}
+ uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
with:
usesh: true
copyback: false
release: "15.0"
+ envs: "GO_VERSION"
prepare: |
# Install required packages
- pkg install -y git curl portlint go
+ pkg install -y git curl portlint
# Install Go for building
- GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz"
+ GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
GO_URL="https://go.dev/dl/$GO_TARBALL"
curl -LO "$GO_URL"
tar -C /usr/local -xzf "$GO_TARBALL"
@@ -93,19 +102,19 @@ jobs:
# Show patched Makefile
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
-
+
cd /usr/ports/security/netbird
export BATCH=yes
make package
pkg add ./work/pkg/netbird-*.pkg
-
+
netbird version | grep "$version"
echo "FreeBSD port test completed successfully!"
- name: Upload FreeBSD port files
if: steps.check_diff.outputs.diff_exists == 'true'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: freebsd-port-files
path: |
@@ -124,26 +133,25 @@ jobs:
env:
flags: ""
steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0 # It is required for GoReleaser to work properly
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0 # It is required for GoReleaser to work properly
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/go/pkg/mod
@@ -156,18 +164,18 @@ jobs:
- name: check git status
run: git --no-pager diff --exit-code
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
- name: Login to Docker hub
if: github.event_name != 'pull_request'
- uses: docker/login-action@v1
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to the GitHub container registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -191,7 +199,7 @@ jobs:
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
- name: Run GoReleaser
id: goreleaser
- uses: goreleaser/goreleaser-action@v4
+ uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
with:
version: ${{ env.GORELEASER_VER }}
args: release --clean ${{ env.flags }}
@@ -282,28 +290,28 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: upload non tags for debug purposes
id: upload_release
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release
path: dist/
retention-days: 7
- name: upload linux packages
id: upload_linux_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: linux-packages
path: dist/netbird_linux**
retention-days: 7
- name: upload windows packages
id: upload_windows_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: windows-packages
path: dist/netbird_windows**
retention-days: 7
- name: upload macos packages
id: upload_macos_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: macos-packages
path: dist/netbird_darwin**
@@ -314,27 +322,26 @@ jobs:
outputs:
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0 # It is required for GoReleaser to work properly
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0 # It is required for GoReleaser to work properly
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/go/pkg/mod
@@ -375,7 +382,7 @@ jobs:
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v4
+ uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
@@ -404,7 +411,7 @@ jobs:
run: rm -f /tmp/gpg-rpm-signing-key.asc
- name: upload non tags for debug purposes
id: upload_release_ui
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui
path: dist/
@@ -418,16 +425,17 @@ jobs:
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
+ persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/go/pkg/mod
@@ -441,7 +449,7 @@ jobs:
run: git --no-pager diff --exit-code
- name: Run GoReleaser
id: goreleaser
- uses: goreleaser/goreleaser-action@v4
+ uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
@@ -449,7 +457,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: upload non tags for debug purposes
id: upload_release_ui_darwin
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui-darwin
path: dist/
@@ -474,27 +482,26 @@ jobs:
PackageWorkdir: netbird_windows_${{ matrix.arch }}
downloadPath: '${{ github.workspace }}\temp'
steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
-
- - name: Checkout
- uses: actions/checkout@v4
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- name: Add 7-Zip to PATH
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Download release artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
with:
name: release
path: release
- name: Download UI release artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
with:
name: release-ui
path: release-ui
@@ -514,29 +521,27 @@ jobs:
Get-ChildItem $workdir
- name: Download wintun
- uses: carlosperate/download-file-action@v2
id: download-wintun
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
- file-name: wintun.zip
- location: ${{ env.downloadPath }}
- sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
+ url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
+ destination: ${{ env.downloadPath }}\wintun.zip
+ sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
- name: Decompress wintun files
- run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
+ run: tar -xvf "${{ env.downloadPath }}\wintun.zip" -C ${{ env.downloadPath }}
- name: Move wintun.dll into dist
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
- name: Download Mesa3D (amd64 only)
- uses: carlosperate/download-file-action@v2
id: download-mesa3d
if: matrix.arch == 'amd64'
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z
- file-name: mesa3d.7z
- location: ${{ env.downloadPath }}
- sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9'
+ url: https://pkgs.netbird.io/mesa3d/MesaForWindows-x64-20.1.8.7z
+ destination: ${{ env.downloadPath }}\mesa3d.7z
+ sha256: 71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9
- name: Extract Mesa3D driver (amd64 only)
if: matrix.arch == 'amd64'
@@ -547,35 +552,38 @@ jobs:
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
- name: Download EnVar plugin for NSIS
- uses: carlosperate/download-file-action@v2
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip
- file-name: envar_plugin.zip
- location: ${{ github.workspace }}
+ url: https://pkgs.netbird.io/nsis/EnVar_plugin.zip
+ destination: ${{ github.workspace }}\envar_plugin.zip
+ sha256: e9aa92de351345ed82795251d838f1ae9041ba35af9d381a5780c7843b01f56a
- name: Extract EnVar plugin
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
- uses: carlosperate/download-file-action@v2
if: matrix.arch == 'amd64'
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z
- file-name: ShellExecAsUser_amd64-Unicode.7z
- location: ${{ github.workspace }}
+ url: https://pkgs.netbird.io/nsis/ShellExecAsUser_amd64-Unicode.7z
+ destination: ${{ github.workspace }}\ShellExecAsUser_amd64-Unicode.7z
+ sha256: 0a55ea25c7330a92cec028eda8afcaf1b1a7092e0dfb77c21c8f654564b4ff9d
- name: Extract ShellExecAsUser plugin (amd64 only)
if: matrix.arch == 'amd64'
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
- name: Build NSIS installer
- uses: joncloud/makensis-action@v3.3
- with:
- additional-plugin-paths: ${{ github.workspace }}/NSIS_Plugins/Plugins
- script-file: client/installer.nsis
- arguments: "/V4 /DARCH=${{ matrix.arch }}"
+ shell: pwsh
env:
APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }}
+ run: |
+ $nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
+ $srcPlugins = "${{ github.workspace }}\NSIS_Plugins\Plugins"
+ Get-ChildItem -Path $srcPlugins -Recurse -Filter *.dll |
+ Copy-Item -Destination $nsisPluginDir -Force
+ & "C:\Program Files (x86)\NSIS\makensis.exe" /V4 "/DARCH=${{ matrix.arch }}" client\installer.nsis
+ if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" }
- name: Rename NSIS installer
run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe
@@ -592,7 +600,7 @@ jobs:
- name: Upload installer artifacts
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: windows-installer-test-${{ matrix.arch }}
path: |
@@ -611,7 +619,7 @@ jobs:
pull-requests: write
steps:
- name: Create or update PR comment
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
RELEASE_RESULT: ${{ needs.release.result }}
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
@@ -703,7 +711,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger binaries sign pipelines
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: Sign bin and installer
repo: netbirdio/sign-pipelines
diff --git a/.github/workflows/sync-main.yml b/.github/workflows/sync-main.yml
index e36e35a2d..5805fcf57 100644
--- a/.github/workflows/sync-main.yml
+++ b/.github/workflows/sync-main.yml
@@ -14,9 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger main branch sync
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: sync-main.yml
repo: ${{ secrets.UPSTREAM_REPO }}
token: ${{ secrets.NC_GITHUB_TOKEN }}
- inputs: '{ "sha": "${{ github.sha }}" }'
\ No newline at end of file
+ inputs: '{ "sha": "${{ github.sha }}" }'
diff --git a/.github/workflows/sync-tag.yml b/.github/workflows/sync-tag.yml
index a75d9a9d5..d99f88b54 100644
--- a/.github/workflows/sync-tag.yml
+++ b/.github/workflows/sync-tag.yml
@@ -3,7 +3,7 @@ name: sync tag
on:
push:
tags:
- - 'v*'
+ - "v*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger release tag sync
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: sync-tag.yml
ref: main
@@ -29,7 +29,7 @@ jobs:
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger android-client submodule bump
- uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
@@ -42,10 +42,10 @@ jobs:
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger ios-client submodule bump
- uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
repo: netbirdio/ios-client
token: ${{ secrets.NC_GITHUB_TOKEN }}
- inputs: '{ "tag": "${{ github.ref_name }}" }'
\ No newline at end of file
+ inputs: '{ "tag": "${{ github.ref_name }}" }'
diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml
index e2f950731..9ad1f2f67 100644
--- a/.github/workflows/test-infrastructure-files.yml
+++ b/.github/workflows/test-infrastructure-files.yml
@@ -6,10 +6,10 @@ on:
- main
pull_request:
paths:
- - 'infrastructure_files/**'
- - '.github/workflows/test-infrastructure-files.yml'
- - 'management/cmd/**'
- - 'signal/cmd/**'
+ - "infrastructure_files/**"
+ - ".github/workflows/test-infrastructure-files.yml"
+ - "management/cmd/**"
+ - "signal/cmd/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- store: [ 'sqlite', 'postgres', 'mysql' ]
+ store: ["sqlite", "postgres", "mysql"]
services:
postgres:
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
@@ -68,15 +68,17 @@ jobs:
run: sudo apt-get install -y curl
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
@@ -139,8 +141,8 @@ jobs:
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
CI_NETBIRD_SIGNAL_PORT: 12345
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
- NETBIRD_STORE_ENGINE_POSTGRES_DSN: '${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$'
- NETBIRD_STORE_ENGINE_MYSQL_DSN: '${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$'
+ NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
+ NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
@@ -254,7 +256,9 @@ jobs:
run: sudo apt-get install -y jq
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: run script with Zitadel PostgreSQL
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml
index 26f3b8f02..ff4f0a86a 100644
--- a/.github/workflows/update-docs.yml
+++ b/.github/workflows/update-docs.yml
@@ -3,9 +3,9 @@ name: update docs
on:
push:
tags:
- - 'v*'
+ - "v*"
paths:
- - 'shared/management/http/api/openapi.yml'
+ - "shared/management/http/api/openapi.yml"
jobs:
trigger_docs_api_update:
@@ -13,10 +13,10 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger API pages generation
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: generate api pages
repo: netbirdio/docs
ref: "refs/heads/main"
token: ${{ secrets.SIGN_GITHUB_TOKEN }}
- inputs: '{ "tag": "${{ github.ref }}" }'
\ No newline at end of file
+ inputs: '{ "tag": "${{ github.ref }}" }'
diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index 81ae36e78..dd39d979d 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -19,15 +19,17 @@ jobs:
GOARCH: wasm
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
- name: Install golangci-lint
- uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
+ uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
with:
version: latest
install-mode: binary
@@ -42,9 +44,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Build Wasm client
@@ -65,4 +69,3 @@ jobs:
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
exit 1
fi
-
diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go
index 2e16836d8..84fa8a214 100644
--- a/client/internal/auth/pkce_flow.go
+++ b/client/internal/auth/pkce_flow.go
@@ -360,7 +360,13 @@ func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRang
return true
}
- addr := fmt.Sprintf(":%s", port)
+ // FreeBSD 15 disables connecting to INADDR_ANY (0.0.0.0) as a localhost
+ // alias by default, ensure explicit ip for localhost.
+ host := parsedURL.Hostname()
+ if host == "" {
+ host = "127.0.0.1"
+ }
+ addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return false
From e9dbf9db6f43dc3799634ad6bca2beae3cc4c88d Mon Sep 17 00:00:00 2001
From: Bethuel Mmbaga
Date: Fri, 29 May 2026 17:35:35 +0300
Subject: [PATCH 22/44] [management] Extend combined server initialization
(#6156)
---
combined/cmd/root.go | 55 ++++++++++++++-------------
combined/cmd/server.go | 13 +++++++
management/internals/server/server.go | 7 +++-
3 files changed, 48 insertions(+), 27 deletions(-)
create mode 100644 combined/cmd/server.go
diff --git a/combined/cmd/root.go b/combined/cmd/root.go
index 78290388b..31e0580fb 100644
--- a/combined/cmd/root.go
+++ b/combined/cmd/root.go
@@ -67,6 +67,10 @@ func init() {
rootCmd.AddCommand(newTokenCommands())
}
+func RootCmd() *cobra.Command {
+ return rootCmd
+}
+
func Execute() error {
return rootCmd.Execute()
}
@@ -168,7 +172,7 @@ func initializeConfig() error {
// serverInstances holds all server instances created during startup.
type serverInstances struct {
relaySrv *relayServer.Server
- mgmtSrv *mgmtServer.BaseServer
+ mgmtSrv mgmtServer.Server
signalSrv *signalServer.Server
healthcheck *healthcheck.Server
stunServer *stun.Server
@@ -324,19 +328,24 @@ func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) {
return
}
- servers.mgmtSrv.AfterInit(func(s *mgmtServer.BaseServer) {
- grpcSrv := s.GRPCServer()
+ if s, ok := servers.mgmtSrv.GetContainer(mgmtServer.ContainerKeyBaseServer); ok {
+ if baseServer, ok := s.(*mgmtServer.BaseServer); ok {
+ baseServer.AfterInit(func(s *mgmtServer.BaseServer) {
+ grpcSrv := s.GRPCServer()
- if servers.signalSrv != nil {
- proto.RegisterSignalExchangeServer(grpcSrv, servers.signalSrv)
- log.Infof("Signal server registered on port %s", cfg.Server.ListenAddress)
- }
+ if servers.signalSrv != nil {
+ proto.RegisterSignalExchangeServer(grpcSrv, servers.signalSrv)
+ log.Infof("Signal server registered on port %s", cfg.Server.ListenAddress)
+ }
- s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), s.IDPHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg))
- if servers.relaySrv != nil {
- log.Infof("Relay WebSocket handler added (path: /relay)")
+ s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), s.IDPHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg))
+ if servers.relaySrv != nil {
+ log.Infof("Relay WebSocket handler added (path: /relay)")
+ }
+ })
}
- })
+ }
+
}
func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, metricsServer *sharedMetrics.Metrics) {
@@ -346,38 +355,32 @@ func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *
log.Infof("Relay WebSocket multiplexed on management port (no separate relay listener)")
}
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
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)
}
- }()
+ })
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
if err := httpHealthcheck.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("failed to start healthcheck server: %v", err)
}
- }()
+ })
if stunServer != nil {
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
if err := stunServer.Listen(); err != nil {
if errors.Is(err, stun.ErrServerClosed) {
return
}
log.Errorf("STUN server error: %v", err)
}
- }()
+ })
}
}
-func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv *mgmtServer.BaseServer, metricsServer *sharedMetrics.Metrics) error {
+func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv mgmtServer.Server, metricsServer *sharedMetrics.Metrics) error {
var errs error
if err := httpHealthcheck.Shutdown(ctx); err != nil {
@@ -491,7 +494,7 @@ func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) {
return nil, false, nil
}
-func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*mgmtServer.BaseServer, error) {
+func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (mgmtServer.Server, error) {
mgmt := cfg.Management
// Extract port from listen address
@@ -502,7 +505,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*
}
mgmtPort, _ := strconv.Atoi(portStr)
- mgmtSrv := mgmtServer.NewServer(
+ mgmtSrv := newServer(
&mgmtServer.Config{
NbConfig: mgmtConfig,
DNSDomain: "",
diff --git a/combined/cmd/server.go b/combined/cmd/server.go
new file mode 100644
index 000000000..f9384dfb1
--- /dev/null
+++ b/combined/cmd/server.go
@@ -0,0 +1,13 @@
+package cmd
+
+import (
+ mgmtServer "github.com/netbirdio/netbird/management/internals/server"
+)
+
+var newServer = func(cfg *mgmtServer.Config) mgmtServer.Server {
+ return mgmtServer.NewServer(cfg)
+}
+
+func SetNewServer(fn func(*mgmtServer.Config) mgmtServer.Server) {
+ newServer = fn
+}
diff --git a/management/internals/server/server.go b/management/internals/server/server.go
index 63d13baab..43ee2126d 100644
--- a/management/internals/server/server.go
+++ b/management/internals/server/server.go
@@ -34,6 +34,8 @@ const (
ManagementLegacyPort = 33073
// DefaultSelfHostedDomain is the default domain used for self-hosted fresh installs.
DefaultSelfHostedDomain = "netbird.selfhosted"
+
+ ContainerKeyBaseServer = "baseServer"
)
type Server interface {
@@ -91,7 +93,7 @@ type Config struct {
// NewServer initializes and configures a new Server instance
func NewServer(cfg *Config) *BaseServer {
- return &BaseServer{
+ s := &BaseServer{
Config: cfg.NbConfig,
container: make(map[string]any),
dnsDomain: cfg.DNSDomain,
@@ -104,6 +106,9 @@ func NewServer(cfg *Config) *BaseServer {
mgmtMetricsPort: cfg.MgmtMetricsPort,
autoResolveDomains: cfg.AutoResolveDomains,
}
+ s.container[ContainerKeyBaseServer] = s
+
+ return s
}
func (s *BaseServer) AfterInit(fn func(s *BaseServer)) {
From 918962548712043bb497f08f0291c2c4510be00e Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Fri, 29 May 2026 16:36:38 +0200
Subject: [PATCH 23/44] [management] enrich context in permissions manager
(#6286)
---
management/internals/modules/peers/manager.go | 4 +-
.../accesslogs/manager/manager.go | 2 +-
.../reverseproxy/domain/manager/manager.go | 8 +--
.../reverseproxy/proxytoken/handler.go | 20 +++---
.../reverseproxy/proxytoken/handler_test.go | 16 ++---
.../reverseproxy/service/manager/manager.go | 16 ++---
.../service/manager/manager_test.go | 2 +-
.../modules/zones/manager/manager.go | 10 +--
.../modules/zones/manager/manager_test.go | 38 +++++------
.../modules/zones/records/manager/manager.go | 10 +--
.../zones/records/manager/manager_test.go | 40 ++++++------
management/server/account.go | 21 ++++---
management/server/context/keys.go | 27 ++++++--
management/server/dns.go | 4 +-
management/server/event.go | 2 +-
management/server/group.go | 12 ++--
management/server/groups/manager.go | 4 +-
.../http/handlers/peers/peers_handler.go | 24 +++----
.../http/handlers/peers/peers_handler_test.go | 8 +--
.../policies/geolocation_handler_test.go | 2 +-
management/server/identity_provider.go | 10 +--
management/server/nameserver.go | 10 +--
management/server/networks/manager.go | 10 +--
.../server/networks/resources/manager.go | 14 ++---
management/server/networks/routers/manager.go | 12 ++--
management/server/peer.go | 16 ++---
management/server/permissions/manager.go | 37 ++++++-----
management/server/permissions/manager_mock.go | 14 +++--
management/server/policy.go | 8 +--
management/server/posture_checks.go | 8 +--
management/server/route.go | 10 +--
management/server/settings/manager.go | 2 +-
management/server/setupkey.go | 10 +--
management/server/user.go | 55 +++++++---------
management/server/user_test.go | 63 -------------------
shared/context/keys.go | 1 +
shared/management/client/client_test.go | 4 +-
37 files changed, 255 insertions(+), 299 deletions(-)
diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go
index 75ae8de91..8f3253063 100644
--- a/management/internals/modules/peers/manager.go
+++ b/management/internals/modules/peers/manager.go
@@ -75,7 +75,7 @@ func (m *managerImpl) SetAccountManager(accountManager account.Manager) {
}
func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) {
- allowed, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -88,7 +88,7 @@ func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID str
}
func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) {
- allowed, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go
index 59d7704eb..ced2ec4d1 100644
--- a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go
+++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go
@@ -63,7 +63,7 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac
// GetAllAccessLogs retrieves access logs for an account with pagination and filtering
func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, 0, status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go
index 2a026c7fa..3c0f0d73b 100644
--- a/management/internals/modules/reverseproxy/domain/manager/manager.go
+++ b/management/internals/modules/reverseproxy/domain/manager/manager.go
@@ -57,7 +57,7 @@ func NewManager(store store, proxyMgr proxyManager, permissionsManager permissio
}
func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -122,7 +122,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
}
func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*domain.Domain, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -163,7 +163,7 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
}
func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -187,7 +187,7 @@ func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID s
}
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
+ ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
log.WithFields(log.Fields{
"accountID": accountID,
diff --git a/management/internals/modules/reverseproxy/proxytoken/handler.go b/management/internals/modules/reverseproxy/proxytoken/handler.go
index 728cdf723..ed098a6dd 100644
--- a/management/internals/modules/reverseproxy/proxytoken/handler.go
+++ b/management/internals/modules/reverseproxy/proxytoken/handler.go
@@ -37,7 +37,7 @@ func (h *handler) createToken(w http.ResponseWriter, r *http.Request) {
return
}
- ok, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Create)
+ ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Create)
if err != nil {
util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w)
return
@@ -76,13 +76,13 @@ func (h *handler) createToken(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.store.SaveProxyAccessToken(r.Context(), &generated.ProxyAccessToken); err != nil {
+ if err := h.store.SaveProxyAccessToken(ctx, &generated.ProxyAccessToken); err != nil {
util.WriteErrorResponse("failed to save token", http.StatusInternalServerError, w)
return
}
resp := toProxyTokenCreatedResponse(generated)
- util.WriteJSONObject(r.Context(), w, resp)
+ util.WriteJSONObject(ctx, w, resp)
}
func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
@@ -92,7 +92,7 @@ func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
return
}
- ok, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Read)
+ ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Read)
if err != nil {
util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w)
return
@@ -102,7 +102,7 @@ func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
return
}
- tokens, err := h.store.GetProxyAccessTokensByAccountID(r.Context(), store.LockingStrengthNone, userAuth.AccountId)
+ tokens, err := h.store.GetProxyAccessTokensByAccountID(ctx, store.LockingStrengthNone, userAuth.AccountId)
if err != nil {
util.WriteErrorResponse("failed to list tokens", http.StatusInternalServerError, w)
return
@@ -113,7 +113,7 @@ func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
resp = append(resp, toProxyTokenResponse(token))
}
- util.WriteJSONObject(r.Context(), w, resp)
+ util.WriteJSONObject(ctx, w, resp)
}
func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
@@ -123,7 +123,7 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
- ok, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Delete)
+ ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Delete)
if err != nil {
util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w)
return
@@ -139,7 +139,7 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
- token, err := h.store.GetProxyAccessTokenByID(r.Context(), store.LockingStrengthNone, tokenID)
+ token, err := h.store.GetProxyAccessTokenByID(ctx, store.LockingStrengthNone, tokenID)
if err != nil {
if s, ok := status.FromError(err); ok && s.ErrorType == status.NotFound {
util.WriteErrorResponse("token not found", http.StatusNotFound, w)
@@ -154,12 +154,12 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.store.RevokeProxyAccessToken(r.Context(), tokenID); err != nil {
+ if err := h.store.RevokeProxyAccessToken(ctx, tokenID); err != nil {
util.WriteErrorResponse("failed to revoke token", http.StatusInternalServerError, w)
return
}
- util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
+ util.WriteJSONObject(ctx, w, util.EmptyObject{})
}
func toProxyTokenResponse(token *types.ProxyAccessToken) api.ProxyToken {
diff --git a/management/internals/modules/reverseproxy/proxytoken/handler_test.go b/management/internals/modules/reverseproxy/proxytoken/handler_test.go
index a28752909..a5b5713c6 100644
--- a/management/internals/modules/reverseproxy/proxytoken/handler_test.go
+++ b/management/internals/modules/reverseproxy/proxytoken/handler_test.go
@@ -47,7 +47,7 @@ func TestCreateToken_AccountScoped(t *testing.T) {
)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Create).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -90,7 +90,7 @@ func TestCreateToken_WithExpiration(t *testing.T) {
)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -115,7 +115,7 @@ func TestCreateToken_EmptyName(t *testing.T) {
defer ctrl.Finish()
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil)
h := &handler{
permissionsManager: permsMgr,
@@ -135,7 +135,7 @@ func TestCreateToken_PermissionDenied(t *testing.T) {
defer ctrl.Finish()
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(false, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(false, context.Background(), nil)
h := &handler{
permissionsManager: permsMgr,
@@ -164,7 +164,7 @@ func TestListTokens(t *testing.T) {
}, nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Read).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Read).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -202,7 +202,7 @@ func TestRevokeToken_Success(t *testing.T) {
mockStore.EXPECT().RevokeProxyAccessToken(gomock.Any(), "tok-1").Return(nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -231,7 +231,7 @@ func TestRevokeToken_WrongAccount(t *testing.T) {
}, nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -258,7 +258,7 @@ func TestRevokeToken_ManagementWideToken(t *testing.T) {
}, nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go
index f0ac68ed0..c8ab4f955 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager.go
@@ -120,7 +120,7 @@ func (m *Manager) StartExposeReaper(ctx context.Context) {
// capability flags reported by its active proxies so the dashboard can
// render feature support without a second round-trip.
func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -146,7 +146,7 @@ func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]
// DeleteAccountCluster removes all proxy registrations for the given cluster address
// owned by the account.
func (m *Manager) DeleteAccountCluster(ctx context.Context, accountID, userID, clusterAddress string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -158,7 +158,7 @@ func (m *Manager) DeleteAccountCluster(ctx context.Context, accountID, userID, c
}
func (m *Manager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -222,7 +222,7 @@ func (m *Manager) replaceHostByLookup(ctx context.Context, accountID string, s *
}
func (m *Manager) GetService(ctx context.Context, accountID, userID, serviceID string) (*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -243,7 +243,7 @@ func (m *Manager) GetService(ctx context.Context, accountID, userID, serviceID s
}
func (m *Manager) CreateService(ctx context.Context, accountID, userID string, s *service.Service) (*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -528,7 +528,7 @@ func (m *Manager) checkDomainAvailable(ctx context.Context, transaction store.St
}
func (m *Manager) UpdateService(ctx context.Context, accountID, userID string, service *service.Service) (*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -836,7 +836,7 @@ func validateResourceTargetType(target *service.Target, resource *resourcetypes.
}
func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -876,7 +876,7 @@ func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceI
}
func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go
index f3ab89a25..0497415b7 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go
@@ -1172,7 +1172,7 @@ func TestDeleteService_DeletesTargets(t *testing.T) {
mockPerms.EXPECT().
ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAcct.EXPECT().
StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceDeleted, gomock.Any())
mockAcct.EXPECT().
diff --git a/management/internals/modules/zones/manager/manager.go b/management/internals/modules/zones/manager/manager.go
index 439671e65..d5348d3d0 100644
--- a/management/internals/modules/zones/manager/manager.go
+++ b/management/internals/modules/zones/manager/manager.go
@@ -32,7 +32,7 @@ func NewManager(store store.Store, accountManager account.Manager, permissionsMa
}
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)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -44,7 +44,7 @@ func (m *managerImpl) GetAllZones(ctx context.Context, accountID, userID string)
}
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)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -56,7 +56,7 @@ func (m *managerImpl) GetZone(ctx context.Context, accountID, userID, zoneID str
}
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)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -103,7 +103,7 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string,
}
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)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -151,7 +151,7 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string,
}
func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/zones/manager/manager_test.go b/management/internals/modules/zones/manager/manager_test.go
index b45ec7874..29e7e8677 100644
--- a/management/internals/modules/zones/manager/manager_test.go
+++ b/management/internals/modules/zones/manager/manager_test.go
@@ -79,7 +79,7 @@ func TestManagerImpl_GetAllZones(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
require.NoError(t, err)
@@ -95,7 +95,7 @@ func TestManagerImpl_GetAllZones(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
require.Error(t, err)
@@ -112,7 +112,7 @@ func TestManagerImpl_GetAllZones(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, status.Errorf(status.Internal, "permission check failed"))
+ Return(false, ctx, status.Errorf(status.Internal, "permission check failed"))
result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
require.Error(t, err)
@@ -134,7 +134,7 @@ func TestManagerImpl_GetZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetZone(ctx, testAccountID, testUserID, zone.ID)
require.NoError(t, err)
@@ -150,7 +150,7 @@ func TestManagerImpl_GetZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetZone(ctx, testAccountID, testUserID, testZoneID)
require.Error(t, err)
@@ -179,7 +179,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -212,7 +212,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -235,7 +235,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -261,7 +261,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -293,7 +293,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -319,7 +319,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -354,7 +354,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -394,7 +394,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
require.Error(t, err)
@@ -418,7 +418,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
require.Error(t, err)
@@ -441,7 +441,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
require.Error(t, err)
@@ -471,7 +471,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCallCount := 0
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -503,7 +503,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -529,7 +529,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(false, nil)
+ Return(false, ctx, nil)
err := manager.DeleteZone(ctx, testAccountID, testUserID, testZoneID)
require.Error(t, err)
@@ -545,7 +545,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
err := manager.DeleteZone(ctx, testAccountID, testUserID, "non-existent-zone")
require.Error(t, err)
diff --git a/management/internals/modules/zones/records/manager/manager.go b/management/internals/modules/zones/records/manager/manager.go
index 7458b41db..b041aca30 100644
--- a/management/internals/modules/zones/records/manager/manager.go
+++ b/management/internals/modules/zones/records/manager/manager.go
@@ -32,7 +32,7 @@ func NewManager(store store.Store, accountManager account.Manager, permissionsMa
}
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)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -44,7 +44,7 @@ func (m *managerImpl) GetAllRecords(ctx context.Context, accountID, userID, zone
}
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)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -56,7 +56,7 @@ func (m *managerImpl) GetRecord(ctx context.Context, accountID, userID, zoneID,
}
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)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -102,7 +102,7 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI
}
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)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -161,7 +161,7 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI
}
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)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/zones/records/manager/manager_test.go b/management/internals/modules/zones/records/manager/manager_test.go
index 0a962e0f4..a5f48c4a9 100644
--- a/management/internals/modules/zones/records/manager/manager_test.go
+++ b/management/internals/modules/zones/records/manager/manager_test.go
@@ -80,7 +80,7 @@ func TestManagerImpl_GetAllRecords(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
require.NoError(t, err)
@@ -96,7 +96,7 @@ func TestManagerImpl_GetAllRecords(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
require.Error(t, err)
@@ -113,7 +113,7 @@ func TestManagerImpl_GetAllRecords(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, status.Errorf(status.Internal, "permission check failed"))
+ Return(false, ctx, status.Errorf(status.Internal, "permission check failed"))
result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
require.Error(t, err)
@@ -135,7 +135,7 @@ func TestManagerImpl_GetRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, record.ID)
require.NoError(t, err)
@@ -153,7 +153,7 @@ func TestManagerImpl_GetRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID)
require.Error(t, err)
@@ -181,7 +181,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -215,7 +215,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -244,7 +244,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -273,7 +273,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -297,7 +297,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -323,7 +323,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -349,7 +349,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -380,7 +380,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -418,7 +418,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
// Event should be stored
@@ -445,7 +445,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
require.Error(t, err)
@@ -470,7 +470,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
require.Error(t, err)
@@ -500,7 +500,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
require.Error(t, err)
@@ -523,7 +523,7 @@ func TestManagerImpl_DeleteRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -549,7 +549,7 @@ func TestManagerImpl_DeleteRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(false, nil)
+ Return(false, ctx, nil)
err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID)
require.Error(t, err)
@@ -565,7 +565,7 @@ func TestManagerImpl_DeleteRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, "non-existent-record")
require.Error(t, err)
diff --git a/management/server/account.go b/management/server/account.go
index d61380d91..f16717857 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -282,7 +282,7 @@ func (am *DefaultAccountManager) GetIdpManager() idp.Manager {
// User that performs the update has to belong to the account.
// Returns an updated Settings
func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -855,7 +855,7 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u
return err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Delete)
if err != nil {
return fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -1422,7 +1422,7 @@ func (am *DefaultAccountManager) GetAccount(ctx context.Context, accountID strin
// GetAccountByID returns an account associated with this account ID.
func (am *DefaultAccountManager) GetAccountByID(ctx context.Context, accountID string, userID string) (*types.Account, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1435,7 +1435,7 @@ func (am *DefaultAccountManager) GetAccountByID(ctx context.Context, accountID s
// GetAccountMeta returns the account metadata associated with this account ID.
func (am *DefaultAccountManager) GetAccountMeta(ctx context.Context, accountID string, userID string) (*types.AccountMeta, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1448,7 +1448,7 @@ func (am *DefaultAccountManager) GetAccountMeta(ctx context.Context, accountID s
// GetAccountOnboarding retrieves the onboarding information for a specific account.
func (am *DefaultAccountManager) GetAccountOnboarding(ctx context.Context, accountID string, userID string) (*types.AccountOnboarding, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1473,7 +1473,7 @@ func (am *DefaultAccountManager) GetAccountOnboarding(ctx context.Context, accou
}
func (am *DefaultAccountManager) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -1540,7 +1540,8 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u
return accountID, user.Id, nil
}
- if err := am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
+ ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false)
+ if err != nil {
return "", "", err
}
@@ -1986,7 +1987,7 @@ func (am *DefaultAccountManager) handleUserPeer(ctx context.Context, transaction
}
func (am *DefaultAccountManager) GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -2554,7 +2555,7 @@ func (am *DefaultAccountManager) validateIPForUpdate(account *types.Account, pee
}
func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return fmt.Errorf("validate user permissions: %w", err)
}
@@ -2644,7 +2645,7 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti
// UpdatePeerIPv6 updates the IPv6 overlay address of a peer, validating it's
// within the account's v6 network range and not already taken.
func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return fmt.Errorf("validate user permissions: %w", err)
}
diff --git a/management/server/context/keys.go b/management/server/context/keys.go
index 9697997a8..7a65afbbd 100644
--- a/management/server/context/keys.go
+++ b/management/server/context/keys.go
@@ -1,10 +1,27 @@
package context
-import "github.com/netbirdio/netbird/shared/context"
+import (
+ "context"
+
+ nbcontext "github.com/netbirdio/netbird/shared/context"
+)
const (
- RequestIDKey = context.RequestIDKey
- AccountIDKey = context.AccountIDKey
- UserIDKey = context.UserIDKey
- PeerIDKey = context.PeerIDKey
+ RequestIDKey = nbcontext.RequestIDKey
+ AccountIDKey = nbcontext.AccountIDKey
+ RoleKey = nbcontext.RoleKey
+ UserIDKey = nbcontext.UserIDKey
+ PeerIDKey = nbcontext.PeerIDKey
)
+
+// RoleFromContext returns the role stored in ctx, or empty string and false if absent.
+func RoleFromContext(ctx context.Context) (string, bool) {
+ role, ok := ctx.Value(RoleKey).(string)
+ return role, ok
+}
+
+// WithRole returns a new context carrying the given role.
+func WithRole(ctx context.Context, role string) context.Context {
+ //nolint
+ return context.WithValue(ctx, RoleKey, role)
+}
diff --git a/management/server/dns.go b/management/server/dns.go
index c62fa5185..dcc3f21c7 100644
--- a/management/server/dns.go
+++ b/management/server/dns.go
@@ -22,7 +22,7 @@ const (
// GetDNSSettings validates a user role and returns the DNS settings for the provided account ID
func (am *DefaultAccountManager) GetDNSSettings(ctx context.Context, accountID string, userID string) (*types.DNSSettings, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -39,7 +39,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
return status.Errorf(status.InvalidArgument, "the dns settings provided are nil")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/event.go b/management/server/event.go
index d26c569ae..4211f2dda 100644
--- a/management/server/event.go
+++ b/management/server/event.go
@@ -23,7 +23,7 @@ func isEnabled() bool {
// GetEvents returns a list of activity events of an account
func (am *DefaultAccountManager) GetEvents(ctx context.Context, accountID, userID string) ([]*activity.Event, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Events, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Events, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/group.go b/management/server/group.go
index 870a441ac..7e02af245 100644
--- a/management/server/group.go
+++ b/management/server/group.go
@@ -32,7 +32,7 @@ func (e *GroupLinkError) Error() string {
// CheckGroupPermissions validates if a user has the necessary permissions to view groups
func (am *DefaultAccountManager) CheckGroupPermissions(ctx context.Context, accountID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
+ allowed, _, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
if err != nil {
return err
}
@@ -70,7 +70,7 @@ func (am *DefaultAccountManager) GetGroupByName(ctx context.Context, groupName,
// CreateGroup object of the peers
func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -125,7 +125,7 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
// UpdateGroup object of the peers
func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -200,7 +200,7 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
// It is the caller's responsibility to ensure proper locking is in place before invoking this method.
// This method will not create group peer membership relations. Use AddPeerToGroup or RemovePeerFromGroup methods for that.
func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, userID string, groups []*types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -268,7 +268,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
// It is the caller's responsibility to ensure proper locking is in place before invoking this method.
// This method will not create group peer membership relations. Use AddPeerToGroup or RemovePeerFromGroup methods for that.
func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, userID string, groups []*types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -427,7 +427,7 @@ func (am *DefaultAccountManager) DeleteGroup(ctx context.Context, accountID, use
// If an error occurs while deleting a group, the function skips it and continues deleting other groups.
// Errors are collected and returned at the end.
func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, userID string, groupIDs []string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go
index d110ab564..c9a877d6f 100644
--- a/management/server/groups/manager.go
+++ b/management/server/groups/manager.go
@@ -42,7 +42,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, accou
}
func (m *managerImpl) GetAllGroups(ctx context.Context, accountID, userID string) ([]*types.Group, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
if err != nil {
return nil, err
}
@@ -73,7 +73,7 @@ func (m *managerImpl) GetAllGroupsMap(ctx context.Context, accountID, userID str
}
func (m *managerImpl) AddResourceToGroup(ctx context.Context, accountID, userID, groupID string, resource *types.Resource) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
if err != nil {
return err
}
diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go
index 91026a374..1d4af95e9 100644
--- a/management/server/http/handlers/peers/peers_handler.go
+++ b/management/server/http/handlers/peers/peers_handler.go
@@ -405,48 +405,48 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
return
}
- allowed, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read)
if err != nil {
- util.WriteError(r.Context(), status.NewPermissionValidationError(err), w)
+ util.WriteError(ctx, status.NewPermissionValidationError(err), w)
return
}
- account, err := h.accountManager.GetAccountByID(r.Context(), accountID, activity.SystemInitiator)
+ account, err := h.accountManager.GetAccountByID(ctx, accountID, activity.SystemInitiator)
if err != nil {
- util.WriteError(r.Context(), err, w)
+ util.WriteError(ctx, err, w)
return
}
if !allowed && !userAuth.IsChild {
if account.Settings.RegularUsersViewBlocked {
- util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{})
+ util.WriteJSONObject(ctx, w, []api.AccessiblePeer{})
return
}
peer, ok := account.Peers[peerID]
if !ok {
- util.WriteError(r.Context(), status.Errorf(status.NotFound, "peer not found"), w)
+ util.WriteError(ctx, status.Errorf(status.NotFound, "peer not found"), w)
return
}
if peer.UserID != user.Id {
- util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{})
+ util.WriteJSONObject(ctx, w, []api.AccessiblePeer{})
return
}
}
- validPeers, _, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
+ validPeers, _, err := h.accountManager.GetValidatedPeers(ctx, accountID)
if err != nil {
- log.WithContext(r.Context()).Errorf("failed to list approved peers: %v", err)
- util.WriteError(r.Context(), fmt.Errorf("internal error"), w)
+ log.WithContext(ctx).Errorf("failed to list approved peers: %v", err)
+ util.WriteError(ctx, fmt.Errorf("internal error"), w)
return
}
dnsDomain := h.networkMapController.GetDNSDomain(account.Settings)
- netMap := account.GetPeerNetworkMapFromComponents(r.Context(), peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
+ netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
- util.WriteJSONObject(r.Context(), w, toAccessiblePeers(netMap, dnsDomain))
+ util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, dnsDomain))
}
func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) {
diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go
index 9db095c8d..047213879 100644
--- a/management/server/http/handlers/peers/peers_handler_test.go
+++ b/management/server/http/handlers/peers/peers_handler_test.go
@@ -116,15 +116,15 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
ctrl2 := gomock.NewController(t)
permissionsManager := permissions.NewMockManager(ctrl2)
- permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+ permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(context.Background(), nil).AnyTimes()
permissionsManager.EXPECT().
ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).
- DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) {
+ DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) {
user, ok := account.Users[userID]
if !ok {
- return false, fmt.Errorf("user not found")
+ return false, ctx, fmt.Errorf("user not found")
}
- return user.HasAdminPower() || user.IsServiceUser, nil
+ return user.HasAdminPower() || user.IsServiceUser, ctx, nil
}).
AnyTimes()
diff --git a/management/server/http/handlers/policies/geolocation_handler_test.go b/management/server/http/handlers/policies/geolocation_handler_test.go
index 094a36e38..f5723b8fc 100644
--- a/management/server/http/handlers/policies/geolocation_handler_test.go
+++ b/management/server/http/handlers/policies/geolocation_handler_test.go
@@ -51,7 +51,7 @@ func initGeolocationTestData(t *testing.T) *geolocationsHandler {
permissionsManagerMock.
EXPECT().
ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), modules.Policies, operations.Read).
- Return(true, nil).
+ Return(true, context.Background(), nil).
AnyTimes()
return &geolocationsHandler{
diff --git a/management/server/identity_provider.go b/management/server/identity_provider.go
index f965f36b8..86bbcd893 100644
--- a/management/server/identity_provider.go
+++ b/management/server/identity_provider.go
@@ -88,7 +88,7 @@ func validateIdentityProviderConfig(ctx context.Context, idpConfig *types.Identi
// 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)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -117,7 +117,7 @@ func (am *DefaultAccountManager) GetIdentityProviders(ctx context.Context, accou
// GetIdentityProvider returns a specific identity provider by ID
func (am *DefaultAccountManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -143,7 +143,7 @@ func (am *DefaultAccountManager) GetIdentityProvider(ctx context.Context, accoun
// CreateIdentityProvider creates a new identity provider
func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Create)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -180,7 +180,7 @@ func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, acc
// UpdateIdentityProvider updates an existing identity provider
func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Update)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -213,7 +213,7 @@ func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, acc
// DeleteIdentityProvider deletes an identity provider
func (am *DefaultAccountManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Delete)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/nameserver.go b/management/server/nameserver.go
index 5859bfb0d..c836fefeb 100644
--- a/management/server/nameserver.go
+++ b/management/server/nameserver.go
@@ -23,7 +23,7 @@ var errInvalidDomainName = errors.New("invalid domain name")
// GetNameServerGroup gets a nameserver group object from account and nameserver group IDs
func (am *DefaultAccountManager) GetNameServerGroup(ctx context.Context, accountID, userID, nsGroupID string) (*nbdns.NameServerGroup, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -36,7 +36,7 @@ func (am *DefaultAccountManager) GetNameServerGroup(ctx context.Context, account
// CreateNameServerGroup creates and saves a new nameserver group
func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainEnabled bool) (*nbdns.NameServerGroup, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -94,7 +94,7 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
return status.Errorf(status.InvalidArgument, "nameserver group provided is nil")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -141,7 +141,7 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
// DeleteNameServerGroup deletes nameserver group with nsGroupID
func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, accountID, nsGroupID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -184,7 +184,7 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco
// ListNameServerGroups returns a list of nameserver groups from account
func (am *DefaultAccountManager) ListNameServerGroups(ctx context.Context, accountID string, userID string) ([]*nbdns.NameServerGroup, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/networks/manager.go b/management/server/networks/manager.go
index c96b60bb2..f825ae015 100644
--- a/management/server/networks/manager.go
+++ b/management/server/networks/manager.go
@@ -49,7 +49,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, resou
}
func (m *managerImpl) GetAllNetworks(ctx context.Context, accountID, userID string) ([]*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -61,7 +61,7 @@ func (m *managerImpl) GetAllNetworks(ctx context.Context, accountID, userID stri
}
func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network *types.Network) (*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -82,7 +82,7 @@ func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network
}
func (m *managerImpl) GetNetwork(ctx context.Context, accountID, userID, networkID string) (*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -94,7 +94,7 @@ func (m *managerImpl) GetNetwork(ctx context.Context, accountID, userID, network
}
func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network *types.Network) (*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -113,7 +113,7 @@ func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network
}
func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, networkID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go
index 5a0e26533..51a269163 100644
--- a/management/server/networks/resources/manager.go
+++ b/management/server/networks/resources/manager.go
@@ -54,7 +54,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, group
}
func (m *managerImpl) GetAllResourcesInNetwork(ctx context.Context, accountID, userID, networkID string) ([]*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -66,7 +66,7 @@ func (m *managerImpl) GetAllResourcesInNetwork(ctx context.Context, accountID, u
}
func (m *managerImpl) GetAllResourcesInAccount(ctx context.Context, accountID, userID string) ([]*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -78,7 +78,7 @@ func (m *managerImpl) GetAllResourcesInAccount(ctx context.Context, accountID, u
}
func (m *managerImpl) GetAllResourceIDsInAccount(ctx context.Context, accountID, userID string) (map[string][]string, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -100,7 +100,7 @@ func (m *managerImpl) GetAllResourceIDsInAccount(ctx context.Context, accountID,
}
func (m *managerImpl) CreateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -168,7 +168,7 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc
}
func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -189,7 +189,7 @@ func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networ
}
func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -314,7 +314,7 @@ func (m *managerImpl) updateResourceGroups(ctx context.Context, transaction stor
}
func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, networkID, resourceID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go
index ed5b0e558..9fa2b95f7 100644
--- a/management/server/networks/routers/manager.go
+++ b/management/server/networks/routers/manager.go
@@ -47,7 +47,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, accou
}
func (m *managerImpl) GetAllRoutersInNetwork(ctx context.Context, accountID, userID, networkID string) ([]*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -59,7 +59,7 @@ func (m *managerImpl) GetAllRoutersInNetwork(ctx context.Context, accountID, use
}
func (m *managerImpl) GetAllRoutersInAccount(ctx context.Context, accountID, userID string) (map[string][]*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -81,7 +81,7 @@ func (m *managerImpl) GetAllRoutersInAccount(ctx context.Context, accountID, use
}
func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -126,7 +126,7 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
}
func (m *managerImpl) GetRouter(ctx context.Context, accountID, userID, networkID, routerID string) (*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -147,7 +147,7 @@ func (m *managerImpl) GetRouter(ctx context.Context, accountID, userID, networkI
}
func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -199,7 +199,7 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t
}
func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/peer.go b/management/server/peer.go
index 7066bf307..4942e44c1 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -42,7 +42,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -209,7 +209,7 @@ func (am *DefaultAccountManager) updatePeerLocationIfChanged(ctx context.Context
// UpdatePeer updates peer. Only Peer.Name, Peer.SSHEnabled, Peer.LoginExpirationEnabled and Peer.InactivityExpirationEnabled can be updated.
func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -354,7 +354,7 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
}
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)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -430,7 +430,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
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)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -456,7 +456,7 @@ func (am *DefaultAccountManager) GetAllPeerJobs(ctx context.Context, accountID,
}
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)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -483,7 +483,7 @@ func (am *DefaultAccountManager) GetPeerJobByID(ctx context.Context, accountID,
// 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)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -643,7 +643,7 @@ func (am *DefaultAccountManager) handleUserAddedPeer(ctx context.Context, accoun
}
if temporary {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create)
+ allowed, _, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -1379,7 +1379,7 @@ func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID,
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/permissions/manager.go b/management/server/permissions/manager.go
index e6bdd2025..995f234d8 100644
--- a/management/server/permissions/manager.go
+++ b/management/server/permissions/manager.go
@@ -9,6 +9,7 @@ import (
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
+ nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/permissions/roles"
@@ -18,9 +19,9 @@ import (
)
type Manager interface {
- ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error)
+ ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error)
ValidateRoleModuleAccess(ctx context.Context, accountID string, role roles.RolePermissions, module modules.Module, operation operations.Operation) bool
- ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error
+ ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error)
GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error)
SetAccountManager(accountManager account.Manager)
@@ -42,42 +43,43 @@ func (m *managerImpl) ValidateUserPermissions(
userID string,
module modules.Module,
operation operations.Operation,
-) (bool, error) {
+) (bool, context.Context, error) {
if userID == activity.SystemInitiator {
- return true, nil
+ return true, ctx, nil
}
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
- return false, err
+ return false, ctx, err
}
if user == nil {
- return false, status.NewUserNotFoundError(userID)
+ return false, ctx, status.NewUserNotFoundError(userID)
}
if user.IsBlocked() && !user.PendingApproval {
- return false, status.NewUserBlockedError()
+ return false, ctx, status.NewUserBlockedError()
}
if user.IsBlocked() && user.PendingApproval {
- return false, status.NewUserPendingApprovalError()
+ return false, ctx, status.NewUserPendingApprovalError()
}
- if err := m.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
- return false, err
+ ctxEnriched, err := m.ValidateAccountAccess(ctx, accountID, user, false)
+ if err != nil {
+ return false, ctx, err
}
if operation == operations.Read && user.IsServiceUser {
- return true, nil // this should be replaced by proper granular access role
+ return true, ctxEnriched, nil // this should be replaced by proper granular access role
}
role, ok := roles.RolesMap[user.Role]
if !ok {
- return false, status.NewUserRoleNotFoundError(string(user.Role))
+ return false, ctxEnriched, status.NewUserRoleNotFoundError(string(user.Role))
}
- return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), nil
+ return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil
}
func (m *managerImpl) ValidateRoleModuleAccess(
@@ -98,11 +100,14 @@ func (m *managerImpl) ValidateRoleModuleAccess(
return role.AutoAllowNew[operation]
}
-func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error {
+func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
if user.AccountID != accountID {
- return status.NewUserNotPartOfAccountError()
+ return ctx, status.NewUserNotPartOfAccountError()
}
- return nil
+
+ ctx = nbcontext.WithRole(ctx, string(user.Role))
+
+ return ctx, nil
}
func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error) {
diff --git a/management/server/permissions/manager_mock.go b/management/server/permissions/manager_mock.go
index ec9f263f9..934e33398 100644
--- a/management/server/permissions/manager_mock.go
+++ b/management/server/permissions/manager_mock.go
@@ -67,11 +67,12 @@ func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{})
}
// ValidateAccountAccess mocks base method.
-func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error {
+func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateAccountAccess", ctx, accountID, user, allowOwnerAndAdmin)
- ret0, _ := ret[0].(error)
- return ret0
+ ret0, _ := ret[0].(context.Context)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
}
// ValidateAccountAccess indicates an expected call of ValidateAccountAccess.
@@ -95,12 +96,13 @@ func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role
}
// ValidateUserPermissions mocks base method.
-func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) {
+func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateUserPermissions", ctx, accountID, userID, module, operation)
ret0, _ := ret[0].(bool)
- ret1, _ := ret[1].(error)
- return ret0, ret1
+ ret1, _ := ret[1].(context.Context)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
}
// ValidateUserPermissions indicates an expected call of ValidateUserPermissions.
diff --git a/management/server/policy.go b/management/server/policy.go
index 40f3908e3..d67b3206e 100644
--- a/management/server/policy.go
+++ b/management/server/policy.go
@@ -19,7 +19,7 @@ import (
// GetPolicy from the store
func (am *DefaultAccountManager) GetPolicy(ctx context.Context, accountID, policyID, userID string) (*types.Policy, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -36,7 +36,7 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
if !create {
operation = operations.Update
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -108,7 +108,7 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
// DeletePolicy from the store
func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, policyID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -151,7 +151,7 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po
// ListPolicies from the store.
func (am *DefaultAccountManager) ListPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go
index 1e3ce4b8a..56a732bf5 100644
--- a/management/server/posture_checks.go
+++ b/management/server/posture_checks.go
@@ -16,7 +16,7 @@ import (
)
func (am *DefaultAccountManager) GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -33,7 +33,7 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
if !create {
operation = operations.Update
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -89,7 +89,7 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
// DeletePostureChecks deletes a posture check by ID.
func (am *DefaultAccountManager) DeletePostureChecks(ctx context.Context, accountID, postureChecksID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -126,7 +126,7 @@ func (am *DefaultAccountManager) DeletePostureChecks(ctx context.Context, accoun
// ListPostureChecks returns a list of posture checks.
func (am *DefaultAccountManager) ListPostureChecks(ctx context.Context, accountID, userID string) ([]*posture.Checks, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/route.go b/management/server/route.go
index a9561faf0..8fd1cb02a 100644
--- a/management/server/route.go
+++ b/management/server/route.go
@@ -21,7 +21,7 @@ import (
// GetRoute gets a route object from account and route IDs
func (am *DefaultAccountManager) GetRoute(ctx context.Context, accountID string, routeID route.ID, userID string) (*route.Route, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -134,7 +134,7 @@ func getRouteDescriptor(prefix netip.Prefix, domains domain.List) string {
// CreateRoute creates and saves a new route
func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peerID string, peerGroupIDs []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupIDs []string, enabled bool, userID string, keepRoute bool, skipAutoApply bool) (*route.Route, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -199,7 +199,7 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
// SaveRoute saves route
func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userID string, routeToSave *route.Route) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -253,7 +253,7 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI
// DeleteRoute deletes route with routeID
func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID string, routeID route.ID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -296,7 +296,7 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri
// ListRoutes returns a list of routes from account
func (am *DefaultAccountManager) ListRoutes(ctx context.Context, accountID, userID string) ([]*route.Route, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/settings/manager.go b/management/server/settings/manager.go
index 345d857f9..f84739193 100644
--- a/management/server/settings/manager.go
+++ b/management/server/settings/manager.go
@@ -59,7 +59,7 @@ func (m *managerImpl) GetExtraSettingsManager() extra_settings.Manager {
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
if userID != activity.SystemInitiator {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
+ ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/setupkey.go b/management/server/setupkey.go
index 8d0509871..cfc44377d 100644
--- a/management/server/setupkey.go
+++ b/management/server/setupkey.go
@@ -56,7 +56,7 @@ type SetupKeyUpdateOperation struct {
func (am *DefaultAccountManager) CreateSetupKey(ctx context.Context, accountID string, keyName string, keyType types.SetupKeyType,
expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -105,7 +105,7 @@ func (am *DefaultAccountManager) SaveSetupKey(ctx context.Context, accountID str
return nil, status.Errorf(status.InvalidArgument, "provided setup key to update is nil")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -162,7 +162,7 @@ func (am *DefaultAccountManager) SaveSetupKey(ctx context.Context, accountID str
// ListSetupKeys returns a list of all setup keys of the account
func (am *DefaultAccountManager) ListSetupKeys(ctx context.Context, accountID, userID string) ([]*types.SetupKey, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -175,7 +175,7 @@ func (am *DefaultAccountManager) ListSetupKeys(ctx context.Context, accountID, u
// GetSetupKey looks up a SetupKey by KeyID, returns NotFound error if not found.
func (am *DefaultAccountManager) GetSetupKey(ctx context.Context, accountID, userID, keyID string) (*types.SetupKey, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -198,7 +198,7 @@ func (am *DefaultAccountManager) GetSetupKey(ctx context.Context, accountID, use
// DeleteSetupKey removes the setup key from the account
func (am *DefaultAccountManager) DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/user.go b/management/server/user.go
index 60571a702..7cd955000 100644
--- a/management/server/user.go
+++ b/management/server/user.go
@@ -31,7 +31,7 @@ import (
// createServiceUser creates a new service user under the given account.
func (am *DefaultAccountManager) createServiceUser(ctx context.Context, accountID string, initiatorUserID string, role types.UserRole, serviceUserName string, nonDeletable bool, autoGroups []string) (*types.UserInfo, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -86,7 +86,7 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -307,7 +307,7 @@ func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, init
return err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -357,7 +357,7 @@ func (am *DefaultAccountManager) InviteUser(ctx context.Context, accountID strin
return status.Errorf(status.PreconditionFailed, "IdP manager must be enabled to send user invites")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -401,7 +401,7 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string
return nil, status.Errorf(status.InvalidArgument, "expiration has to be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays)
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -445,7 +445,7 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string
// DeletePAT deletes a specific PAT from a user
func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -488,7 +488,7 @@ func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string
// GetPAT returns a specific PAT from a user
func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -519,7 +519,7 @@ func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, i
// GetAllPATs returns all PATs for a user
func (am *DefaultAccountManager) GetAllPATs(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) ([]*types.PersonalAccessToken, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -576,7 +576,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
return nil, nil //nolint:nilnil
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) // TODO: split by Create and Update
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) // TODO: split by Create and Update
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -610,6 +610,11 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
return nil, err
}
initiatorUser = result
+ role, ok := nbcontext.RoleFromContext(ctx)
+ if !ok {
+ return nil, status.Errorf(status.Internal, "failed to get user role from context")
+ }
+ initiatorUser.Role = types.UserRole(role)
}
var globalErr error
@@ -755,19 +760,6 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
return false, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
}
- if initiatorUserId != activity.SystemInitiator {
- freshInitiator, err := transaction.GetUserByUserID(ctx, store.LockingStrengthUpdate, initiatorUserId)
- if err != nil {
- return false, nil, nil, nil, fmt.Errorf("failed to re-read initiator user in transaction: %w", err)
- }
-
- // Ensure the initiator still has admin privileges
- if !freshInitiator.HasAdminPower() {
- return false, nil, nil, nil, status.Errorf(status.PermissionDenied, "initiator role was changed during request processing")
- }
- initiatorUser = freshInitiator
- }
-
oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists)
if err != nil {
return false, nil, nil, nil, err
@@ -988,7 +980,7 @@ func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, u
// GetUsersFromAccount performs a batched request for users from IDP by account ID apply filter on what data to return
// based on provided user role.
func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accountID, initiatorUserID string) (map[string]*types.UserInfo, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1205,7 +1197,7 @@ func (am *DefaultAccountManager) deleteUserFromIDP(ctx context.Context, targetUs
// If an error occurs while deleting the user, the function skips it and continues deleting other users.
// Errors are collected and returned at the end.
func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -1403,7 +1395,8 @@ func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAut
return nil, status.NewPermissionDeniedError()
}
- if err := am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
+ ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false)
+ if err != nil {
return nil, err
}
@@ -1432,7 +1425,7 @@ func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAut
// ApproveUser approves a user that is pending approval
func (am *DefaultAccountManager) ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1473,7 +1466,7 @@ func (am *DefaultAccountManager) ApproveUser(ctx context.Context, accountID, ini
// RejectUser rejects a user that is pending approval by deleting them
func (am *DefaultAccountManager) RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -1519,7 +1512,7 @@ func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1637,7 +1630,7 @@ func (am *DefaultAccountManager) ListUserInvites(ctx context.Context, accountID,
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1751,7 +1744,7 @@ func (am *DefaultAccountManager) RegenerateUserInvite(ctx context.Context, accou
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1813,7 +1806,7 @@ func (am *DefaultAccountManager) DeleteUserInvite(ctx context.Context, accountID
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/user_test.go b/management/server/user_test.go
index c77ea53d1..2a2d7857d 100644
--- a/management/server/user_test.go
+++ b/management/server/user_test.go
@@ -2129,66 +2129,3 @@ func TestUser_Operations_WithEmbeddedIDP(t *testing.T) {
t.Logf("Duplicate email error: %v", err)
})
}
-
-func TestProcessUserUpdate_RejectsStaleInitiatorRole(t *testing.T) {
- s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
- require.NoError(t, err)
- t.Cleanup(cleanup)
-
- account := newAccountWithId(context.Background(), "account1", "owner1", "", "", "", false)
-
- adminID := "admin1"
- account.Users[adminID] = types.NewAdminUser(adminID)
-
- targetID := "target1"
- account.Users[targetID] = types.NewRegularUser(targetID, "", "")
-
- require.NoError(t, s.SaveAccount(context.Background(), account))
-
- demotedAdmin, err := s.GetUserByUserID(context.Background(), store.LockingStrengthNone, adminID)
- require.NoError(t, err)
- demotedAdmin.Role = types.UserRoleUser
- require.NoError(t, s.SaveUser(context.Background(), demotedAdmin))
-
- staleInitiator := &types.User{
- Id: adminID,
- AccountID: account.Id,
- Role: types.UserRoleAdmin,
- }
-
- permissionsManager := permissions.NewManager(s)
- am := DefaultAccountManager{
- Store: s,
- eventStore: &activity.InMemoryEventStore{},
- permissionsManager: permissionsManager,
- }
-
- settings, err := s.GetAccountSettings(context.Background(), store.LockingStrengthNone, account.Id)
- require.NoError(t, err)
-
- groups, err := s.GetAccountGroups(context.Background(), store.LockingStrengthNone, account.Id)
- require.NoError(t, err)
- groupsMap := make(map[string]*types.Group, len(groups))
- for _, g := range groups {
- groupsMap[g.ID] = g
- }
-
- update := &types.User{
- Id: targetID,
- Role: types.UserRoleAdmin,
- }
-
- err = s.ExecuteInTransaction(context.Background(), func(tx store.Store) error {
- _, _, _, _, txErr := am.processUserUpdate(
- context.Background(), tx, groupsMap, account.Id, adminID, staleInitiator, update, false, settings,
- )
- return txErr
- })
-
- require.Error(t, err, "processUserUpdate should reject stale initiator whose role was demoted")
- assert.Contains(t, err.Error(), "initiator role was changed during request processing")
-
- targetUser, err := s.GetUserByUserID(context.Background(), store.LockingStrengthNone, targetID)
- require.NoError(t, err)
- assert.Equal(t, types.UserRoleUser, targetUser.Role)
-}
diff --git a/shared/context/keys.go b/shared/context/keys.go
index c5b5da044..ca56be67e 100644
--- a/shared/context/keys.go
+++ b/shared/context/keys.go
@@ -3,6 +3,7 @@ package context
const (
RequestIDKey = "requestID"
AccountIDKey = "accountID"
+ RoleKey = "role"
UserIDKey = "userID"
PeerIDKey = "peerID"
)
diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go
index be2c009ad..53f3a262d 100644
--- a/shared/management/client/client_test.go
+++ b/shared/management/client/client_test.go
@@ -17,8 +17,8 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
- "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
@@ -89,7 +89,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
gomock.Any(),
gomock.Any(),
).
- Return(true, nil).
+ Return(true, context.Background(), nil).
AnyTimes()
peersManger := peers.NewManager(store, permissionsManagerMock)
From e7c9182ff93b113d8ee14385f451d98bca9440a2 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 2 Jun 2026 02:38:00 +0900
Subject: [PATCH 24/44] [client] Offer injected ICMPv6 echo replies to packet
capture (#6321)
---
client/firewall/uspfilter/forwarder/icmp.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/client/firewall/uspfilter/forwarder/icmp.go b/client/firewall/uspfilter/forwarder/icmp.go
index d6d4e705e..94a50570f 100644
--- a/client/firewall/uspfilter/forwarder/icmp.go
+++ b/client/firewall/uspfilter/forwarder/icmp.go
@@ -362,6 +362,10 @@ func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload
return 0
}
+ if pc := f.endpoint.capture.Load(); pc != nil {
+ (*pc).Offer(fullPacket, true)
+ }
+
return len(fullPacket)
}
From fa1e241aea327c78187665c07c34506be42f1fd6 Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Tue, 2 Jun 2026 13:40:09 +0200
Subject: [PATCH 25/44] [management, client, proxy] Follow-up fixes for private
reverse-proxy services (#6268)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(proxy): gate tunnel-peer fast-path on inbound listener marker
forwardWithTunnelPeer previously accepted any RFC1918 / ULA / CGNAT
source IP, so a public client whose address happened to fall in those
ranges could bypass the configured operator auth scheme by colliding
with a known tunnel IP. The fast-path is now gated on
TunnelLookupFromContext(r.Context()) being present โ that context value
is attached only by the per-account inbound (overlay) listener, so the
host-facing listener never enters this branch.
Tests updated to reflect the new requirement: requests that don't
carry the inbound marker now fall through to the regular auth flow.
* fix(proxy): harden inbound listener resource + startup-ctx handling
Three correctness fixes on the per-account inbound path, with tests:
- Close the logrus ErrorLog PipeWriter on tearDown. WriterLevel hands
back an *io.PipeWriter backed by a pipe + scanner goroutine that the
caller owns; the two writers per account (https + plain) were never
closed, leaking the pipe and goroutine on every teardown.
- Run the post-Start hooks on context.Background(). runClientStartup
is launched in a goroutine from AddPeer and was inheriting the
caller's request-scoped ctx, so a cancelled request could abort the
inbound bring-up or fail the management status notification. The
tail is split into notifyClientReady so the contract is testable.
Tests cover the PipeWriter close behaviour and assert the readyHandler
+ NotifyStatus calls receive a non-cancelled background context.
* feat(proxy): short-circuit peer-own-target loops with 421
When a peer that hosts the target of a private service dials its own
service URL the request was being looped through the proxy and back
over WireGuard to the same peer โ twice the WG round-trip for no
benefit, with no signal to the caller that something was wrong.
Add isSelfTargetLoop to ReverseProxy.ServeHTTP: when the request
arrived on the per-account overlay listener (IsOverlayOrigin) and the
source tunnel IP matches the target host, refuse the request with 421
Misdirected Request and a body pointing the operator at the backend
directly.
The gate is scoped to overlay origin so requests on the public
listener that happen to share a source IP with the target host are
forwarded normally.
* fix(management): private-service validation + tunnel-IP lookup semantics
- Require an explicit port for L4 cluster targets. validateL4Target
exempted TargetTypeCluster from the port check, but buildPathMappings
serializes every L4 target via net.JoinHostPort(host, port) โ port=0
shipped a ":0" upstream. Cluster targets use the same Host/Port
fields, so the same requirement applies.
- GetPeerByIP returns NotFound on a tunnel-IP miss instead of mapping
every error to Internal. The proxy's ValidateTunnelPeer probes IPs
that legitimately aren't in the roster; the miss is expected and now
distinguishable from a real store failure.
- Thread ctx into getClusterCapability's gorm query so a cancelled
request doesn't keep the store busy.
Tests updated for the L4-cluster port requirement and the GetPeerByIP
NotFound path.
* fix(client): include offlinePeers in PeerStateByIP lookup
ReplaceOfflinePeers moves peers into d.offlinePeers but PeerStateByIP
only scanned d.peers. Callers (the local DNS filter via
localPeerConnectivity, embed.Client.IdentityForIP used by the
proxy's tunnel-peer validator) were treating known-but-offline peers
as unknown, which:
- causes the DNS filter to keep returning records pointing at peers
that have no live tunnel, AND
- makes the proxy's local-roster check deny a request from such a
peer rather than letting the cached management RPC carry the
authorisation decision.
Search both slices in PeerStateByIP. Adds a unit test for the IPv4
and IPv6 offline-match paths.
* fix(rest): reject empty Delete path params in reverse-proxy clients
ReverseProxyClustersAPI.Delete and ReverseProxyTokensAPI.Delete passed
the path parameter into url.PathEscape without an empty check.
PathEscape("") returns "" which collapses the request onto the
collection endpoint ("/api/reverse-proxies/clusters/" /
"/api/reverse-proxies/proxy-tokens/"), so a caller bug delete with no
id reached a routable URL with surprising semantics (typically 405).
Short-circuit with a typed error before the request is built. Tests
mount a handler on the collection path that fails the test if hit, so
the regression is impossible to reintroduce silently.
* chore(api,ci,docs,test): private-service schema, proto-check, fixups
Non-functional cleanups and contract/CI hardening around the
private-service work:
API schema (openapi.yml):
- Require a non-empty access_groups and mode=http when private=true,
on both Service and ServiceRequest, mirroring
validatePrivateRequirements. mode stays optional-but-constrained
(empty defaults to http server-side), matching runtime.
CI (proto-version-check.yml):
- Cover renamed .pb.go files (read base via previous_filename).
- Match protoc-gen-go-grpc version headers (optional "- " prefix and
-gen-go-grpc suffix) so grpc-generated files are in scope.
Docs / comments:
- Reword Config field docs to say defaults are applied at Server.Start
(initDefaults), not New.
- Rename the obsolete --private-inbound flag to --private across
comments and the proto doc.
Pre-existing test fixups surfaced by review:
- Repair the integration-tagged validate_session_test.go (SignToken
signature growth + new Manager interface methods).
- Fix the CI-skip boolean precedence so Windows isn't skipped
unconditionally.
- Guard the router.HTTPListener type assertion with comma-ok.
* fix(proxy): background ctx for already-started AddPeer notification
The earlier ctx fix covered the async runClientStartup path but missed
the synchronous branch: when a service is added to an already-started
client, AddPeer called NotifyStatus with the caller's request-scoped
ctx. A cancelled request/stream could drop the connected notification
to management. Use context.Background() here too, matching
notifyClientReady.
Extends TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus to
pass a pre-cancelled caller ctx and assert the notification still ran
on a non-cancelled context.
* use the cmd context for roundtripper
---
.github/workflows/proto-version-check.yml | 39 +++++---
client/internal/peer/status.go | 13 ++-
client/internal/peer/status_test.go | 22 +++++
.../modules/reverseproxy/service/service.go | 6 +-
.../reverseproxy/service/service_test.go | 14 ++-
.../shared/grpc/validate_session_test.go | 27 ++++-
management/server/store/sql_store.go | 9 +-
.../server/store/sql_store_service_test.go | 2 +-
management/server/store/sql_store_test.go | 21 ++++
proxy/cmd/proxy/cmd/root.go | 8 +-
proxy/inbound.go | 66 ++++++++-----
proxy/inbound_test.go | 39 +++++++-
proxy/internal/auth/middleware.go | 40 +++++---
proxy/internal/auth/middleware_test.go | 90 +++++++++++++++++
proxy/internal/auth/tunnel_lookup_test.go | 44 +++++++--
proxy/internal/debug/handler.go | 2 +-
proxy/internal/proxy/reverseproxy.go | 42 ++++++++
proxy/internal/proxy/reverseproxy_test.go | 98 +++++++++++++++++++
proxy/internal/roundtrip/netbird.go | 44 +++++++--
proxy/internal/roundtrip/netbird_test.go | 75 ++++++++++++--
proxy/internal/tcp/router_test.go | 5 +-
proxy/lifecycle.go | 17 ++--
proxy/process_mappings_bench_test.go | 2 +-
proxy/server.go | 7 +-
proxy/server_test.go | 6 +-
.../client/rest/reverse_proxy_clusters.go | 7 ++
.../rest/reverse_proxy_clusters_test.go | 14 +++
.../client/rest/reverse_proxy_tokens.go | 7 ++
.../client/rest/reverse_proxy_tokens_test.go | 13 +++
shared/management/http/api/openapi.yml | 35 +++++++
shared/management/proto/proxy_service.proto | 2 +-
31 files changed, 711 insertions(+), 105 deletions(-)
diff --git a/.github/workflows/proto-version-check.yml b/.github/workflows/proto-version-check.yml
index 04793b404..fd2c2c908 100644
--- a/.github/workflows/proto-version-check.yml
+++ b/.github/workflows/proto-version-check.yml
@@ -20,15 +20,30 @@ jobs:
per_page: 100,
});
- const modifiedPbFiles = files.filter(
- f => f.filename.endsWith('.pb.go') && f.status === 'modified'
- );
- if (modifiedPbFiles.length === 0) {
- console.log('No modified .pb.go files to check');
+ // Cover renamed .pb.go files in addition to plain edits.
+ // Renamed entries land under the new path with previous_filename
+ // pointing at the base-side name, so we read the base content
+ // from the old path when present.
+ const changedPbFiles = files
+ .filter(f => (f.status === 'modified' || f.status === 'renamed')
+ && f.filename.endsWith('.pb.go'))
+ .map(f => ({
+ headPath: f.filename,
+ basePath: f.previous_filename || f.filename,
+ }));
+ if (changedPbFiles.length === 0) {
+ console.log('No modified or renamed .pb.go files to check');
return;
}
- const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
+ // Matches the generator version headers protoc writes at the top
+ // of generated files:
+ // // protoc v3.21.12
+ // // protoc-gen-go v1.26.0
+ // // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ")
+ // The optional "- " prefix and the optional -gen-go / -gen-go-grpc
+ // suffixes keep the *_grpc.pb.go headers in scope.
+ const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\s+v[\d.]+/;
const baseSha = context.payload.pull_request.base.sha;
const headSha = context.payload.pull_request.head.sha;
@@ -55,20 +70,22 @@ jobs:
}
const violations = [];
- for (const file of modifiedPbFiles) {
+ for (const file of changedPbFiles) {
const [base, head] = await Promise.all([
- getVersionHeader(file.filename, baseSha),
- getVersionHeader(file.filename, headSha),
+ getVersionHeader(file.basePath, baseSha),
+ getVersionHeader(file.headPath, headSha),
]);
if (!base.ok || !head.ok) {
core.warning(
- `Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
+ `Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
);
continue;
}
if (base.lines.join('\n') !== head.lines.join('\n')) {
violations.push({
- file: file.filename,
+ file: file.basePath === file.headPath
+ ? file.headPath
+ : `${file.basePath} โ ${file.headPath}`,
base: base.lines,
head: head.lines,
});
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index f9eb9adf5..c5fa4e9f9 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -310,8 +310,12 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
// PeerStateByIP returns the full peer State for the given tunnel IP.
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
-// address so dual-stack peers are reachable on either family. Returns the
-// zero State and false when no peer matches or the input is empty.
+// address so dual-stack peers are reachable on either family. Searches
+// both d.peers and d.offlinePeers โ peers that have been moved into
+// the offline slice by ReplaceOfflinePeers are still part of the
+// account's roster and callers (DNS filter, embed.Client.IdentityForIP)
+// need to recognise them rather than treating them as unknown. Returns
+// the zero State and false when no peer matches or the input is empty.
func (d *Status) PeerStateByIP(ip string) (State, bool) {
if ip == "" {
return State{}, false
@@ -324,6 +328,11 @@ func (d *Status) PeerStateByIP(ip string) (State, bool) {
return state, true
}
}
+ for _, state := range d.offlinePeers {
+ if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
+ return state, true
+ }
+ }
return State{}, false
}
diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go
index 8d889b0ae..97fb32c03 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -90,6 +90,28 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
}
+// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
+// been moved into the offline slice via ReplaceOfflinePeers. Callers
+// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
+// rather than unknown โ otherwise authentication / DNS filtering treats
+// known-but-offline peers as foreign IPs.
+func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ status.ReplaceOfflinePeers([]State{
+ {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
+ })
+
+ state, ok := status.PeerStateByIP("100.64.0.20")
+ req.True(ok, "offline peer must resolve by IPv4 tunnel address")
+ req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
+
+ state, ok = status.PeerStateByIP("fd00::20")
+ req.True(ok, "offline peer must resolve by IPv6 tunnel address")
+ req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
+}
+
func TestStatus_UpdatePeerFQDN(t *testing.T) {
key := "abc"
fqdn := "peer-a.netbird.local"
diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go
index 27f6d914d..ee1e3c8b2 100644
--- a/management/internals/modules/reverseproxy/service/service.go
+++ b/management/internals/modules/reverseproxy/service/service.go
@@ -932,7 +932,11 @@ func (s *Service) validateL4Target(target *Target) error {
if target.TargetId == "" {
return errors.New("target_id is required for L4 services")
}
- if target.TargetType != TargetTypeCluster && target.Port == 0 {
+ // Cluster targets resolve their upstream host:port from the target's
+ // own Host/Port fields just like the other L4 types โ buildPathMappings
+ // emits net.JoinHostPort(target.Host, target.Port) for every L4
+ // target, so allowing port=0 here would let ":0" reach the proxy.
+ if target.Port == 0 {
return errors.New("target port is required for L4 services")
}
switch target.TargetType {
diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go
index ba63d76ed..a149ac609 100644
--- a/management/internals/modules/reverseproxy/service/service_test.go
+++ b/management/internals/modules/reverseproxy/service/service_test.go
@@ -1176,7 +1176,12 @@ func TestValidate_HTTPClusterTarget_RequiresDirectUpstream(t *testing.T) {
assert.ErrorContains(t, rp.Validate(), "direct upstream disabled", "cluster target must reject direct_upstream=false")
}
-func TestValidate_L4ClusterTarget(t *testing.T) {
+// TestValidate_L4ClusterTarget_RequiresPort confirms that an L4 cluster
+// target without an explicit port is rejected. buildPathMappings emits
+// net.JoinHostPort(target.Host, target.Port) for every L4 target โ so
+// allowing port=0 would let the proxy ship ":0" upstreams. The port
+// requirement is the same as every other L4 target type.
+func TestValidate_L4ClusterTarget_RequiresPort(t *testing.T) {
rp := validProxy()
rp.Mode = ModeTCP
rp.ListenPort = 9000
@@ -1186,7 +1191,12 @@ func TestValidate_L4ClusterTarget(t *testing.T) {
Protocol: "tcp",
Enabled: true,
}}
- require.NoError(t, rp.Validate(), "L4 cluster target must validate without an explicit port")
+ assert.ErrorContains(t, rp.Validate(), "port is required",
+ "L4 cluster target must require an explicit port like other L4 target types")
+
+ rp.Targets[0].Port = 5432
+ rp.Targets[0].Host = "db.lan"
+ require.NoError(t, rp.Validate(), "L4 cluster target with host:port must validate")
}
func TestService_Copy_RoundtripsPrivate(t *testing.T) {
diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go
index 1dc2dac28..27d9a65e7 100644
--- a/management/internals/shared/grpc/validate_session_test.go
+++ b/management/internals/shared/grpc/validate_session_test.go
@@ -102,7 +102,7 @@ func generateSessionKeyPair(t *testing.T) (string, string) {
func createSessionToken(t *testing.T, privKeyB64, userID, domain string) string {
t.Helper()
- token, err := sessionkey.SignToken(privKeyB64, userID, domain, auth.MethodOIDC, nil, time.Hour)
+ token, err := sessionkey.SignToken(privKeyB64, userID, "", domain, auth.MethodOIDC, nil, nil, time.Hour)
require.NoError(t, err)
return token
}
@@ -394,6 +394,10 @@ func (m *testValidateSessionProxyManager) ClusterSupportsCrowdSec(_ context.Cont
return nil
}
+func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
+ return nil
+}
+
type testValidateSessionUsersManager struct {
store store.Store
}
@@ -401,3 +405,24 @@ type testValidateSessionUsersManager struct {
func (m *testValidateSessionUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) {
return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
}
+
+func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
+ user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(user.AutoGroups) == 0 {
+ return user, nil, nil
+ }
+ groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups)
+ if err != nil {
+ return nil, nil, err
+ }
+ groups := make([]*types.Group, 0, len(user.AutoGroups))
+ for _, id := range user.AutoGroups {
+ if g, ok := groupsMap[id]; ok && g != nil {
+ groups = append(groups, g)
+ }
+ }
+ return user, groups, nil
+}
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index d8c27fb5c..b6691ac79 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -4734,7 +4734,13 @@ func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength
result := tx.
Take(&peer, fmt.Sprintf("account_id = ? AND %s = ?", column), accountID, jsonValue)
if result.Error != nil {
- // no logging here
+ // A tunnel-IP miss is an expected outcome (e.g. the proxy's
+ // ValidateTunnelPeer probing an address that isn't in the
+ // account roster); surface it as NotFound so callers can tell
+ // it apart from a real store failure.
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ return nil, status.Errorf(status.NotFound, "peer with ip %s not found", ip.String())
+ }
return nil, status.Errorf(status.Internal, "failed to get peer from store")
}
@@ -5962,6 +5968,7 @@ func (s *SqlStore) getClusterCapability(ctx context.Context, clusterAddr, column
}
err := s.db.
+ WithContext(ctx).
Model(&proxy.Proxy{}).
Select("COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) > 0 AS has_capability, "+
"COALESCE(MAX(CASE WHEN "+column+" = true THEN 1 ELSE 0 END), 0) = 1 AS any_true").
diff --git a/management/server/store/sql_store_service_test.go b/management/server/store/sql_store_service_test.go
index 0978440c6..34999da4b 100644
--- a/management/server/store/sql_store_service_test.go
+++ b/management/server/store/sql_store_service_test.go
@@ -13,7 +13,7 @@ import (
)
func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) {
- if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
+ if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
t.Skip("skip CI tests on darwin and windows")
}
diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go
index 41e3290b6..0c90eaf5f 100644
--- a/management/server/store/sql_store_test.go
+++ b/management/server/store/sql_store_test.go
@@ -491,6 +491,27 @@ func Test_GetAccount(t *testing.T) {
})
}
+// TestSqlStore_GetPeerByIP_NotFound pins the not-found semantics the
+// proxy's ValidateTunnelPeer relies on: a tunnel-IP that isn't in the
+// account roster must surface as a NotFound error (not a generic
+// Internal) so callers can distinguish an expected miss from a real
+// store failure. A known IP still resolves.
+func TestSqlStore_GetPeerByIP_NotFound(t *testing.T) {
+ runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) {
+ const accountID = "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ peer, err := store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("192.168.0.0"))
+ require.NoError(t, err, "known tunnel IP must resolve")
+ require.NotNil(t, peer)
+
+ _, err = store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("100.65.0.99"))
+ require.Error(t, err, "unknown tunnel IP must error")
+ parsedErr, ok := status.FromError(err)
+ require.True(t, ok, "error must be a status error")
+ require.Equal(t, status.NotFound, parsedErr.Type(), "tunnel-IP miss must be NotFound, not Internal")
+ })
+}
+
func TestSqlStore_SavePeer(t *testing.T) {
store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanUp)
diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go
index 405fa2789..d0e11517e 100644
--- a/proxy/cmd/proxy/cmd/root.go
+++ b/proxy/cmd/proxy/cmd/root.go
@@ -214,7 +214,10 @@ func runServer(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid --trusted-proxies: %w", err)
}
- srv := proxy.New(proxy.Config{
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
+ defer stop()
+
+ srv := proxy.New(ctx, proxy.Config{
ListenAddr: addr,
Logger: logger,
Version: Version,
@@ -251,9 +254,6 @@ func runServer(cmd *cobra.Command, args []string) error {
CrowdSecAPIKey: crowdsecAPIKey,
})
- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
- defer stop()
-
return srv.ListenAndServe(ctx, addr)
}
diff --git a/proxy/inbound.go b/proxy/inbound.go
index 8165b331f..d729ba9ae 100644
--- a/proxy/inbound.go
+++ b/proxy/inbound.go
@@ -5,6 +5,7 @@ import (
"crypto/tls"
"errors"
"fmt"
+ "io"
stdlog "log"
"net"
"net/http"
@@ -42,7 +43,7 @@ const privateInboundPortHTTPS = 443
const privateInboundPortHTTP = 80
// inboundManager wires per-account inbound listeners into the proxy
-// pipeline when --private-inbound is enabled. When disabled the manager
+// pipeline when --private is enabled. When disabled the manager
// is nil and every method on *Server that touches it short-circuits.
type inboundManager struct {
logger *log.Logger
@@ -55,15 +56,18 @@ type inboundManager struct {
}
// inboundEntry owns the listeners, router and HTTP servers for a single
-// account's embedded netstack.
+// account's embedded netstack. errorLogWriters retain the logrus pipe
+// writers backing each http.Server's ErrorLog so tearDown can close
+// them โ otherwise the pipe + its scanner goroutine leak per account.
type inboundEntry struct {
- router *nbtcp.Router
- tlsListener net.Listener
- plainListener net.Listener
- httpsServer *http.Server
- httpServer *http.Server
- cancel context.CancelFunc
- wg sync.WaitGroup
+ router *nbtcp.Router
+ tlsListener net.Listener
+ plainListener net.Listener
+ httpsServer *http.Server
+ httpServer *http.Server
+ errorLogWriters []*io.PipeWriter
+ cancel context.CancelFunc
+ wg sync.WaitGroup
}
// pendingInboundRoute holds a route that arrived before the account's
@@ -147,30 +151,34 @@ func (m *inboundManager) bringUp(ctx context.Context, accountID types.AccountID,
return types.WithOverlayOrigin(ctx)
}
+ httpsErrLog, httpsErrW := newInboundErrorLog(m.logger, "https", accountID)
+ httpErrLog, httpErrW := newInboundErrorLog(m.logger, "http", accountID)
+
httpsServer := &http.Server{
Handler: scopedHandler,
TLSConfig: m.tlsConfig,
ReadHeaderTimeout: httpInboundReadHeaderTimeout,
IdleTimeout: httpInboundIdleTimeout,
- ErrorLog: newInboundErrorLog(m.logger, "https", accountID),
+ ErrorLog: httpsErrLog,
ConnContext: markOverlayOrigin,
}
httpServer := &http.Server{
Handler: scopedHandler,
ReadHeaderTimeout: httpInboundReadHeaderTimeout,
IdleTimeout: httpInboundIdleTimeout,
- ErrorLog: newInboundErrorLog(m.logger, "http", accountID),
+ ErrorLog: httpErrLog,
ConnContext: markOverlayOrigin,
}
runCtx, cancel := context.WithCancel(ctx)
entry := &inboundEntry{
- router: router,
- tlsListener: tlsListener,
- plainListener: plainListener,
- httpsServer: httpsServer,
- httpServer: httpServer,
- cancel: cancel,
+ router: router,
+ tlsListener: tlsListener,
+ plainListener: plainListener,
+ httpsServer: httpsServer,
+ httpServer: httpServer,
+ errorLogWriters: []*io.PipeWriter{httpsErrW, httpErrW},
+ cancel: cancel,
}
entry.wg.Add(1)
@@ -237,6 +245,14 @@ func (m *inboundManager) tearDown(accountID types.AccountID, entry *inboundEntry
m.logger.Debugf("close per-account plain listener: %v", err)
}
entry.wg.Wait()
+ // Close the ErrorLog pipes only after the http.Servers have fully
+ // stopped so any straggling stdlib write doesn't race with the
+ // close. Each writer also tears down the logrus scanner goroutine.
+ for _, w := range entry.errorLogWriters {
+ if err := w.Close(); err != nil {
+ m.logger.Debugf("close per-account inbound error log writer: %v", err)
+ }
+ }
}
// AddRoute records an SNI/host route on the account's per-account router.
@@ -374,7 +390,7 @@ func (m *inboundManager) ListenerInfo(accountID types.AccountID) (InboundListene
}
// Snapshot returns the inbound listener state for every account that has
-// a live listener at call time. Empty when --private-inbound is off or
+// a live listener at call time. Empty when --private is off or
// no accounts have come up yet.
func (m *inboundManager) Snapshot() map[types.AccountID]InboundListenerInfo {
if m == nil {
@@ -497,7 +513,7 @@ func accountTunnelLookup(client *embed.Client) auth.TunnelLookupFunc {
// peerstore lookup to every request's context before delegating to next.
// Calling on the host-level listener is a no-op because that path never
// installs this wrapper, so the existing behaviour stays byte-for-byte
-// identical when --private-inbound is off or the request didn't arrive
+// identical when --private is off or the request didn't arrive
// on a per-account listener.
func withTunnelLookup(next http.Handler, lookup auth.TunnelLookupFunc) http.Handler {
if lookup == nil {
@@ -538,10 +554,14 @@ func (a inboundDebugAdapter) InboundListeners() map[types.AccountID]debug.Inboun
}
// newInboundErrorLog routes a per-account http.Server's stdlib error
-// stream through logrus at warn level.
-func newInboundErrorLog(logger *log.Logger, scheme string, accountID types.AccountID) *stdlog.Logger {
- return stdlog.New(logger.WithFields(log.Fields{
+// stream through logrus at warn level. The returned PipeWriter must be
+// closed by the caller (tearDown) once the http.Server has shut down โ
+// otherwise the pipe and its scanner goroutine leak per account, see
+// logrus.Entry.WriterLevel.
+func newInboundErrorLog(logger *log.Logger, scheme string, accountID types.AccountID) (*stdlog.Logger, *io.PipeWriter) {
+ w := logger.WithFields(log.Fields{
"inbound-http": scheme,
"account_id": accountID,
- }).WriterLevel(log.WarnLevel), "", 0)
+ }).WriterLevel(log.WarnLevel)
+ return stdlog.New(w, "", 0), w
}
diff --git a/proxy/inbound_test.go b/proxy/inbound_test.go
index a868f1c12..584a04238 100644
--- a/proxy/inbound_test.go
+++ b/proxy/inbound_test.go
@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"crypto/tls"
+ "io"
"net"
"net/http"
"net/http/httptest"
@@ -110,7 +111,7 @@ func TestServer_PrivateInbound_Enabled_WiresLifecycle(t *testing.T) {
// Construct a NetBird transport. We can't actually start the embedded
// client here (that needs a real management server), but we can
// confirm that the lifecycle callbacks are registered.
- s.netbird = roundtrip.NewNetBird("test", "test", roundtrip.ClientConfig{
+ s.netbird = roundtrip.NewNetBird(t.Context(), "test", "test", roundtrip.ClientConfig{
MgmtAddr: "http://invalid.test",
}, quietLogger(), nil, fakeMgmtClient{})
@@ -139,7 +140,7 @@ func TestInboundManager_AddRouteAfterReady_RegistersDirectly(t *testing.T) {
// TestPrivateCapability_DerivedFromPrivateOnly tests that the capability
// bit reported upstream tracks --private exclusively. The previous
-// --private-inbound flag has been folded into --private.
+// --private flag has been folded into --private.
func TestPrivateCapability_DerivedFromPrivateOnly(t *testing.T) {
tests := []struct {
name string
@@ -318,7 +319,7 @@ func TestInboundManager_ListenerInfo(t *testing.T) {
}
// TestInboundManager_NilManagerSafe ensures the observability accessors
-// are safe to call when --private-inbound is off (nil manager).
+// are safe to call when --private is off (nil manager).
func TestInboundManager_NilManagerSafe(t *testing.T) {
var mgr *inboundManager
_, ok := mgr.ListenerInfo("anything")
@@ -482,6 +483,38 @@ func selfSignedTLSConfig(t *testing.T) *tls.Config {
return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12} //nolint:gosec
}
+// TestNewInboundErrorLog_WriterIsCloseable guards the close path on the
+// logrus PipeWriter that backs each per-account http.Server's ErrorLog.
+// logrus.Entry.WriterLevel returns an *io.PipeWriter that owns a pipe +
+// scanner goroutine; the caller must Close() it on teardown or the
+// resources leak per account. The contract is verified two ways:
+//
+// - the constructor returns a non-nil writer the caller can keep,
+// - writing to the writer after Close() fails with io.ErrClosedPipe,
+// which is the only externally observable sign that Close was wired.
+//
+// A leaking refactor (forgetting to thread the writer to tearDown, or
+// dropping the Close call) would still pass this test individually but
+// fail an integration goleak check; this unit test is the cheap first
+// line of defence.
+func TestNewInboundErrorLog_WriterIsCloseable(t *testing.T) {
+ logger := quietLogger()
+ stdLog, writer := newInboundErrorLog(logger, "https", types.AccountID("acct-1"))
+
+ require.NotNil(t, stdLog, "newInboundErrorLog must return a non-nil *log.Logger")
+ require.NotNil(t, writer, "newInboundErrorLog must return the underlying PipeWriter so tearDown can Close it")
+
+ // First Close succeeds.
+ require.NoError(t, writer.Close(), "PipeWriter.Close should succeed the first time")
+
+ // After Close, the writer must refuse new writes โ that's the only
+ // behavioural signal that the pipe (and its scanner goroutine) has
+ // shut down.
+ _, err := writer.Write([]byte("post-close write\n"))
+ require.ErrorIs(t, err, io.ErrClosedPipe,
+ "writes after Close must surface io.ErrClosedPipe so callers know the writer is gone")
+}
+
// testCertPEM / testKeyPEM are a minimal RSA self-signed cert for
// 127.0.0.1 โ only used by tests that need a working TLS handshake.
var testCertPEM = []byte(`-----BEGIN CERTIFICATE-----
diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go
index a76427ca0..72630b085 100644
--- a/proxy/internal/auth/middleware.go
+++ b/proxy/internal/auth/middleware.go
@@ -346,13 +346,15 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re
// management unreachable, peer unknown, user not in group) returns false so
// the caller falls back to the existing OIDC scheme dispatch.
//
-// Phase 3 adds a local-first short-circuit: when the request arrived on a
-// per-account inbound listener the context carries a peerstore lookup
-// (TunnelLookupFromContext). If the lookup says the IP isn't in the account's
-// roster the proxy denies fast without calling management. If the lookup
-// confirms a known peer the RPC still runs for the user-identity tail
-// (UserID + group access), but its result is cached for tunnelCacheTTL so
-// repeat requests skip management entirely.
+// The fast-path is gated on TunnelLookupFromContext(r.Context()) being
+// present โ that context value is attached only by the per-account
+// inbound (overlay) listener. The host listener never sets it, so a
+// public client whose source IP happens to fall inside an RFC1918 / ULA
+// / CGNAT range can't impersonate a mesh peer by colliding with a
+// tunnel-IP. Once we know the request arrived over WireGuard the
+// per-account peerstore lookup is consulted: a miss denies fast (no
+// management round-trip), a hit gates the cached ValidateTunnelPeer RPC
+// that mints the session JWT.
func (mw *Middleware) forwardWithTunnelPeer(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
if mw.sessionValidator == nil {
return false
@@ -361,18 +363,24 @@ func (mw *Middleware) forwardWithTunnelPeer(w http.ResponseWriter, r *http.Reque
if !clientIP.IsValid() {
return false
}
+
+ // Anti-spoof: only honour the tunnel-peer fast-path on requests that
+ // were stamped by an overlay listener. Without that marker an
+ // attacker could send a request from a colliding RFC1918 / CGNAT
+ // source on the public listener and bypass operator auth.
+ lookup := TunnelLookupFromContext(r.Context())
+ if lookup == nil {
+ return false
+ }
if !isTunnelSourceIP(clientIP) {
return false
}
-
- if lookup := TunnelLookupFromContext(r.Context()); lookup != nil {
- if _, ok := lookup(clientIP); !ok {
- mw.logger.WithFields(log.Fields{
- "host": host,
- "remote": clientIP,
- }).Debug("local peerstore: tunnel IP not in account roster; denying without RPC")
- return false
- }
+ if _, ok := lookup(clientIP); !ok {
+ mw.logger.WithFields(log.Fields{
+ "host": host,
+ "remote": clientIP,
+ }).Debug("local peerstore: tunnel IP not in account roster; denying without RPC")
+ return false
}
resp, _, err := mw.tunnelCache.fetch(r.Context(), tunnelCacheKey{
diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go
index 84c319446..c0ec5c94c 100644
--- a/proxy/internal/auth/middleware_test.go
+++ b/proxy/internal/auth/middleware_test.go
@@ -1227,3 +1227,93 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rec.Code, "PIN-only domain should serve the login page on plain HTTP")
}
+
+// stubTunnelValidator records ValidateTunnelPeer calls so a test can
+// assert whether the fast-path reached management.
+type stubTunnelValidator struct {
+ called bool
+ resp *proto.ValidateTunnelPeerResponse
+}
+
+func (s *stubTunnelValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) {
+ return nil, errors.New("not used in this test")
+}
+
+func (s *stubTunnelValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) {
+ s.called = true
+ return s.resp, nil
+}
+
+// TestProtect_TunnelPeerFastPath_RequiresInboundMarker guards the
+// anti-spoof gate: a request with an RFC1918 source IP arriving on the
+// public listener (no TunnelLookupFromContext attached) must not be
+// allowed to take the tunnel-peer fast-path. Without this gate a public
+// client whose source IP happens to fall inside an RFC1918 range could
+// bypass the configured auth scheme by colliding with a known tunnel
+// IP.
+func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) {
+ validator := &stubTunnelValidator{
+ resp: &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "should-not-be-used",
+ UserId: "user-1",
+ },
+ }
+ mw := NewMiddleware(log.StandardLogger(), validator, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ // Request from an RFC1918 source IP on the public listener โ no
+ // TunnelLookupFromContext attached. The fast-path must reject this
+ // and fall through to the PIN scheme (which renders 401 on plain
+ // HTTP for a non-authenticated request).
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.64.0.5:5000"
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.False(t, validator.called,
+ "ValidateTunnelPeer must not be invoked when the request lacks the inbound TunnelLookup marker")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code,
+ "without the inbound marker the request must fall through to the operator auth scheme")
+}
+
+// TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker verifies
+// the positive side: a request marked as overlay-origin (carrying the
+// TunnelLookup context value) and matching a tunnel-IP range does take
+// the fast-path and reach management.
+func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
+ validator := &stubTunnelValidator{
+ resp: &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "tunnel-session-token",
+ UserId: "user-1",
+ },
+ }
+ mw := NewMiddleware(log.StandardLogger(), validator, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.64.0.5:5000"
+ req = req.WithContext(WithTunnelLookup(req.Context(), lookup))
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.True(t, validator.called,
+ "ValidateTunnelPeer must run when the request carries the inbound TunnelLookup marker")
+ assert.Equal(t, http.StatusOK, rec.Code,
+ "a successful tunnel-peer validation must forward to the next handler")
+}
diff --git a/proxy/internal/auth/tunnel_lookup_test.go b/proxy/internal/auth/tunnel_lookup_test.go
index cc8081af2..808aa8b41 100644
--- a/proxy/internal/auth/tunnel_lookup_test.go
+++ b/proxy/internal/auth/tunnel_lookup_test.go
@@ -101,7 +101,10 @@ func TestForwardWithTunnelPeer_GroupsPropagateToCapturedData(t *testing.T) {
w, r := newTunnelRequest("100.64.0.10:55555")
cd := proxy.NewCapturedData("")
- r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+ r = r.WithContext(proxy.WithCapturedData(WithTunnelLookup(r.Context(), lookup), cd))
called := false
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
@@ -148,9 +151,13 @@ func TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs(t *testing.T) {
assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC must run for the user-identity tail when local lookup confirms the peer")
}
-// TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath ensures the existing
-// behaviour stays intact on the host-level listener (no lookup attached).
-func TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath(t *testing.T) {
+// TestForwardWithTunnelPeer_NoLookupRefusesFastPath guards the
+// anti-spoof gate: requests that didn't arrive on the per-account
+// inbound listener (no TunnelLookup attached) must never reach
+// management's ValidateTunnelPeer, even when the source IP looks like
+// a tunnel address. A colliding RFC1918 / CGNAT source on the public
+// listener would otherwise impersonate a mesh peer.
+func TestForwardWithTunnelPeer_NoLookupRefusesFastPath(t *testing.T) {
validator := &stubSessionValidator{
respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
@@ -165,9 +172,9 @@ func TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath(t *testing.T) {
config, _ := mw.getDomainConfig("svc.example")
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
- assert.True(t, handled, "host-level path forwards on positive RPC result")
- assert.True(t, called, "next handler runs on host-level success")
- assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "host-level path always RPCs (Phase 3 unchanged)")
+ assert.False(t, handled, "fast-path must refuse without the inbound marker")
+ assert.False(t, called, "next handler must not run")
+ assert.Equal(t, int32(0), validator.tunnelCalls.Load(), "ValidateTunnelPeer must not be invoked without the inbound marker")
}
// TestForwardWithTunnelPeer_RPCErrorFallsThrough validates that an RPC
@@ -201,8 +208,13 @@ func TestForwardWithTunnelPeer_CacheReusesPositiveResponse(t *testing.T) {
}
mw := newTunnelMiddleware(t, validator)
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
for i := 0; i < 4; i++ {
w, r := newTunnelRequest("100.64.0.10:55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
config, _ := mw.getDomainConfig("svc.example")
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
@@ -226,11 +238,21 @@ func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
+ // The fast-path requires the inbound-listener marker on the context.
+ // The peerstore lookup itself is account-agnostic at this level
+ // (one TunnelLookupFunc per account is attached by inbound.go); a
+ // trivial "always hit" lookup is enough to exercise the cache-key
+ // branch this test covers.
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
for _, host := range []string{"svc-a.example", "svc-b.example"} {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "https://"+host+"/", nil)
r.Host = host
r.RemoteAddr = "100.64.0.10:55555"
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
config, _ := mw.getDomainConfig(host)
handled := mw.forwardWithTunnelPeer(w, r, host, config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
require.True(t, handled, "host %s should forward", host)
@@ -314,9 +336,17 @@ func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
w.WriteHeader(http.StatusOK)
}))
+ // Per-account inbound listener attaches WithTunnelLookup; without it
+ // forwardWithTunnelPeer refuses to take the fast-path. Mirror the
+ // real flow so this test exercises the post-gating success branch.
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil)
req.Host = "private.svc"
req.RemoteAddr = "100.64.0.10:55555"
+ req = req.WithContext(WithTunnelLookup(req.Context(), lookup))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go
index 826c6817f..6300228d7 100644
--- a/proxy/internal/debug/handler.go
+++ b/proxy/internal/debug/handler.go
@@ -131,7 +131,7 @@ func (h *Handler) SetCertStatus(cs certStatus) {
// SetInboundProvider wires per-account inbound listener observability.
// Pass nil (or skip the call) to keep the inbound section out of debug
-// responses on proxies that don't run --private-inbound.
+// responses on proxies that don't run --private.
func (h *Handler) SetInboundProvider(p InboundProvider) {
h.inbound = p
}
diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go
index e437e78a7..da0bf6552 100644
--- a/proxy/internal/proxy/reverseproxy.go
+++ b/proxy/internal/proxy/reverseproxy.go
@@ -66,6 +66,22 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
+ // Loop guard for private services: a peer that hosts the target
+ // dialing its own service URL would round-trip its own traffic
+ // through the proxy and back over WG to itself. Refuse the request
+ // with 421 (Misdirected Request) so the caller sees an explicit
+ // error instead of silently doubling tunnel traffic.
+ if p.isSelfTargetLoop(r, result.target.URL) {
+ if cd := CapturedDataFromContext(r.Context()); cd != nil {
+ cd.SetOrigin(OriginNoRoute)
+ }
+ requestID := getRequestID(r)
+ web.ServeErrorPage(w, r, http.StatusMisdirectedRequest, "Loop Detected",
+ "This peer is the target of the requested service. Reach the backend directly instead of dialing the public service URL from the same machine.",
+ requestID, web.ErrorStatus{Proxy: true, Destination: false})
+ return
+ }
+
ctx := r.Context()
// Set the account ID in the context for the roundtripper to use.
ctx = roundtrip.WithAccountID(ctx, result.accountID)
@@ -107,6 +123,32 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rp.ServeHTTP(w, r.WithContext(ctx))
}
+// isSelfTargetLoop reports whether an overlay-origin request is about to
+// be forwarded back to the very peer that initiated it. The detection
+// is intentionally narrow: it only fires when the request arrived on
+// the per-account inbound (overlay) listener (so we're confident the
+// source address is the caller's tunnel IP), and only when the resolved
+// target host matches that tunnel IP. Catching this here returns 421 to
+// the caller instead of letting the proxy round-trip its own traffic
+// over WG twice.
+func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool {
+ if target == nil {
+ return false
+ }
+ if !types.IsOverlayOrigin(r.Context()) {
+ return false
+ }
+ srcIP := extractHostIP(r.RemoteAddr)
+ if !srcIP.IsValid() {
+ return false
+ }
+ targetIP, err := netip.ParseAddr(target.Hostname())
+ if err != nil {
+ return false
+ }
+ return srcIP.Unmap() == targetIP.Unmap()
+}
+
// rewriteFunc returns a Rewrite function for httputil.ReverseProxy that rewrites
// inbound requests to target the backend service while setting security-relevant
// forwarding headers and stripping proxy authentication credentials.
diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go
index d5158a6cc..a8244fa56 100644
--- a/proxy/internal/proxy/reverseproxy_test.go
+++ b/proxy/internal/proxy/reverseproxy_test.go
@@ -20,6 +20,7 @@ import (
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/proxy/web"
)
@@ -1285,6 +1286,103 @@ func TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid(t *testing.T) {
"X-NetBird-Groups must not be set when every group label is rejected")
}
+// nopOKTransport returns 200 for every request without dialing โ used
+// by the self-target-loop tests so the non-loop cases don't pay a real
+// TCP-dial timeout.
+type nopOKTransport struct{}
+
+func (nopOKTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: http.Header{}}, nil
+}
+
+// TestServeHTTP_SelfTargetLoopReturns421 covers the loop guard for
+// private services: when a peer dials a service whose only target is
+// the peer itself, the proxy must refuse with 421 (Misdirected
+// Request) rather than round-tripping the request back over WG to
+// the same peer.
+func TestServeHTTP_SelfTargetLoopReturns421(t *testing.T) {
+ rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
+ rp.AddMapping(Mapping{
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Host: "private.svc",
+ Paths: map[string]*PathTarget{
+ "/": {
+ URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
+ },
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.5:55555"
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rec := httptest.NewRecorder()
+
+ rp.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusMisdirectedRequest, rec.Code,
+ "a peer dialing a service whose target is itself must get 421")
+}
+
+// TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough verifies
+// the guard is scoped to overlay-origin requests. A public-listener
+// request that happens to share a source IP with the target host must
+// not be misinterpreted as a loop โ the gating relies on the inbound
+// marker being attached only by the per-account overlay listener.
+func TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough(t *testing.T) {
+ rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
+ rp.AddMapping(Mapping{
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Host: "public.svc",
+ Paths: map[string]*PathTarget{
+ "/": {
+ URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
+ },
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://public.svc/", nil)
+ req.Host = "public.svc"
+ req.RemoteAddr = "100.64.0.5:55555"
+ // No WithOverlayOrigin โ the guard must not fire.
+ rec := httptest.NewRecorder()
+
+ rp.ServeHTTP(rec, req)
+
+ assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code,
+ "a non-overlay request with a colliding source IP must not be flagged as a loop")
+}
+
+// TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough confirms
+// that overlay-origin requests with a source IP that does *not* match
+// the target host are forwarded normally.
+func TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough(t *testing.T) {
+ rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
+ rp.AddMapping(Mapping{
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Host: "private.svc",
+ Paths: map[string]*PathTarget{
+ "/": {
+ URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
+ },
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.99:55555" // different from the target
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rec := httptest.NewRecorder()
+
+ rp.ServeHTTP(rec, req)
+
+ assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code,
+ "overlay request with a non-matching source IP must not be flagged as a loop")
+}
+
// TestStampNetBirdIdentity_CapturedDataPresentButEmpty covers requests
// that carry CapturedData with no identity fields populated (e.g. the
// auth middleware ran but the request didn't authenticate). Both
diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go
index 11bca22e3..1d1e68f4a 100644
--- a/proxy/internal/roundtrip/netbird.go
+++ b/proxy/internal/roundtrip/netbird.go
@@ -152,6 +152,7 @@ type managementClient interface {
// backed by underlying NetBird connections.
// Clients are keyed by AccountID, allowing multiple services to share the same connection.
type NetBird struct {
+ ctx context.Context
proxyID string
proxyAddr string
clientCfg ClientConfig
@@ -213,7 +214,11 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
}).Debug("registered service with existing client")
if started && n.statusNotifier != nil {
- if err := n.statusNotifier.NotifyStatus(ctx, accountID, serviceID, true); err != nil {
+ // Use a background context, not the caller's: the management
+ // connection notification must land even if the request /
+ // stream that triggered this registration is cancelled.
+ // Mirrors the async runClientStartup path.
+ if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, serviceID, true); err != nil {
n.logger.WithFields(log.Fields{
"account_id": accountID,
"service_key": key,
@@ -242,8 +247,10 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
}).Info("created new client for account")
// Attempt to start the client in the background; if this fails we will
- // retry on the first request via RoundTrip.
- go n.runClientStartup(ctx, accountID, entry.client)
+ // retry on the first request via RoundTrip. runClientStartup uses its
+ // own background context so the caller's request-scoped ctx can't
+ // cancel the inbound bring-up.
+ go n.runClientStartup(accountID, entry.client)
return nil
}
@@ -307,7 +314,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
ManagementURL: n.clientCfg.MgmtAddr,
PrivateKey: privateKey.String(),
LogLevel: log.WarnLevel.String(),
- BlockInbound: n.clientCfg.BlockInbound,
+ BlockInbound: n.clientCfg.BlockInbound,
// The embedded proxy peer must never be a stepping stone into
// the proxy host's LAN: it only exists to reach NetBird mesh
// targets or, when direct_upstream is set, the host network
@@ -355,8 +362,14 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
}, nil
}
-// runClientStartup starts the client and notifies registered services on success.
-func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountID, client *embed.Client) {
+// runClientStartup starts the client and notifies registered services on
+// success. This function runs in a goroutine launched from AddPeer, so it
+// must never inherit the caller's request-scoped context โ a canceled
+// request must not abort the inbound listener bring-up or the management
+// status notification. The embedded client.Start gets its own bounded
+// startCtx; once Start succeeds, notifyClientReady takes over with a
+// fresh context.Background() (see that function for the contract).
+func (n *NetBird) runClientStartup(accountID types.AccountID, client *embed.Client) {
startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -369,7 +382,17 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
return
}
- // Mark client as started and collect services to notify outside the lock.
+ n.notifyClientReady(accountID, client)
+}
+
+// notifyClientReady marks the account's client as started, fires the
+// readyHandler hook, and notifies management of the new tunnel
+// connection for every registered service. It is split out of
+// runClientStartup so a regression test can drive the post-Start tail
+// without needing a live embedded client. The contract that the
+// hooks/notifier see context.Background() โ never the AddPeer caller's
+// ctx โ lives here.
+func (n *NetBird) notifyClientReady(accountID types.AccountID, client *embed.Client) {
n.clientsMux.Lock()
entry, exists := n.clients[accountID]
if exists {
@@ -385,7 +408,7 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
n.clientsMux.Unlock()
if readyHandler != nil {
- state := readyHandler(ctx, accountID, client)
+ state := readyHandler(n.ctx, accountID, client)
n.clientsMux.Lock()
if e, ok := n.clients[accountID]; ok {
e.inbound = state
@@ -404,7 +427,7 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
return
}
for _, sn := range toNotify {
- if err := n.statusNotifier.NotifyStatus(ctx, accountID, sn.serviceID, true); err != nil {
+ if err := n.statusNotifier.NotifyStatus(n.ctx, accountID, sn.serviceID, true); err != nil {
n.logger.WithFields(log.Fields{
"account_id": accountID,
"service_key": sn.key,
@@ -666,11 +689,12 @@ func (n *NetBird) ListClientsForStartup() map[types.AccountID]*embed.Client {
// NewNetBird creates a new NetBird transport. Set clientCfg.WGPort to 0 for a random
// OS-assigned port. A fixed port only works with single-account deployments;
// multiple accounts will fail to bind the same port.
-func NewNetBird(proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.Logger, notifier statusNotifier, mgmtClient managementClient) *NetBird {
+func NewNetBird(ctx context.Context, proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.Logger, notifier statusNotifier, mgmtClient managementClient) *NetBird {
if logger == nil {
logger = log.StandardLogger()
}
return &NetBird{
+ ctx: ctx,
proxyID: proxyID,
proxyAddr: proxyAddr,
clientCfg: clientCfg,
diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go
index 3f3e4138a..b1c36b465 100644
--- a/proxy/internal/roundtrip/netbird_test.go
+++ b/proxy/internal/roundtrip/netbird_test.go
@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
+ "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -30,12 +31,15 @@ type statusCall struct {
accountID types.AccountID
serviceID types.ServiceID
connected bool
+ // ctx is captured so tests can assert the notifier received a
+ // fresh background context rather than an inherited request ctx.
+ ctx context.Context
}
-func (m *mockStatusNotifier) NotifyStatus(_ context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error {
+func (m *mockStatusNotifier) NotifyStatus(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error {
m.mu.Lock()
defer m.mu.Unlock()
- m.statuses = append(m.statuses, statusCall{accountID, serviceID, connected})
+ m.statuses = append(m.statuses, statusCall{accountID, serviceID, connected, ctx})
return nil
}
@@ -48,7 +52,7 @@ func (m *mockStatusNotifier) calls() []statusCall {
// mockNetBird creates a NetBird instance for testing without actually connecting.
// It uses an invalid management URL to prevent real connections.
func mockNetBird() *NetBird {
- return NewNetBird("test-proxy", "invalid.test", ClientConfig{
+ return NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
@@ -279,7 +283,7 @@ func TestNetBird_RoundTrip_RequiresExistingClient(t *testing.T) {
func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
notifier := &mockStatusNotifier{}
- nb := NewNetBird("test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
@@ -295,8 +299,12 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
nb.clients[accountID].started = true
nb.clientsMux.Unlock()
- // Add second service โ should notify immediately since client is already started.
- err = nb.AddPeer(context.Background(), accountID, "domain2.test", "key-1", types.ServiceID("svc-2"))
+ // Add second service with an already-cancelled caller context โ
+ // should notify immediately (client is started) AND the notification
+ // must not inherit the cancelled ctx.
+ cancelledCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+ err = nb.AddPeer(cancelledCtx, accountID, "domain2.test", "key-1", types.ServiceID("svc-2"))
require.NoError(t, err)
calls := notifier.calls()
@@ -304,6 +312,9 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
assert.Equal(t, accountID, calls[0].accountID)
assert.Equal(t, types.ServiceID("svc-2"), calls[0].serviceID)
assert.True(t, calls[0].connected)
+ require.NotNil(t, calls[0].ctx, "NotifyStatus must receive a context")
+ require.NoError(t, calls[0].ctx.Err(),
+ "already-started NotifyStatus must use a background ctx, not the cancelled caller ctx")
}
// TestNetBird_IdentityForIP_UnknownAccountReturnsFalse confirms that the
@@ -338,7 +349,7 @@ func TestClientEntry_IdentityForIP_InvalidIPReturnsFalse(t *testing.T) {
func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
notifier := &mockStatusNotifier{}
- nb := NewNetBird("test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
@@ -360,3 +371,53 @@ func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
assert.Equal(t, types.ServiceID("svc-1"), calls[0].serviceID)
assert.False(t, calls[0].connected)
}
+
+// TestNotifyClientReady_UsesBackgroundCtx pins the contract that the
+// post-Start hooks (readyHandler + statusNotifier.NotifyStatus) run on
+// a fresh context.Background() rather than inheriting the AddPeer
+// caller's request- or stream-scoped ctx. Without this, a cancelled
+// caller ctx could abort the inbound listener bring-up or cause the
+// management status notification to fail spuriously and leave the
+// account in a half-connected state.
+func TestNotifyClientReady_UsesBackgroundCtx(t *testing.T) {
+ notifier := &mockStatusNotifier{}
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, notifier, &mockMgmtClient{})
+
+ accountID := types.AccountID("acct-async")
+ // Pre-populate a client entry so notifyClientReady has something
+ // to mark started + something to enumerate for NotifyStatus.
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{
+ DomainServiceKey("svc.example"): {serviceID: types.ServiceID("svc-1")},
+ },
+ }
+ nb.clientsMux.Unlock()
+
+ var capturedReadyCtx context.Context
+ nb.SetClientLifecycle(
+ func(ctx context.Context, _ types.AccountID, _ *embed.Client) any {
+ capturedReadyCtx = ctx
+ return nil
+ },
+ nil,
+ )
+
+ // Drive the post-Start path directly; a real client.Start would
+ // need a working management URL.
+ nb.notifyClientReady(accountID, nil)
+
+ require.NotNil(t, capturedReadyCtx, "readyHandler must have been invoked")
+ require.NoError(t, capturedReadyCtx.Err(),
+ "readyHandler must receive a background context, not an inherited cancelled one")
+ deadline, ok := capturedReadyCtx.Deadline()
+ assert.False(t, ok, "readyHandler ctx must have no deadline (background); got %v", deadline)
+
+ calls := notifier.calls()
+ require.Len(t, calls, 1, "NotifyStatus must be invoked once per registered service")
+ require.NotNil(t, calls[0].ctx, "NotifyStatus must receive a context")
+ require.NoError(t, calls[0].ctx.Err(),
+ "NotifyStatus must receive a background context, not an inherited cancelled one")
+}
diff --git a/proxy/internal/tcp/router_test.go b/proxy/internal/tcp/router_test.go
index 2f96d142c..ea1b418f5 100644
--- a/proxy/internal/tcp/router_test.go
+++ b/proxy/internal/tcp/router_test.go
@@ -1781,11 +1781,14 @@ func TestRouter_PlainHTTP_RoutesToPlainChannel(t *testing.T) {
}
}()
+ tlsListener, ok := router.HTTPListener().(*chanListener)
+ require.True(t, ok, "router.HTTPListener() must be the test's chanListener; the test relies on observing its channel directly")
+
select {
case conn := <-acceptDone:
require.NotNil(t, conn)
_ = conn.Close()
- case <-router.HTTPListener().(*chanListener).ch:
+ case <-tlsListener.ch:
t.Fatal("plain HTTP request leaked into TLS channel")
case <-time.After(3 * time.Second):
t.Fatal("plain HTTP connection never reached plain channel")
diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go
index 9787f237e..41d4bc496 100644
--- a/proxy/lifecycle.go
+++ b/proxy/lifecycle.go
@@ -1,6 +1,7 @@
package proxy
import (
+ "context"
"net/netip"
"time"
@@ -20,14 +21,17 @@ import (
type Config struct {
// ListenAddr is the TCP address the main listener binds. Required.
ListenAddr string
- // ID identifies this proxy instance to management. Empty value lets
- // New generate a timestamped default.
+ // ID identifies this proxy instance to management. Empty values are
+ // replaced with a timestamped default at Server.Start time (see
+ // initDefaults), not in New.
ID string
- // Logger is the logrus logger used everywhere. Empty value falls back
- // to log.StandardLogger().
+ // Logger is the logrus logger used everywhere. Empty values fall
+ // back to log.StandardLogger() at Server.Start time (see
+ // initDefaults), not in New.
Logger *log.Logger
// Version is the build version string reported to management. Empty
- // becomes "dev".
+ // values are replaced with "dev" at Server.Start time (see
+ // initDefaults), not in New.
Version string
// ProxyURL is the public address operators use to reach this proxy.
ProxyURL string
@@ -125,8 +129,9 @@ type Config struct {
// bound โ call Start to bring the proxy up. Returning a fully-formed
// Server keeps the standalone code path (which still constructs Server
// directly) byte-for-byte equivalent.
-func New(cfg Config) *Server {
+func New(ctx context.Context, cfg Config) *Server {
return &Server{
+ ctx: ctx,
ListenAddr: cfg.ListenAddr,
ID: cfg.ID,
Logger: cfg.Logger,
diff --git a/proxy/process_mappings_bench_test.go b/proxy/process_mappings_bench_test.go
index ca0792590..919cab95c 100644
--- a/proxy/process_mappings_bench_test.go
+++ b/proxy/process_mappings_bench_test.go
@@ -73,7 +73,7 @@ func benchServerWithLatency(b *testing.B, createPeerDelay, statusDelay time.Dura
statusUpdateDelay: statusDelay,
}
- nb := roundtrip.NewNetBird("bench-proxy", "bench.test",
+ nb := roundtrip.NewNetBird(b.Context(), "bench-proxy", "bench.test",
roundtrip.ClientConfig{MgmtAddr: "http://bench.test:9999"},
logger, nil, mgmtClient)
diff --git a/proxy/server.go b/proxy/server.go
index 037da925c..1f5e0abd6 100644
--- a/proxy/server.go
+++ b/proxy/server.go
@@ -75,6 +75,7 @@ type portRouter struct {
}
type Server struct {
+ ctx context.Context
mgmtClient proto.ProxyServiceClient
proxy *proxy.ReverseProxy
netbird *roundtrip.NetBird
@@ -281,7 +282,7 @@ func (s *Server) NotifyCertificateIssued(ctx context.Context, accountID types.Ac
}
// inboundListenerProto resolves the per-account inbound listener state for
-// the SendStatusUpdate payload. Returns nil when --private-inbound is off
+// the SendStatusUpdate payload. Returns nil when --private is off
// or the account has no live listener so management treats the field as
// absent.
func (s *Server) inboundListenerProto(accountID types.AccountID) *proto.ProxyInboundListener {
@@ -528,10 +529,10 @@ func (s *Server) initManagementClient() error {
}
// initNetBirdClient builds the multi-tenant embedded NetBird client used
-// for outbound RoundTripping and (when --private-inbound is on) per-account
+// for outbound RoundTripping and (when --private is on) per-account
// inbound listeners.
func (s *Server) initNetBirdClient() {
- s.netbird = roundtrip.NewNetBird(s.ID, s.ProxyURL, roundtrip.ClientConfig{
+ s.netbird = roundtrip.NewNetBird(s.ctx, s.ID, s.ProxyURL, roundtrip.ClientConfig{
MgmtAddr: s.ManagementAddress,
WGPort: s.WireguardPort,
PreSharedKey: s.PreSharedKey,
diff --git a/proxy/server_test.go b/proxy/server_test.go
index 10d38f250..aa4892201 100644
--- a/proxy/server_test.go
+++ b/proxy/server_test.go
@@ -64,7 +64,7 @@ func quietLifecycleLogger() *log.Logger {
}
func TestStopBeforeStartIsNoOp(t *testing.T) {
- srv := New(Config{Logger: quietLifecycleLogger()})
+ srv := New(t.Context(), Config{Logger: quietLifecycleLogger()})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
@@ -77,7 +77,7 @@ func TestStopBeforeStartIsNoOp(t *testing.T) {
}
func TestStartFailsWithoutManagement(t *testing.T) {
- srv := New(Config{
+ srv := New(t.Context(), Config{
Logger: quietLifecycleLogger(),
ListenAddr: "127.0.0.1:0",
ManagementAddress: "://broken-url",
@@ -137,7 +137,7 @@ func TestRecordRunErrPreservesFirstFailure(t *testing.T) {
}
func TestStopSkipsShutdownWhenNeverStarted(t *testing.T) {
- srv := New(Config{Logger: quietLifecycleLogger()})
+ srv := New(t.Context(), Config{Logger: quietLifecycleLogger()})
ctx, cancel := context.WithCancel(context.Background())
cancel()
diff --git a/shared/management/client/rest/reverse_proxy_clusters.go b/shared/management/client/rest/reverse_proxy_clusters.go
index 249833b01..ca9714dc0 100644
--- a/shared/management/client/rest/reverse_proxy_clusters.go
+++ b/shared/management/client/rest/reverse_proxy_clusters.go
@@ -2,6 +2,7 @@ package rest
import (
"context"
+ "errors"
"net/url"
"github.com/netbirdio/netbird/shared/management/http/api"
@@ -33,6 +34,12 @@ func (a *ReverseProxyClustersAPI) List(ctx context.Context) ([]api.ProxyCluster,
// NetBird cannot be deleted via this endpoint; the server returns 404 / 400
// for cluster addresses the account does not own.
func (a *ReverseProxyClustersAPI) Delete(ctx context.Context, clusterAddress string) error {
+ // Guard against the empty input: url.PathEscape("") returns "" which
+ // would collapse the request URL onto the collection endpoint and
+ // silently delete nothing (or 405 depending on routing).
+ if clusterAddress == "" {
+ return errors.New("clusterAddress is required")
+ }
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/clusters/"+url.PathEscape(clusterAddress), nil, nil)
if err != nil {
return err
diff --git a/shared/management/client/rest/reverse_proxy_clusters_test.go b/shared/management/client/rest/reverse_proxy_clusters_test.go
index 2d9f6f7bb..16f955d5a 100644
--- a/shared/management/client/rest/reverse_proxy_clusters_test.go
+++ b/shared/management/client/rest/reverse_proxy_clusters_test.go
@@ -88,3 +88,17 @@ func TestReverseProxyClusters_Delete_Err(t *testing.T) {
assert.Error(t, err)
})
}
+
+// TestReverseProxyClusters_Delete_EmptyAddress guards against an empty
+// clusterAddress reaching the wire โ that would collapse the URL onto
+// the collection endpoint instead of a specific cluster. The client
+// must short-circuit with a typed error before any request is issued.
+func TestReverseProxyClusters_Delete_EmptyAddress(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/clusters/", func(http.ResponseWriter, *http.Request) {
+ t.Fatal("empty clusterAddress must be rejected client-side; no request should reach the server")
+ })
+ err := c.ReverseProxyClusters.Delete(context.Background(), "")
+ assert.Error(t, err, "empty clusterAddress must surface as an error")
+ })
+}
diff --git a/shared/management/client/rest/reverse_proxy_tokens.go b/shared/management/client/rest/reverse_proxy_tokens.go
index de59f3176..caa240395 100644
--- a/shared/management/client/rest/reverse_proxy_tokens.go
+++ b/shared/management/client/rest/reverse_proxy_tokens.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"net/url"
"github.com/netbirdio/netbird/shared/management/http/api"
@@ -61,6 +62,12 @@ func (a *ReverseProxyTokensAPI) Create(ctx context.Context, request api.ProxyTok
// credentials existed; the plain secret can no longer authenticate any
// new proxy registration.
func (a *ReverseProxyTokensAPI) Delete(ctx context.Context, tokenID string) error {
+ // Guard against the empty input: url.PathEscape("") returns "" which
+ // would collapse the request URL onto the collection endpoint and
+ // silently delete nothing (or 405 depending on routing).
+ if tokenID == "" {
+ return errors.New("tokenID is required")
+ }
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/proxy-tokens/"+url.PathEscape(tokenID), nil, nil)
if err != nil {
return err
diff --git a/shared/management/client/rest/reverse_proxy_tokens_test.go b/shared/management/client/rest/reverse_proxy_tokens_test.go
index a3f5e014f..ecd80bd1a 100644
--- a/shared/management/client/rest/reverse_proxy_tokens_test.go
+++ b/shared/management/client/rest/reverse_proxy_tokens_test.go
@@ -129,3 +129,16 @@ func TestReverseProxyTokens_Delete_Err(t *testing.T) {
assert.Error(t, err)
})
}
+
+// TestReverseProxyTokens_Delete_EmptyID guards against an empty tokenID
+// reaching the wire โ url.PathEscape("") would collapse the URL onto
+// the collection endpoint.
+func TestReverseProxyTokens_Delete_EmptyID(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens/", func(http.ResponseWriter, *http.Request) {
+ t.Fatal("empty tokenID must be rejected client-side; no request should reach the server")
+ })
+ err := c.ReverseProxyTokens.Delete(context.Background(), "")
+ assert.Error(t, err, "empty tokenID must surface as an error")
+ })
+}
diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml
index 6b8939598..03e30e6b7 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -3086,6 +3086,24 @@ components:
- enabled
- auth
- meta
+ allOf:
+ # When private=true, access_groups must be present and non-empty,
+ # and the service mode must be "http". The bearer-auth mutex is
+ # enforced at the service-validation layer
+ # (validatePrivateRequirements) because it sits in a nested
+ # ServiceAuthConfig and isn't cleanly expressible here.
+ - if:
+ required: [private]
+ properties:
+ private:
+ const: true
+ then:
+ required: [access_groups]
+ properties:
+ access_groups:
+ minItems: 1
+ mode:
+ const: http
ServiceMeta:
type: object
properties:
@@ -3173,6 +3191,23 @@ components:
- name
- domain
- enabled
+ allOf:
+ # Mirror of the Service conditional: when private=true the
+ # request must carry a non-empty access_groups list and the
+ # mode must be "http". The bearer-auth mutex is enforced at the
+ # service-validation layer (validatePrivateRequirements).
+ - if:
+ required: [private]
+ properties:
+ private:
+ const: true
+ then:
+ required: [access_groups]
+ properties:
+ access_groups:
+ minItems: 1
+ mode:
+ const: http
ServiceTargetOptions:
type: object
properties:
diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto
index 71e18c721..14d188877 100644
--- a/shared/management/proto/proxy_service.proto
+++ b/shared/management/proto/proxy_service.proto
@@ -237,7 +237,7 @@ message SendStatusUpdateRequest {
bool certificate_issued = 4;
optional string error_message = 5;
// Per-account inbound listener state for the account that owns
- // service_id. Populated only when --private-inbound is enabled and the
+ // service_id. Populated only when --private is enabled and the
// embedded client for the account is up. Field numbers >=50 reserved
// for observability extensions.
optional ProxyInboundListener inbound_listener = 50;
From 2b57a7d43bd5c914d238e321a2cdb998213b3b91 Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Wed, 3 Jun 2026 08:56:50 +0200
Subject: [PATCH 26/44] [client, management, misc] expose VCS revision in dev
build version output (#6263)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Refactor to use a common checker for development version
* Adds commit sha to development version for cobra command only
Leave dashboard unaffected
* Adjust for "v0.31.1-dev" test case
which must be considered pre-release
* Drop synthetic "dev"/"0.50.0-dev" firewall feature-gate fixtures
These test cases encoded the loose strings.Contains(v, "dev")
semantics inherited from peerSupportedFirewallFeatures, but
NetbirdVersion() never produces those values โ only the literal
"development" (and now "development-[-dirty]") ever flows
through the wire. The agent owns the semantics of an ephemeral
development build, so the tests should exercise the strings we
actually emit.
Replaced with development, development- and
development--dirty cases that match the HasPrefix("development")
predicate introduced upstream.
* Remove unexistent tests on wire format
The sha / dirty flag are added only when the CLI asks the version.
Account versions is unaffacted and can only strictly match "development"
* Adds tests for IsDevelopmentVersion
---
client/cmd/version.go | 8 ++-
client/internal/lazyconn/support.go | 4 +-
client/internal/updater/manager.go | 4 +-
.../network_map/controller/controller.go | 3 +-
management/server/peer.go | 3 +-
management/server/types/account.go | 3 +-
management/server/types/account_test.go | 36 +----------
version/version.go | 60 ++++++++++++++++++-
version/version_test.go | 26 ++++++++
9 files changed, 102 insertions(+), 45 deletions(-)
create mode 100644 version/version_test.go
diff --git a/client/cmd/version.go b/client/cmd/version.go
index 249854444..5deeae1a0 100644
--- a/client/cmd/version.go
+++ b/client/cmd/version.go
@@ -12,7 +12,13 @@ var (
Short: "Print the NetBird's client application version",
Run: func(cmd *cobra.Command, args []string) {
cmd.SetOut(cmd.OutOrStdout())
- cmd.Println(version.NetbirdVersion())
+ out := version.NetbirdVersion()
+ if version.IsDevelopmentVersion(out) {
+ if commit := version.NetbirdCommit(); commit != "" {
+ out += "-" + commit
+ }
+ }
+ cmd.Println(out)
},
}
)
diff --git a/client/internal/lazyconn/support.go b/client/internal/lazyconn/support.go
index 5e765c2d6..cc0e95e53 100644
--- a/client/internal/lazyconn/support.go
+++ b/client/internal/lazyconn/support.go
@@ -4,6 +4,8 @@ import (
"strings"
"github.com/hashicorp/go-version"
+
+ nbversion "github.com/netbirdio/netbird/version"
)
var (
@@ -11,7 +13,7 @@ var (
)
func IsSupported(agentVersion string) bool {
- if agentVersion == "development" {
+ if nbversion.IsDevelopmentVersion(agentVersion) {
return true
}
diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go
index dfcb93177..7fc300739 100644
--- a/client/internal/updater/manager.go
+++ b/client/internal/updater/manager.go
@@ -19,8 +19,6 @@ import (
const (
latestVersion = "latest"
- // this version will be ignored
- developmentVersion = "development"
)
var errNoUpdateState = errors.New("no update state found")
@@ -483,7 +481,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e
}
func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool {
- if m.currentVersion == developmentVersion {
+ if version.IsDevelopmentVersion(m.currentVersion) {
log.Debugf("skipping auto-update, running development version")
return false
}
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 4199b2b27..2b81cd6e5 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -32,6 +32,7 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/util"
+ "github.com/netbirdio/netbird/version"
)
type Controller struct {
@@ -514,7 +515,7 @@ func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
for _, peer := range peers {
// Development version is always supported
- if peer.Meta.WtVersion == "development" {
+ if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
continue
}
peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
diff --git a/management/server/peer.go b/management/server/peer.go
index 4942e44c1..d4e3ebb49 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -30,6 +30,7 @@ import (
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/shared/management/status"
+ "github.com/netbirdio/netbird/version"
)
const remoteJobsMinVer = "0.64.0"
@@ -372,7 +373,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
}
meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
- if !strings.Contains(p.Meta.WtVersion, "dev") && (!meetMinVer || err != nil) {
+ if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!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)
}
diff --git a/management/server/types/account.go b/management/server/types/account.go
index dc0c5a685..0d0893e28 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -29,6 +29,7 @@ import (
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/status"
+ "github.com/netbirdio/netbird/version"
)
const (
@@ -1804,7 +1805,7 @@ func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *n
// peerSupportedFirewallFeatures checks if the peer version supports port ranges.
func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
- if strings.Contains(peerVer, "dev") {
+ if version.IsDevelopmentVersion(peerVer) {
return supportedFeatures{true, true}
}
diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go
index b55b41638..d8e2e1f8c 100644
--- a/management/server/types/account_test.go
+++ b/management/server/types/account_test.go
@@ -646,41 +646,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) {
expectedPorts: []string{"20-25", "10-100", "22022"},
},
{
- name: "dev suffix version supports all features",
- peer: &nbpeer.Peer{
- ID: "peer1",
- SSHEnabled: true,
- Meta: nbpeer.PeerSystemMeta{
- WtVersion: "0.50.0-dev",
- Flags: nbpeer.Flags{ServerSSHAllowed: true},
- },
- },
- rule: &PolicyRule{
- Protocol: PolicyRuleProtocolTCP,
- Ports: []string{"22"},
- },
- base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"},
- expectedPorts: []string{"22", "22022"},
- },
- {
- name: "dev suffix version supports all features",
- peer: &nbpeer.Peer{
- ID: "peer1",
- SSHEnabled: true,
- Meta: nbpeer.PeerSystemMeta{
- WtVersion: "dev",
- Flags: nbpeer.Flags{ServerSSHAllowed: true},
- },
- },
- rule: &PolicyRule{
- Protocol: PolicyRuleProtocolTCP,
- Ports: []string{"22"},
- },
- base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"},
- expectedPorts: []string{"22", "22022"},
- },
- {
- name: "development suffix version supports all features",
+ name: "development version supports all features",
peer: &nbpeer.Peer{
ID: "peer1",
SSHEnabled: true,
diff --git a/version/version.go b/version/version.go
index d70a5effa..f33ff133c 100644
--- a/version/version.go
+++ b/version/version.go
@@ -2,19 +2,75 @@ package version
import (
"regexp"
+ "runtime/debug"
+ "strings"
v "github.com/hashicorp/go-version"
)
+// DevelopmentVersion is the value of NetbirdVersion() for non-release builds.
+// Wire-format consumers (management server, dashboard) match against this
+// string, so it must not change without coordinating those consumers.
+const DevelopmentVersion = "development"
+
// will be replaced with the release version when using goreleaser
-var version = "development"
+var version = DevelopmentVersion
var (
VersionRegexp = regexp.MustCompile("^" + v.VersionRegexpRaw + "$")
SemverRegexp = regexp.MustCompile("^" + v.SemverRegexpRaw + "$")
)
-// NetbirdVersion returns the Netbird version
+// NetbirdVersion returns the Netbird version. For non-release builds the
+// value is the literal DevelopmentVersion constant; the VCS revision is
+// exposed separately via NetbirdCommit so the wire format stays stable.
func NetbirdVersion() string {
return version
}
+
+// NetbirdCommit returns the VCS revision (truncated to 12 chars) of the
+// build, with a "-dirty" suffix when the working tree was modified.
+// Returns an empty string when no build info is embedded (e.g. release
+// builds compiled by goreleaser without -buildvcs).
+func NetbirdCommit() string {
+ info, ok := debug.ReadBuildInfo()
+ if !ok {
+ return ""
+ }
+
+ var revision string
+ var modified bool
+ for _, s := range info.Settings {
+ switch s.Key {
+ case "vcs.revision":
+ revision = s.Value
+ case "vcs.modified":
+ modified = s.Value == "true"
+ }
+ }
+
+ if revision == "" {
+ return ""
+ }
+
+ if len(revision) > 12 {
+ revision = revision[:12]
+ }
+
+ if modified {
+ revision += "-dirty"
+ }
+ return revision
+}
+
+// IsDevelopmentVersion reports whether the given version string identifies
+// a non-release / development build. It is the single source of truth for
+// "is this a dev build" checks across the codebase; use it instead of
+// comparing against the "development" literal or ad-hoc substring checks.
+//
+// Matches the bare DevelopmentVersion constant as well as any future
+// extension such as "development-" or "development--dirty",
+// while excluding tagged prereleases like "v0.31.1-dev".
+func IsDevelopmentVersion(v string) bool {
+ return strings.HasPrefix(v, DevelopmentVersion)
+}
diff --git a/version/version_test.go b/version/version_test.go
new file mode 100644
index 000000000..47b77b50d
--- /dev/null
+++ b/version/version_test.go
@@ -0,0 +1,26 @@
+package version
+
+import "testing"
+
+func TestIsDevelopmentVersion(t *testing.T) {
+ tests := []struct {
+ version string
+ want bool
+ }{
+ {"development", true},
+ {"development-0823f3ff9ab1", true},
+ {"development-0823f3ff9ab1-dirty", true},
+ {"0.50.0", false},
+ {"v0.31.1-dev", false},
+ {"1.0.0-dev", false},
+ {"dev", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.version, func(t *testing.T) {
+ if got := IsDevelopmentVersion(tt.version); got != tt.want {
+ t.Errorf("IsDevelopmentVersion(%q) = %v, want %v", tt.version, got, tt.want)
+ }
+ })
+ }
+}
From a48c20d8d898901f8622bf7791d3f473e4c27624 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Wed, 3 Jun 2026 18:33:29 +0900
Subject: [PATCH 27/44] [client] Gate DNS forwarder on BlockInbound (#6257)
---
client/internal/engine.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/internal/engine.go b/client/internal/engine.go
index b82eb95b7..b181bbc4e 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -2216,7 +2216,7 @@ func (e *Engine) updateDNSForwarder(
enabled bool,
fwdEntries []*dnsfwd.ForwarderEntry,
) {
- if e.config.DisableServerRoutes {
+ if e.config.DisableServerRoutes || e.config.BlockInbound {
return
}
From 3e61ccb162cd36d1ba48b966bf6dfa84714f957c Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Wed, 3 Jun 2026 14:18:50 +0200
Subject: [PATCH 28/44] [client] Persist sync response via pluggable store
(disk on iOS) (#6331)
* Persist sync response via pluggable store (disk on iOS)
The latest Management sync response (which carries the network map) was
kept in memory for debug bundle generation. On memory-constrained
platforms like iOS the network map can be large enough to matter.
Introduce a syncstore package with a Store interface and two backends:
a memory backend (the previous behavior) and a disk backend that
serializes the response to a file in the state directory. The backend
is selected per-platform at build time: disk on iOS, memory elsewhere.
The disk store clears any leftover file on construction so a fresh
store never reads stale data from an earlier run (e.g. another
profile's network map).
In the engine, drop the separate persistSyncResponse bool: the store is
only instantiated while persistence is enabled, and its presence is
what marks persistence as active. The store is also cleared on engine
close so the file does not linger on disk.
* syncstore: silence nilnil linter on "nothing stored" returns
Get returns (nil, nil) to signal that nothing is stored, which is part
of the Store contract and preserves the original behaviour. Annotate
both backends with //nolint:nilnil so golangci-lint does not flag it.
* syncstore: hold syncRespMux for the whole store Set/Get
Both handleSync and GetLatestSyncResponse snapshotted e.syncStore under
the read lock and then released it before calling Set/Get. That allowed
SetSyncResponsePersistence(false) or engine close to clear the store
mid-call. In particular a concurrent Clear()+nil followed by a late
Set could re-create the file that was just removed, defeating the
leak/lingering protection.
Hold syncRespMux for the duration of the store operation in both spots
so the store cannot be cleared while a Set/Get is in flight.
* syncstore: avoid StateDir "." when state path is empty
On mobile the state path may be empty (the engine tolerates a missing
state file). filepath.Dir("") returns ".", which would make a
disk-backed syncstore write into the working directory instead of
letting NewDiskStore fall back to os.TempDir().
Only set engineConfig.StateDir when path is non-empty.
---
client/internal/connect.go | 6 ++
client/internal/engine.go | 90 ++++++++++++--------
client/internal/syncstore/disk.go | 99 ++++++++++++++++++++++
client/internal/syncstore/factory_ios.go | 9 ++
client/internal/syncstore/factory_other.go | 9 ++
client/internal/syncstore/memory.go | 56 ++++++++++++
client/internal/syncstore/syncstore.go | 29 +++++++
7 files changed, 262 insertions(+), 36 deletions(-)
create mode 100644 client/internal/syncstore/disk.go
create mode 100644 client/internal/syncstore/factory_ios.go
create mode 100644 client/internal/syncstore/factory_other.go
create mode 100644 client/internal/syncstore/memory.go
create mode 100644 client/internal/syncstore/syncstore.go
diff --git a/client/internal/connect.go b/client/internal/connect.go
index ea884818f..e38bc2f58 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
+ "path/filepath"
"runtime"
"runtime/debug"
"strings"
@@ -346,6 +347,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return wrapErr(err)
}
engineConfig.TempDir = mobileDependency.TempDir
+ // Leave StateDir empty when there is no state path so a disk-backed
+ // syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
+ if path != "" {
+ engineConfig.StateDir = filepath.Dir(path)
+ }
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
c.statusRecorder.SetRelayMgr(relayManager)
diff --git a/client/internal/engine.go b/client/internal/engine.go
index b181bbc4e..048ff5fcc 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -22,7 +22,6 @@ import (
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "google.golang.org/protobuf/proto"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall"
@@ -56,6 +55,7 @@ import (
"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/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
cProto "github.com/netbirdio/netbird/client/proto"
@@ -148,6 +148,10 @@ type EngineConfig struct {
LogPath string
TempDir string
+
+ // StateDir is the directory holding the state file. The sync response
+ // (network map) is serialized here on platforms that persist it to disk.
+ StateDir string
}
// EngineServices holds the external service dependencies required by the Engine.
@@ -226,10 +230,15 @@ type Engine struct {
afpacketCapture *capture.AFPacketCapture
- // Sync response persistence (protected by syncRespMux)
- syncRespMux sync.RWMutex
- persistSyncResponse bool
- latestSyncResponse *mgmProto.SyncResponse
+ // Sync response persistence (protected by syncRespMux).
+ // syncStore is nil unless persistence has been enabled; its presence is
+ // what marks persistence as active. The backend (disk or memory) is
+ // selected per-platform; see the syncstore package. syncStoreDir is where
+ // a disk-backed store serializes to.
+ syncRespMux sync.RWMutex
+ syncStore syncstore.Store
+ syncStoreDir string
+
flowManager nftypes.FlowManager
// auto-update
@@ -292,6 +301,7 @@ func NewEngine(
jobExecutor: jobexec.NewExecutor(),
clientMetrics: services.ClientMetrics,
updateManager: services.UpdateManager,
+ syncStoreDir: config.StateDir,
}
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
@@ -913,19 +923,18 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
}
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
- // Read the storage-enabled flag under the syncRespMux too.
+ // A non-nil syncStore is what marks persistence as enabled. Hold the lock for
+ // the whole Set so the store cannot be cleared (disabled / engine close)
+ // mid-call and have this write resurrect a file that was just removed.
e.syncRespMux.RLock()
- enabled := e.persistSyncResponse
- e.syncRespMux.RUnlock()
-
- // Store sync response if persistence is enabled
- if enabled {
- e.syncRespMux.Lock()
- e.latestSyncResponse = update
- e.syncRespMux.Unlock()
-
- log.Debugf("sync response persisted with serial %d", nm.GetSerial())
+ if e.syncStore != nil {
+ if err := e.syncStore.Set(update); err != nil {
+ log.Errorf("failed to persist sync response: %v", err)
+ } else {
+ log.Debugf("sync response persisted with serial %d", nm.GetSerial())
+ }
}
+ e.syncRespMux.RUnlock()
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -1813,6 +1822,18 @@ func (e *Engine) close() {
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
}
+
+ // Drop any persisted sync response so its network map does not linger on
+ // disk after the engine stops (and cannot leak into a later run).
+ e.syncRespMux.Lock()
+ store := e.syncStore
+ e.syncStore = nil
+ e.syncRespMux.Unlock()
+ if store != nil {
+ if err := store.Clear(); err != nil {
+ log.Warnf("failed to clear persisted sync response on close: %v", err)
+ }
+ }
}
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
@@ -2142,45 +2163,42 @@ func (e *Engine) stopDNSServer() {
e.statusRecorder.UpdateDNSStates(nsGroupStates)
}
-// SetSyncResponsePersistence enables or disables sync response persistence
+// SetSyncResponsePersistence enables or disables sync response persistence.
+// The store is only instantiated while persistence is enabled; construction
+// itself drops any stale data left over from an earlier run (see syncstore).
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
e.syncRespMux.Lock()
defer e.syncRespMux.Unlock()
- if enabled == e.persistSyncResponse {
+ if enabled == (e.syncStore != nil) {
return
}
- e.persistSyncResponse = enabled
log.Debugf("Sync response persistence is set to %t", enabled)
if !enabled {
- e.latestSyncResponse = nil
+ if err := e.syncStore.Clear(); err != nil {
+ log.Warnf("failed to clear persisted sync response: %v", err)
+ }
+ e.syncStore = nil
+ return
}
+
+ e.syncStore = syncstore.New(e.syncStoreDir)
}
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
+ // Hold the lock for the whole Get so the store cannot be cleared
+ // (disabled / engine close) mid-call.
e.syncRespMux.RLock()
- enabled := e.persistSyncResponse
- latest := e.latestSyncResponse
- e.syncRespMux.RUnlock()
+ defer e.syncRespMux.RUnlock()
- if !enabled {
+ if e.syncStore == nil {
return nil, errors.New("sync response persistence is disabled")
}
- if latest == nil {
- //nolint:nilnil
- return nil, nil
- }
-
- 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")
- }
-
- return sr, nil
+ //nolint:nilnil
+ return e.syncStore.Get()
}
// GetWgAddr returns the wireguard address
diff --git a/client/internal/syncstore/disk.go b/client/internal/syncstore/disk.go
new file mode 100644
index 000000000..eb24e87a7
--- /dev/null
+++ b/client/internal/syncstore/disk.go
@@ -0,0 +1,99 @@
+package syncstore
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/proto"
+
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/util"
+)
+
+// syncResponseFileName is the name of the file the sync response is serialized
+// to, placed inside the configured directory (the state directory).
+const syncResponseFileName = "networkmap.pb"
+
+// diskStore serializes the latest sync response to a file on disk instead of
+// keeping it in memory. This trades disk I/O for a much smaller memory
+// footprint, which matters on memory-constrained platforms (iOS).
+type diskStore struct {
+ mu sync.Mutex
+ path string
+}
+
+// NewDiskStore returns a Store that serializes the sync response to a file in
+// the given directory. If dir is empty it falls back to the OS temp directory.
+//
+// Any file left over from a previous run is removed on construction so a fresh
+// store never reads stale data (e.g. another profile's network map).
+func NewDiskStore(dir string) Store {
+ if dir == "" {
+ dir = os.TempDir()
+ }
+ s := &diskStore{
+ path: filepath.Join(dir, syncResponseFileName),
+ }
+ if err := s.Clear(); err != nil {
+ log.Warnf("failed to clear stale sync response file: %v", err)
+ }
+ return s
+}
+
+func (s *diskStore) Set(resp *mgmProto.SyncResponse) error {
+ if resp == nil {
+ return s.Clear()
+ }
+
+ bs, err := proto.Marshal(resp)
+ if err != nil {
+ return fmt.Errorf("marshal sync response: %w", err)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := util.WriteBytesWithRestrictedPermission(context.Background(), s.path, bs); err != nil {
+ return fmt.Errorf("write sync response to %s: %w", s.path, err)
+ }
+
+ log.Debugf("sync response persisted to %s (%d bytes)", s.path, len(bs))
+ return nil
+}
+
+func (s *diskStore) Get() (*mgmProto.SyncResponse, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ bs, err := os.ReadFile(s.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
+ return nil, nil
+ }
+ return nil, fmt.Errorf("read sync response from %s: %w", s.path, err)
+ }
+
+ resp := &mgmProto.SyncResponse{}
+ if err := proto.Unmarshal(bs, resp); err != nil {
+ return nil, fmt.Errorf("unmarshal sync response: %w", err)
+ }
+
+ log.Debugf("retrieving latest sync response from %s (%d bytes)", s.path, len(bs))
+ return resp, nil
+}
+
+func (s *diskStore) Clear() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("remove sync response file %s: %w", s.path, err)
+ }
+ return nil
+}
diff --git a/client/internal/syncstore/factory_ios.go b/client/internal/syncstore/factory_ios.go
new file mode 100644
index 000000000..f19ab5e5c
--- /dev/null
+++ b/client/internal/syncstore/factory_ios.go
@@ -0,0 +1,9 @@
+//go:build ios
+
+package syncstore
+
+// New returns the platform default store. On iOS the sync response is
+// serialized to disk (in dir) to keep it out of the constrained process memory.
+func New(dir string) Store {
+ return NewDiskStore(dir)
+}
diff --git a/client/internal/syncstore/factory_other.go b/client/internal/syncstore/factory_other.go
new file mode 100644
index 000000000..79ea46116
--- /dev/null
+++ b/client/internal/syncstore/factory_other.go
@@ -0,0 +1,9 @@
+//go:build !ios
+
+package syncstore
+
+// New returns the platform default store. On all non-iOS platforms the sync
+// response is kept in memory; dir is unused.
+func New(_ string) Store {
+ return NewMemoryStore()
+}
diff --git a/client/internal/syncstore/memory.go b/client/internal/syncstore/memory.go
new file mode 100644
index 000000000..8fc069069
--- /dev/null
+++ b/client/internal/syncstore/memory.go
@@ -0,0 +1,56 @@
+package syncstore
+
+import (
+ "fmt"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/proto"
+
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// memoryStore keeps the latest sync response in memory.
+type memoryStore struct {
+ mu sync.RWMutex
+ latest *mgmProto.SyncResponse
+}
+
+// NewMemoryStore returns a Store that keeps the sync response in memory.
+func NewMemoryStore() Store {
+ return &memoryStore{}
+}
+
+func (s *memoryStore) Set(resp *mgmProto.SyncResponse) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.latest = resp
+ return nil
+}
+
+func (s *memoryStore) Get() (*mgmProto.SyncResponse, error) {
+ s.mu.RLock()
+ latest := s.latest
+ s.mu.RUnlock()
+
+ if latest == nil {
+ //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
+ return nil, nil
+ }
+
+ 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("clone sync response")
+ }
+ return sr, nil
+}
+
+func (s *memoryStore) Clear() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.latest = nil
+ return nil
+}
diff --git a/client/internal/syncstore/syncstore.go b/client/internal/syncstore/syncstore.go
new file mode 100644
index 000000000..ba24b9c57
--- /dev/null
+++ b/client/internal/syncstore/syncstore.go
@@ -0,0 +1,29 @@
+// Package syncstore stores the latest Management sync response (which carries
+// the network map) for debug bundle generation.
+//
+// The storage backend is selected at build time per operating system: on iOS
+// the response is serialized to disk to keep it out of the (tightly
+// constrained) process memory, while on all other platforms it is kept in
+// memory. The backend is chosen by the New constructor; see factory_ios.go and
+// factory_other.go.
+package syncstore
+
+import (
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// Store persists the latest sync response and returns it on demand.
+//
+// Implementations must be safe for concurrent use.
+type Store interface {
+ // Set stores the given sync response, replacing any previously stored one.
+ Set(resp *mgmProto.SyncResponse) error
+
+ // Get returns the stored sync response, or nil if none is stored.
+ // The returned value is an independent copy that the caller may retain.
+ Get() (*mgmProto.SyncResponse, error)
+
+ // Clear removes any stored sync response. It is safe to call when nothing
+ // is stored.
+ Clear() error
+}
From f3cdf163e1487686e3b7c5bf577a8f96f0c9a8e6 Mon Sep 17 00:00:00 2001
From: Bethuel Mmbaga
Date: Wed, 3 Jun 2026 19:53:57 +0300
Subject: [PATCH 29/44] [management] Export ResolveDomain (#6334)
---
management/internals/server/server.go | 6 +++---
management/internals/server/server_resolve_domains_test.go | 6 +++---
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/management/internals/server/server.go b/management/internals/server/server.go
index 43ee2126d..9411073ac 100644
--- a/management/internals/server/server.go
+++ b/management/internals/server/server.go
@@ -122,7 +122,7 @@ func (s *BaseServer) Start(ctx context.Context) error {
s.errCh = make(chan error, 4)
if s.autoResolveDomains {
- s.resolveDomains(srvCtx)
+ s.ResolveDomains(srvCtx)
}
s.PeersManager()
@@ -398,10 +398,10 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene
}()
}
-// resolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
+// ResolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
// Fresh installs use the default self-hosted domain, while existing installs reuse the
// persisted account domain to keep addressing stable across config changes.
-func (s *BaseServer) resolveDomains(ctx context.Context) {
+func (s *BaseServer) ResolveDomains(ctx context.Context) {
st := s.Store()
setDefault := func(logMsg string, args ...any) {
diff --git a/management/internals/server/server_resolve_domains_test.go b/management/internals/server/server_resolve_domains_test.go
index db1d7e8ca..ba9eb3f74 100644
--- a/management/internals/server/server_resolve_domains_test.go
+++ b/management/internals/server/server_resolve_domains_test.go
@@ -22,7 +22,7 @@ func TestResolveDomains_FreshInstallUsesDefault(t *testing.T) {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
Inject[store.Store](srv, mockStore)
- srv.resolveDomains(context.Background())
+ srv.ResolveDomains(context.Background())
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
@@ -40,7 +40,7 @@ func TestResolveDomains_ExistingInstallUsesPersistedDomain(t *testing.T) {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
Inject[store.Store](srv, mockStore)
- srv.resolveDomains(context.Background())
+ srv.ResolveDomains(context.Background())
require.Equal(t, "vpn.mycompany.com", srv.dnsDomain)
require.Equal(t, "vpn.mycompany.com", srv.mgmtSingleAccModeDomain)
@@ -56,7 +56,7 @@ func TestResolveDomains_StoreErrorFallsBackToDefault(t *testing.T) {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
Inject[store.Store](srv, mockStore)
- srv.resolveDomains(context.Background())
+ srv.ResolveDomains(context.Background())
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
From deeae306121a2c304a6f044933fdc5e3fe6f0cba Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Wed, 3 Jun 2026 19:08:45 +0200
Subject: [PATCH 30/44] [misc] Add Codecov integration and coverage reporting
across workflows (#6333)
---
.github/workflows/golang-test-darwin.yml | 9 +++-
.github/workflows/golang-test-linux.yml | 61 +++++++++++++++++++++---
2 files changed, 63 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index 200e888ba..ad84840a2 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -45,4 +45,11 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+
+ - name: Upload coverage reports to Codecov
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,client
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index fc4187b8f..c17f83222 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -158,7 +158,16 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,client
+
test_client_on_docker:
name: "Client (Docker) / Unit"
@@ -276,9 +285,17 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test ${{ matrix.raceFlag }} \
- -exec 'sudo' \
+ -exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m -p 1 ./relay/... ./shared/relay/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,relay
+
test_proxy:
name: "Proxy / Unit"
needs: [build-cache]
@@ -326,7 +343,15 @@ jobs:
- name: Test
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
- go test -timeout 10m -p 1 ./proxy/...
+ go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,proxy
test_signal:
name: "Signal / Unit"
@@ -377,9 +402,17 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test \
- -exec 'sudo' \
+ -exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m ./signal/... ./shared/signal/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,signal
+
test_management:
name: "Management / Unit"
needs: [build-cache]
@@ -445,10 +478,18 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- go test -tags=devcert \
+ go test -tags=devcert -coverprofile=coverage.txt \
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
-timeout 20m ./management/... ./shared/management/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,management
+
benchmark:
name: "Management / Benchmark"
needs: [build-cache]
@@ -687,6 +728,14 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- go test -tags=integration \
+ go test -tags=integration -coverprofile=coverage.txt \
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
-timeout 20m ./management/server/http/...
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: integration,management
From eac6d501c344933fdd2aad48f6a26b7da7558b94 Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Thu, 4 Jun 2026 11:24:47 +0200
Subject: [PATCH 31/44] [infrastructure] allow docker image overrides for
getting started (#6335)
* [infrastructure] allow docker image overrides for getting started
Make dashboard and server image configurations overrideable via environment variables
* [infrastructure] update Traefik gRPC rule to include ProxyService PathPrefix
* make Traefik and CrowdSec images configurable via environment variables
---
infrastructure_files/getting-started.sh | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh
index 9d1b57258..910cea095 100755
--- a/infrastructure_files/getting-started.sh
+++ b/infrastructure_files/getting-started.sh
@@ -311,11 +311,12 @@ initialize_default_values() {
NETBIRD_STUN_PORT=3478
# Docker images
- DASHBOARD_IMAGE="netbirdio/dashboard:latest"
+ DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"}
# Combined server replaces separate signal, relay, and management containers
- NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest"
- NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:latest"
-
+ NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"}
+ NETBIRD_PROXY_IMAGE=${NETBIRD_PROXY_IMAGE:-"netbirdio/reverse-proxy:latest"}
+ TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.6"}
+ CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.7.7"}
# Reverse proxy configuration
REVERSE_PROXY_TYPE="0"
TRAEFIK_EXTERNAL_NETWORK=""
@@ -656,7 +657,7 @@ render_docker_compose_traefik_builtin() {
if [[ "$ENABLE_CROWDSEC" == "true" ]]; then
crowdsec_service="
crowdsec:
- image: crowdsecurity/crowdsec:v1.7.7
+ image: $CROWDSEC_IMAGE
container_name: netbird-crowdsec
restart: unless-stopped
networks: [netbird]
@@ -687,7 +688,7 @@ render_docker_compose_traefik_builtin() {
services:
# Traefik reverse proxy (automatic TLS via Let's Encrypt)
traefik:
- image: traefik:v3.6
+ image: $TRAEFIK_IMAGE
container_name: netbird-traefik
restart: unless-stopped
networks:
@@ -771,7 +772,7 @@ $traefik_dynamic_volume
labels:
- traefik.enable=true
# gRPC router (needs h2c backend for HTTP/2 cleartext)
- - traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`))
+ - traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`) || PathPrefix(\`/management.ProxyService/\`))
- traefik.http.routers.netbird-grpc.entrypoints=websecure
- traefik.http.routers.netbird-grpc.tls=true
- traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt
From 5993ec6e435aca0917a80fbf28c8001b1208759d Mon Sep 17 00:00:00 2001
From: Theodor Midtlien
Date: Thu, 4 Jun 2026 15:04:11 +0200
Subject: [PATCH 32/44] [client] Allow wireguard port to be zero in UI and show
port in status command (#6158)
* Allow wireguard port to be set to 0 in UI
* Add wireguard port to cmd status
* Correct protoc version
---
client/internal/engine.go | 1 +
client/internal/peer/status.go | 2 ++
client/proto/daemon.pb.go | 13 +++++++++++--
client/proto/daemon.proto | 1 +
client/proto/generate.sh | 11 +++++------
client/status/status.go | 9 +++++++++
client/status/status_test.go | 8 +++++++-
client/ui/client_ui.go | 8 ++++----
8 files changed, 40 insertions(+), 13 deletions(-)
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 048ff5fcc..1de7164a4 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -1072,6 +1072,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
state.FQDN = conf.GetFqdn()
+ state.WgPort = e.config.WgPort
e.statusRecorder.UpdateLocalPeerState(state)
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index c5fa4e9f9..b6c6c14ac 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -111,6 +111,7 @@ type LocalPeerState struct {
PubKey string
KernelInterface bool
FQDN string
+ WgPort int
Routes map[string]struct{}
}
@@ -1357,6 +1358,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
+ pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 2c054c99a..91a4ec10f 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -1614,6 +1614,7 @@ type LocalPeerState struct {
RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"`
Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
+ WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1704,6 +1705,13 @@ func (x *LocalPeerState) GetIpv6() string {
return ""
}
+func (x *LocalPeerState) GetWgPort() int32 {
+ if x != nil {
+ return x.WgPort
+ }
+ return 0
+}
+
// SignalState contains the latest state of a signal connection
type SignalState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -6389,7 +6397,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"sshHostKey\x18\x13 \x01(\fR\n" +
"sshHostKey\x12\x12\n" +
- "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
+ "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
"\x0eLocalPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
@@ -6398,7 +6406,8 @@ const file_daemon_proto_rawDesc = "" +
"\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" +
"\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" +
"\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" +
- "\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" +
+ "\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" +
+ "\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
"\vSignalState\x12\x10\n" +
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index dedff43e2..95260faa4 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -349,6 +349,7 @@ message LocalPeerState {
bool rosenpassPermissive = 6;
repeated string networks = 7;
string ipv6 = 8;
+ int32 wgPort = 9;
}
// SignalState contains the latest state of a signal connection
diff --git a/client/proto/generate.sh b/client/proto/generate.sh
index e659cef90..21e020ae6 100755
--- a/client/proto/generate.sh
+++ b/client/proto/generate.sh
@@ -1,17 +1,16 @@
#!/bin/bash
set -e
-if ! which realpath > /dev/null 2>&1
-then
- echo realpath is not installed
- echo run: brew install coreutils
- exit 1
+if ! which realpath >/dev/null 2>&1; then
+ echo realpath is not installed
+ echo run: brew install coreutils
+ exit 1
fi
old_pwd=$(pwd)
script_path=$(dirname $(realpath "$0"))
cd "$script_path"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
-go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
+go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
cd "$old_pwd"
diff --git a/client/status/status.go b/client/status/status.go
index 11ed06c2d..b9bb86a6e 100644
--- a/client/status/status.go
+++ b/client/status/status.go
@@ -143,6 +143,7 @@ type OutputOverview struct {
IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"`
PubKey string `json:"publicKey" yaml:"publicKey"`
KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"`
+ WgPort int `json:"wireguardPort" yaml:"wireguardPort"`
FQDN string `json:"fqdn" yaml:"fqdn"`
RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"`
RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"`
@@ -187,6 +188,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(),
PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(),
KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(),
+ WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()),
FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(),
RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(),
RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(),
@@ -547,6 +549,11 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
}
+ wgPortString := "N/A"
+ if o.WgPort > 0 {
+ wgPortString = fmt.Sprintf("%d", o.WgPort)
+ }
+
summary := fmt.Sprintf(
"OS: %s\n"+
"Daemon version: %s\n"+
@@ -560,6 +567,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"NetBird IP: %s\n"+
"%s"+
"Interface type: %s\n"+
+ "Wireguard port: %s\n"+
"Quantum resistance: %s\n"+
"Lazy connection: %s\n"+
"SSH Server: %s\n"+
@@ -578,6 +586,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
interfaceIP,
ipv6Line,
interfaceTypeString,
+ wgPortString,
rosenpassEnabledStatus,
lazyConnectionEnabledStatus,
sshServerStatus,
diff --git a/client/status/status_test.go b/client/status/status_test.go
index 0986bf0cd..1ae7157c0 100644
--- a/client/status/status_test.go
+++ b/client/status/status_test.go
@@ -94,6 +94,7 @@ var resp = &proto.StatusResponse{
Ipv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
+ WgPort: 51820,
Fqdn: "some-localhost.awesome-domain.com",
Networks: []string{
"10.10.0.0/24",
@@ -210,6 +211,7 @@ var overview = OutputOverview{
IPv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
+ WgPort: 51820,
FQDN: "some-localhost.awesome-domain.com",
NSServerGroups: []NsServerGroupStateOutput{
{
@@ -369,6 +371,7 @@ func TestParsingToJSON(t *testing.T) {
"netbirdIpv6": "fd00::100",
"publicKey": "Some-Pub-Key",
"usesKernelInterface": true,
+ "wireguardPort": 51820,
"fqdn": "some-localhost.awesome-domain.com",
"quantumResistance": false,
"quantumResistancePermissive": false,
@@ -487,6 +490,7 @@ netbirdIp: 192.168.178.100/16
netbirdIpv6: fd00::100
publicKey: Some-Pub-Key
usesKernelInterface: true
+wireguardPort: 51820
fqdn: some-localhost.awesome-domain.com
quantumResistance: false
quantumResistancePermissive: false
@@ -579,12 +583,13 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
+Wireguard port: %d
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
Networks: 10.10.0.0/24
Peers count: 2/2 Connected
-`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
+`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
assert.Equal(t, expectedDetail, detail)
}
@@ -604,6 +609,7 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
+Wireguard port: 51820
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
index c2129c7a2..c4b644354 100644
--- a/client/ui/client_ui.go
+++ b/client/ui/client_ui.go
@@ -502,7 +502,7 @@ func (s *serviceClient) getConnectionForm() *widget.Form {
{Text: "Pre-shared Key", Widget: s.iPreSharedKey},
{Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
{Text: "Interface Name", Widget: s.iInterfaceName},
- {Text: "Interface Port", Widget: s.iInterfacePort},
+ {Text: "Interface Port", Widget: s.iInterfacePort, HintText: "If set to 0, a random free port will be used"},
{Text: "MTU", Widget: s.iMTU},
{Text: "Log File", Widget: s.iLogFile},
},
@@ -558,8 +558,8 @@ func (s *serviceClient) parseNumericSettings() (int64, int64, error) {
if err != nil {
return 0, 0, errors.New("invalid interface port")
}
- if port < 1 || port > 65535 {
- return 0, 0, errors.New("invalid interface port: out of range 1-65535")
+ if port < 0 || port > 65535 {
+ return 0, 0, errors.New("invalid interface port: out of range 0-65535")
}
var mtu int64
@@ -1438,7 +1438,7 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
}
config.WgIface = cfg.InterfaceName
- if cfg.WireguardPort != 0 {
+ if cfg.WireguardPort >= 0 && cfg.WireguardPort <= 65535 {
config.WgPort = int(cfg.WireguardPort)
} else {
config.WgPort = iface.DefaultWgPort
From 512899d82d884bb5451e9cd540db324dbe5d2144 Mon Sep 17 00:00:00 2001
From: Theodor Midtlien
Date: Thu, 4 Jun 2026 17:36:45 +0200
Subject: [PATCH 33/44] [client] Prevent corruption from competing log rotation
and improve debug bundle (#6214)
* Adds heuristic to detect an edge case on Linux where a system has configured logrotate as a separate service to rotate log files which would mangle our client log files. If we detect logrotate being configured for netbird, we disable our rotation.
* Adds new env var to disable log rotation: NB_LOG_DISABLE_ROTATION
* Adds compressed and plain logrotate files to debug bundle.
* Replaces lumberjack with timberjack (maintained fork with bug fixes and extra features).
* Clarifies which daemon version is running in the bundle stats.
* Change logging for client service status to console
---
client/cmd/debug.go | 4 +
client/cmd/service_controller.go | 22 ++--
client/internal/debug/debug.go | 22 +++-
client/internal/debug/debug_logfiles_test.go | 103 +++++++++++++++++++
client/internal/engine.go | 2 +
client/proto/daemon.pb.go | 15 ++-
client/proto/daemon.proto | 1 +
client/proto/generate.sh | 2 +-
client/server/debug.go | 3 +
client/status/status.go | 14 ++-
client/ui/debug.go | 3 +
go.mod | 2 +-
go.sum | 4 +-
util/log.go | 54 +++++++---
util/log_test.go | 96 +++++++++++++++++
util/logrotate_linux.go | 93 +++++++++++++++++
util/logrotate_linux_test.go | 95 +++++++++++++++++
util/logrotate_nonlinux.go | 10 ++
18 files changed, 513 insertions(+), 32 deletions(-)
create mode 100644 client/internal/debug/debug_logfiles_test.go
create mode 100644 util/log_test.go
create mode 100644 util/logrotate_linux.go
create mode 100644 util/logrotate_linux_test.go
create mode 100644 util/logrotate_nonlinux.go
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index 2a8cdc887..02a742b28 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -19,6 +19,7 @@ import (
"github.com/netbirdio/netbird/client/server"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/upload-server/types"
+ "github.com/netbirdio/netbird/version"
)
const errCloseConnection = "Failed to close connection: %v"
@@ -100,6 +101,7 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
+ CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -298,6 +300,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
+ CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -432,6 +435,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
SyncResponse: syncResponse,
LogPath: logFilePath,
CPUProfile: nil,
+ DaemonVersion: version.NetbirdVersion(), // acting as daemon
},
debug.BundleConfig{
IncludeSystemInfo: true,
diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go
index 88121c067..8de147946 100644
--- a/client/cmd/service_controller.go
+++ b/client/cmd/service_controller.go
@@ -102,7 +102,7 @@ func (p *program) Stop(srv service.Service) error {
}
// Common setup for service control commands
-func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
+func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
// rootCmd env vars are already applied by PersistentPreRunE.
SetFlagsFromEnvVars(serviceCmd)
@@ -112,8 +112,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
return nil, err
}
- if err := util.InitLog(logLevel, logFiles...); err != nil {
- return nil, fmt.Errorf("init log: %w", err)
+ if consoleLog {
+ if err := util.InitLog(logLevel, util.LogConsole); err != nil {
+ return nil, fmt.Errorf("init log: %w", err)
+ }
+ } else {
+ if err := util.InitLog(logLevel, logFiles...); err != nil {
+ return nil, fmt.Errorf("init log: %w", err)
+ }
}
cfg, err := newSVCConfig()
@@ -138,7 +144,7 @@ var runCmd = &cobra.Command{
SetupCloseHandler(ctx, cancel)
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -152,7 +158,7 @@ var startCmd = &cobra.Command{
Short: "starts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -170,7 +176,7 @@ var stopCmd = &cobra.Command{
Short: "stops NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -188,7 +194,7 @@ var restartCmd = &cobra.Command{
Short: "restarts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -206,7 +212,7 @@ var svcStatusCmd = &cobra.Command{
Short: "shows NetBird service status",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
if err != nil {
return err
}
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index ebaf71b21..5176c17d7 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -254,6 +254,8 @@ type BundleGenerator struct {
capturePath string
refreshStatus func() // Optional callback to refresh status before bundle generation
clientMetrics MetricsExporter
+ daemonVersion string
+ cliVersion string
anonymize bool
includeSystemInfo bool
@@ -278,6 +280,8 @@ type GeneratorDependencies struct {
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
+ DaemonVersion string
+ CliVersion string
}
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
@@ -299,6 +303,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
capturePath: deps.CapturePath,
refreshStatus: deps.RefreshStatus,
clientMetrics: deps.ClientMetrics,
+ daemonVersion: deps.DaemonVersion,
+ cliVersion: deps.CliVersion,
anonymize: cfg.Anonymize,
includeSystemInfo: cfg.IncludeSystemInfo,
@@ -459,9 +465,11 @@ func (g *BundleGenerator) addStatus() error {
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
- Anonymize: g.anonymize,
- ProfileName: profName,
+ Anonymize: g.anonymize,
+ ProfileName: profName,
+ DaemonVersion: g.daemonVersion,
})
+ overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary()
statusReader := strings.NewReader(statusOutput)
@@ -1039,7 +1047,8 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
return
}
- pattern := filepath.Join(logDir, "client-*.log.gz")
+ // This regex will match both logs rotated by us and logrotate on linux
+ pattern := filepath.Join(logDir, "client*.log.*")
files, err := filepath.Glob(pattern)
if err != nil {
log.Warnf("failed to glob rotated logs: %v", err)
@@ -1072,7 +1081,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
- if err := g.addSingleLogFileGz(files[i], name); err != nil {
+ if strings.HasSuffix(name, ".gz") {
+ err = g.addSingleLogFileGz(files[i], name)
+ } else {
+ err = g.addSingleLogfile(files[i], name)
+ }
+ if err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)
}
}
diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go
new file mode 100644
index 000000000..f6473f979
--- /dev/null
+++ b/client/internal/debug/debug_logfiles_test.go
@@ -0,0 +1,103 @@
+package debug
+
+import (
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
+// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
+// and gzipped), and skips unrelated files.
+func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
+ dir := t.TempDir()
+
+ writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
+ writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
+
+ timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
+ writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
+
+ logrotatePlain := "client.log.1"
+ writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
+
+ logrotateGz := "client.log.2.gz"
+ writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
+
+ names := runAddRotatedLogFiles(t, dir, 10)
+
+ require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
+ require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
+ require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
+ require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
+ require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
+}
+
+// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
+// logFileCount rotated files are bundled, ordered by mtime.
+func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
+ dir := t.TempDir()
+
+ oldest := filepath.Join(dir, "client.log.3")
+ middle := filepath.Join(dir, "client.log.2")
+ newest := filepath.Join(dir, "client.log.1")
+ writeFile(t, oldest, "old\n")
+ writeFile(t, middle, "mid\n")
+ writeFile(t, newest, "new\n")
+
+ now := time.Now()
+ require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
+ require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
+ require.NoError(t, os.Chtimes(newest, now, now))
+
+ names := runAddRotatedLogFiles(t, dir, 2)
+
+ require.Contains(t, names, "client.log.1")
+ require.Contains(t, names, "client.log.2")
+ require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
+}
+
+// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
+// zip writer and returns the set of entry names that ended up in the archive.
+func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
+ t.Helper()
+
+ var buf bytes.Buffer
+ g := &BundleGenerator{
+ archive: zip.NewWriter(&buf),
+ logFileCount: logFileCount,
+ }
+ g.addRotatedLogFiles(dir)
+ require.NoError(t, g.archive.Close())
+
+ zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
+ require.NoError(t, err)
+
+ names := make(map[string]struct{}, len(zr.File))
+ for _, f := range zr.File {
+ names[f.Name] = struct{}{}
+ }
+ return names
+}
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
+}
+
+func writeGzFile(t *testing.T, path, content string) {
+ t.Helper()
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ _, err := io.WriteString(gw, content)
+ require.NoError(t, err)
+ require.NoError(t, gw.Close())
+ require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
+}
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 1de7164a4..980326720 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -72,6 +72,7 @@ import (
sProto "github.com/netbirdio/netbird/shared/signal/proto"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/capture"
+ "github.com/netbirdio/netbird/version"
)
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
@@ -1151,6 +1152,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
LogPath: e.config.LogPath,
TempDir: e.config.TempDir,
ClientMetrics: e.clientMetrics,
+ DaemonVersion: version.NetbirdVersion(),
RefreshStatus: func() {
e.RunHealthProbes(true)
},
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 91a4ec10f..79fa1418a 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -2717,6 +2717,7 @@ type DebugBundleRequest struct {
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"`
+ CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2779,6 +2780,13 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 {
return 0
}
+func (x *DebugBundleRequest) GetCliVersion() string {
+ if x != nil {
+ return x.CliVersion
+ }
+ return ""
+}
+
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -6484,14 +6492,17 @@ 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\"\x94\x01\n" +
+ "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
"systemInfo\x18\x03 \x01(\bR\n" +
"systemInfo\x12\x1c\n" +
"\tuploadURL\x18\x04 \x01(\tR\tuploadURL\x12\"\n" +
- "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" +
+ "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
+ "\n" +
+ "cliVersion\x18\x06 \x01(\tR\n" +
+ "cliVersion\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index 95260faa4..6982e4a1c 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -472,6 +472,7 @@ message DebugBundleRequest {
bool systemInfo = 3;
string uploadURL = 4;
uint32 logFileCount = 5;
+ string cliVersion = 6;
}
message DebugBundleResponse {
diff --git a/client/proto/generate.sh b/client/proto/generate.sh
index 21e020ae6..1ae55e380 100755
--- a/client/proto/generate.sh
+++ b/client/proto/generate.sh
@@ -8,7 +8,7 @@ if ! which realpath >/dev/null 2>&1; then
fi
old_pwd=$(pwd)
-script_path=$(dirname $(realpath "$0"))
+script_path=$(dirname "$(realpath "$0")")
cd "$script_path"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
diff --git a/client/server/debug.go b/client/server/debug.go
index 33247db5f..14dcaba33 100644
--- a/client/server/debug.go
+++ b/client/server/debug.go
@@ -14,6 +14,7 @@ import (
"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/version"
)
// DebugBundle creates a debug bundle and returns the location.
@@ -67,6 +68,8 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
CapturePath: capturePath,
RefreshStatus: refreshStatus,
ClientMetrics: clientMetrics,
+ DaemonVersion: version.NetbirdVersion(),
+ CliVersion: req.CliVersion,
},
debug.BundleConfig{
Anonymize: req.GetAnonymize(),
diff --git a/client/status/status.go b/client/status/status.go
index b9bb86a6e..e7e8ee11c 100644
--- a/client/status/status.go
+++ b/client/status/status.go
@@ -549,6 +549,16 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
}
+ daemonVersion := "N/A"
+ if o.DaemonVersion != "" {
+ daemonVersion = o.DaemonVersion
+ }
+
+ cliVersion := version.NetbirdVersion()
+ if o.CliVersion != "" {
+ cliVersion = o.CliVersion
+ }
+
wgPortString := "N/A"
if o.WgPort > 0 {
wgPortString = fmt.Sprintf("%d", o.WgPort)
@@ -575,8 +585,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"%s"+
"Peers count: %s\n",
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
- o.DaemonVersion,
- version.NetbirdVersion(),
+ daemonVersion,
+ cliVersion,
o.ProfileName,
managementConnString,
signalConnString,
diff --git a/client/ui/debug.go b/client/ui/debug.go
index cf5ac1a75..d3d4fa4f8 100644
--- a/client/ui/debug.go
+++ b/client/ui/debug.go
@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
uptypes "github.com/netbirdio/netbird/upload-server/types"
+ "github.com/netbirdio/netbird/version"
)
// Initial state for the debug collection
@@ -462,6 +463,7 @@ func (s *serviceClient) createDebugBundleFromCollection(
request := &proto.DebugBundleRequest{
Anonymize: params.anonymize,
SystemInfo: params.systemInfo,
+ CliVersion: version.NetbirdVersion(),
}
if params.upload {
@@ -593,6 +595,7 @@ func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploa
request := &proto.DebugBundleRequest{
Anonymize: anonymize,
SystemInfo: systemInfo,
+ CliVersion: version.NetbirdVersion(),
}
if uploadURL != "" {
diff --git a/go.mod b/go.mod
index caf9cb689..bafdeaf86 100644
--- a/go.mod
+++ b/go.mod
@@ -24,13 +24,13 @@ require (
golang.zx2c4.com/wireguard/windows v0.5.3
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
- gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
fyne.io/fyne/v2 v2.7.0
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
+ github.com/DeRuina/timberjack v1.4.2
github.com/awnumar/memguard v0.23.0
github.com/aws/aws-sdk-go-v2 v1.38.3
github.com/aws/aws-sdk-go-v2/config v1.31.6
diff --git a/go.sum b/go.sum
index 7f0081425..2f42f96b1 100644
--- a/go.sum
+++ b/go.sum
@@ -29,6 +29,8 @@ github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/DeRuina/timberjack v1.4.2 h1:4bKlzhKdsR+2oNkgef9mqb4n11ICow8VK88RfzJPzN8=
+github.com/DeRuina/timberjack v1.4.2/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
@@ -940,8 +942,6 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
-gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
diff --git a/util/log.go b/util/log.go
index b1de2d999..3896ff6bc 100644
--- a/util/log.go
+++ b/util/log.go
@@ -1,15 +1,16 @@
package util
import (
+ "fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv"
+ "github.com/DeRuina/timberjack"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/grpclog"
- "gopkg.in/natefinch/lumberjack.v2"
"github.com/netbirdio/netbird/formatter"
)
@@ -37,8 +38,7 @@ func InitLog(logLevel string, logs ...string) error {
func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
level, err := log.ParseLevel(logLevel)
if err != nil {
- logger.Errorf("Failed parsing log-level %s: %s", logLevel, err)
- return err
+ return fmt.Errorf("failed parsing log-level %s: %w", logLevel, err)
}
var writers []io.Writer
logFmt := os.Getenv("NB_LOG_FORMAT")
@@ -59,7 +59,11 @@ func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
case "":
logger.Warnf("empty log path received: %#v", logPath)
default:
- writers = append(writers, newRotatedOutput(logPath))
+ writer, err := setupLogFile(logPath, isRotationDisabled(logger))
+ if err != nil {
+ return fmt.Errorf("failed setting up log file: %s, %w", logPath, err)
+ }
+ writers = append(writers, writer)
}
}
@@ -94,17 +98,43 @@ func FindFirstLogPath(logs []string) string {
return ""
}
+func isRotationDisabled(logger *log.Logger) bool {
+ v, _ := os.LookupEnv("NB_LOG_DISABLE_ROTATION")
+ disabled, _ := strconv.ParseBool(v)
+ if disabled {
+ logger.Warnf("log rotation is disabled by env flag")
+ return true
+ }
+ conflict, configPath := FindFirstLogrotateConflict()
+ if conflict {
+ logger.Warnf("log rotation conflict detected in: %#v, rotation is disabled", configPath)
+ return true
+ }
+ return false
+}
+
+func setupLogFile(logPath string, disableRotation bool) (io.Writer, error) {
+ if disableRotation {
+ file, err := os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
+ if err != nil {
+ return nil, err
+ }
+ return file, nil
+ }
+ return newRotatedOutput(logPath), nil
+}
+
func newRotatedOutput(logPath string) io.Writer {
maxLogSize := getLogMaxSize()
- lumberjackLogger := &lumberjack.Logger{
+ timberjackLogger := &timberjack.Logger{
// Log file absolute path, os agnostic
- Filename: filepath.ToSlash(logPath),
- MaxSize: maxLogSize, // MB
- MaxBackups: 10,
- MaxAge: 30, // days
- Compress: true,
+ Filename: filepath.ToSlash(logPath),
+ MaxSize: maxLogSize, // MB
+ MaxBackups: 10,
+ MaxAge: 30, // days
+ Compression: "gzip",
}
- return lumberjackLogger
+ return timberjackLogger
}
func setGRPCLibLogger(logger *log.Logger) {
@@ -127,7 +157,7 @@ func getLogMaxSize() int {
if sizeVar, ok := os.LookupEnv("NB_LOG_MAX_SIZE_MB"); ok {
size, err := strconv.ParseInt(sizeVar, 10, 64)
if err != nil {
- log.Errorf("Failed parsing log-size %s: %s. Should be just an integer", sizeVar, err)
+ log.Errorf("failed parsing log-size %s: %s. Should be just an integer", sizeVar, err)
return defaultLogSize
}
diff --git a/util/log_test.go b/util/log_test.go
new file mode 100644
index 000000000..e9933e479
--- /dev/null
+++ b/util/log_test.go
@@ -0,0 +1,96 @@
+package util
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/require"
+)
+
+// TestSetupLogFile_RotatesOnSize drives >MaxSize bytes through the writer
+// returned by setupLogFile and asserts a backup file appears.
+func TestSetupLogFile_RotatesOnSize(t *testing.T) {
+ t.Setenv("NB_LOG_MAX_SIZE_MB", "1")
+
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "netbird.log")
+
+ w, err := setupLogFile(logPath, false)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ if c, ok := w.(io.Closer); ok {
+ _ = c.Close()
+ }
+ })
+
+ chunk := []byte(strings.Repeat("x", 64*1024) + "\n")
+ for range 20 {
+ _, err := w.Write(chunk)
+ require.NoError(t, err)
+ }
+
+ info, err := os.Stat(logPath)
+ require.NoError(t, err)
+ require.Less(t, info.Size(), int64(1<<20),
+ "active log should be < 1 MB after rotation, got %d", info.Size())
+
+ require.Eventually(t, func() bool {
+ entries, _ := os.ReadDir(dir)
+ for _, e := range entries {
+ name := e.Name()
+ if name == filepath.Base(logPath) {
+ continue
+ }
+ if strings.HasPrefix(name, "netbird-") && strings.HasSuffix(name, ".log.gz") {
+ return true
+ }
+ }
+ return false
+ }, 5*time.Second, 50*time.Millisecond, "expected a rotated backup file in %s", dir)
+}
+
+// TestSetupLogFile_RotationDisabled verifies that with rotation off, the file
+// grows past MaxSize and no backups are created.
+func TestSetupLogFile_RotationDisabled(t *testing.T) {
+ t.Setenv("NB_LOG_MAX_SIZE_MB", "1")
+
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "netbird.log")
+
+ w, err := setupLogFile(logPath, true)
+ require.NoError(t, err)
+
+ f, ok := w.(*os.File)
+ require.True(t, ok, "expected plain *os.File when rotation is disabled, got %T", w)
+ t.Cleanup(func() { _ = f.Close() })
+
+ chunk := []byte(strings.Repeat("y", 64*1024) + "\n")
+ for range 20 {
+ _, err := w.Write(chunk)
+ require.NoError(t, err)
+ }
+
+ info, err := os.Stat(logPath)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, info.Size(), int64(1<<20),
+ "file should exceed MaxSize when rotation is disabled, got %d", info.Size())
+
+ entries, err := os.ReadDir(dir)
+ require.NoError(t, err)
+ require.Len(t, entries, 1, "no backup files should exist when rotation is disabled, got %v", entries)
+}
+
+// TestIsRotationDisabled_EnvFlag covers the NB_LOG_DISABLE_ROTATION env path.
+// The logrotate-conflict branch is exercised separately on linux.
+func TestIsRotationDisabled_EnvFlag(t *testing.T) {
+ logger := log.New()
+ logger.SetOutput(io.Discard)
+
+ t.Setenv("NB_LOG_DISABLE_ROTATION", "true")
+ require.True(t, isRotationDisabled(logger))
+}
diff --git a/util/logrotate_linux.go b/util/logrotate_linux.go
new file mode 100644
index 000000000..7d2173ea8
--- /dev/null
+++ b/util/logrotate_linux.go
@@ -0,0 +1,93 @@
+//go:build linux
+
+package util
+
+import (
+ "bufio"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ defaultLogrotateConfPath = "/etc/logrotate.conf"
+ defaultLogrotateConfDir = "/etc/logrotate.d"
+ netbirdString = "netbird"
+)
+
+// FindLogrotateConflicts scans the standard logrotate locations for
+// indications of conflict with netbird. It returns true and the config file
+// path if a conflict was found.
+func FindFirstLogrotateConflict() (bool, string) {
+ return findFirstLogrotateConflictIn(defaultLogrotateConfPath, defaultLogrotateConfDir)
+}
+
+func findFirstLogrotateConflictIn(confPath, confDir string) (bool, string) {
+ for _, f := range listLogrotateConfigs(confPath, confDir) {
+ present, err := scanLogrotateFile(f, netbirdString)
+ if err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ log.Debugf("scan %s: %v", f, err)
+ }
+ continue
+ }
+ if present {
+ return present, f
+ }
+ }
+ return false, ""
+}
+
+// listLogrotateConfigs returns all config files for logrotate.
+func listLogrotateConfigs(confPath, confDir string) []string {
+ files := []string{confPath}
+ entries, err := os.ReadDir(confDir)
+ if err != nil {
+ return files
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ files = append(files, filepath.Join(confDir, e.Name()))
+ }
+ return files
+}
+
+// scanLogrotateFile reads a config and reports if a non-comment line
+// contains the given substring.
+func scanLogrotateFile(path string, substring string) (bool, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ if err := f.Close(); err != nil {
+ log.Debugf("close %s: %v", path, err)
+ }
+ }()
+
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(stripLogrotateComment(scanner.Text()))
+ if line == "" {
+ continue
+ }
+ if strings.Contains(line, substring) {
+ return true, nil
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return false, err
+ }
+ return false, nil
+}
+
+func stripLogrotateComment(line string) string {
+ before, _, _ := strings.Cut(line, "#")
+ return before
+}
diff --git a/util/logrotate_linux_test.go b/util/logrotate_linux_test.go
new file mode 100644
index 000000000..92d7e410b
--- /dev/null
+++ b/util/logrotate_linux_test.go
@@ -0,0 +1,95 @@
+//go:build linux
+
+package util
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFindFirstLogrotateConflict(t *testing.T) {
+ t.Run("conflict in confDir", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ conflictPath := filepath.Join(confDir, "netbird")
+ writeLogrotateConfig(t, conflictPath, `/var/log/netbird/*.log {
+ daily
+ rotate 7
+}`)
+ writeLogrotateConfig(t, filepath.Join(confDir, "nginx"), `/var/log/nginx/*.log { daily }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.True(t, got)
+ require.Equal(t, conflictPath, path)
+ })
+
+ t.Run("conflict in main conf file", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ writeLogrotateConfig(t, confPath, `weekly
+rotate 4
+include /etc/logrotate.d
+/var/log/netbird/client.log { rotate 5 }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.True(t, got)
+ require.Equal(t, confPath, path)
+ })
+
+ t.Run("no conflict when netbird is absent", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ writeLogrotateConfig(t, filepath.Join(confDir, "nginx"), `/var/log/nginx/*.log { daily }`)
+ writeLogrotateConfig(t, filepath.Join(confDir, "syslog"), `/var/log/syslog { weekly }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+
+ t.Run("commented-out netbird line is ignored", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ writeLogrotateConfig(t, filepath.Join(confDir, "misc"), `# /var/log/netbird/*.log { daily }
+/var/log/other.log { weekly }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+
+ t.Run("subdirectories in confDir are ignored", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ sub := filepath.Join(confDir, "nested")
+ require.NoError(t, os.MkdirAll(sub, 0o755))
+ writeLogrotateConfig(t, filepath.Join(sub, "netbird"), `/var/log/netbird/*.log { daily }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+
+ t.Run("missing paths return no conflict", func(t *testing.T) {
+ dir := t.TempDir()
+ got, path := findFirstLogrotateConflictIn(
+ filepath.Join(dir, "does-not-exist.conf"),
+ filepath.Join(dir, "does-not-exist.d"),
+ )
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+}
+
+// newLogrotateLayout creates a temp logrotate.conf path and logrotate.d dir,
+// returning their paths. The conf file itself is not created.
+func newLogrotateLayout(t *testing.T) (confPath, confDir string) {
+ t.Helper()
+ root := t.TempDir()
+ confDir = filepath.Join(root, "logrotate.d")
+ require.NoError(t, os.MkdirAll(confDir, 0o755))
+ return filepath.Join(root, "logrotate.conf"), confDir
+}
+
+func writeLogrotateConfig(t *testing.T, path, body string) {
+ t.Helper()
+ require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
+}
diff --git a/util/logrotate_nonlinux.go b/util/logrotate_nonlinux.go
new file mode 100644
index 000000000..0a188b864
--- /dev/null
+++ b/util/logrotate_nonlinux.go
@@ -0,0 +1,10 @@
+//go:build !linux
+
+package util
+
+// FindLogrotateConflicts scans the standard logrotate locations for
+// indications of conflict with netbird. It will always return false for
+// non-linux devices.
+func FindFirstLogrotateConflict() (bool, string) {
+ return false, ""
+}
From b377d9993334bc5fd73d64c6c468a60653996148 Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Fri, 5 Jun 2026 22:45:49 +0200
Subject: [PATCH 34/44] [management] Copy private field on shallowCloneMapping
(#6347)
* [management] Copy private field on shallowCloneMapping
added test to ensure clone handles new fields
* Remove unnecessary debug logs from proxy service
* Increase Wasm binary size limit to 60MB in build validation
---
.github/workflows/wasm-build-validation.yml | 4 +-
management/internals/shared/grpc/proxy.go | 1 +
.../internals/shared/grpc/proxy_clone_test.go | 88 +++++++++++++++++++
3 files changed, 91 insertions(+), 2 deletions(-)
create mode 100644 management/internals/shared/grpc/proxy_clone_test.go
diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index dd39d979d..318a127dd 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -65,7 +65,7 @@ jobs:
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
- if [ ${SIZE} -gt 58720256 ]; then
- echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
+ if [ ${SIZE} -gt 62914560 ]; then
+ echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
exit 1
fi
diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go
index e7155ae09..72735b210 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -978,6 +978,7 @@ func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping {
Mode: m.Mode,
ListenPort: m.ListenPort,
AccessRestrictions: m.AccessRestrictions,
+ Private: m.Private,
}
}
diff --git a/management/internals/shared/grpc/proxy_clone_test.go b/management/internals/shared/grpc/proxy_clone_test.go
new file mode 100644
index 000000000..f00d40fae
--- /dev/null
+++ b/management/internals/shared/grpc/proxy_clone_test.go
@@ -0,0 +1,88 @@
+package grpc
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// authTokenField is the only per-proxy field that shallowCloneMapping must NOT
+// copy from the source, since callers assign it individually after cloning.
+const authTokenField = "AuthToken"
+
+// TestShallowCloneMapping_ClonesAllFields populates every exported field of
+// ProxyMapping with a non-zero value and verifies the clone carries each one
+// (except AuthToken). It uses reflection so adding a new field to ProxyMapping
+// without updating shallowCloneMapping fails this test.
+func TestShallowCloneMapping_ClonesAllFields(t *testing.T) {
+ src := &proto.ProxyMapping{}
+ populated := populateExportedFields(t, reflect.ValueOf(src).Elem())
+ require.NotEmpty(t, populated, "ProxyMapping should expose fields to populate")
+
+ clone := shallowCloneMapping(src)
+ require.NotNil(t, clone, "clone must not be nil")
+
+ srcVal := reflect.ValueOf(src).Elem()
+ cloneVal := reflect.ValueOf(clone).Elem()
+
+ for _, name := range populated {
+ srcField := srcVal.FieldByName(name).Interface()
+ cloneField := cloneVal.FieldByName(name).Interface()
+
+ if name == authTokenField {
+ assert.Zero(t, cloneField, "AuthToken must not be cloned; it is set per proxy after cloning")
+ continue
+ }
+
+ assert.Equal(t, srcField, cloneField, "field %s must be carried over by shallowCloneMapping", name)
+ }
+}
+
+// populateExportedFields sets a non-zero value on every settable exported field
+// of the struct and returns their names.
+func populateExportedFields(t *testing.T, v reflect.Value) []string {
+ t.Helper()
+
+ var names []string
+ typ := v.Type()
+ for i := 0; i < v.NumField(); i++ {
+ field := v.Field(i)
+ structField := typ.Field(i)
+
+ if structField.PkgPath != "" || !field.CanSet() {
+ continue
+ }
+
+ setNonZero(t, field, structField.Name)
+ names = append(names, structField.Name)
+ }
+ return names
+}
+
+// setNonZero assigns a deterministic non-zero value based on the field kind.
+func setNonZero(t *testing.T, field reflect.Value, name string) {
+ t.Helper()
+
+ switch field.Kind() {
+ case reflect.String:
+ field.SetString("non-zero-" + name)
+ case reflect.Bool:
+ field.SetBool(true)
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ field.SetInt(7)
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ field.SetUint(7)
+ case reflect.Ptr:
+ field.Set(reflect.New(field.Type().Elem()))
+ case reflect.Slice:
+ field.Set(reflect.MakeSlice(field.Type(), 1, 1))
+ case reflect.Map:
+ field.Set(reflect.MakeMapWithSize(field.Type(), 0))
+ default:
+ t.Fatalf("unhandled field kind %s for field %s; extend setNonZero", field.Kind(), name)
+ }
+}
From 1e7b16db0aee0002dc1b1ee2b5733d965baeac08 Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Sat, 6 Jun 2026 12:56:01 +0200
Subject: [PATCH 35/44] [management] resolve private services on custom domains
in synthesized DNS zones (#6348)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
private services on a custom domain didn't resolve on clients โ the synthesized DNS zone was anchored to the cluster, and the account's custom domains weren't even
loaded.
- account.go โ SynthesizePrivateServiceZones now keys zones by a resolved apex (privateServiceDomainZone): cluster suffix โ registered account.Domains (filtered by matching
TargetCluster, longest wins) โ skip if none. One zone per apex; custom-domain services group under their registered domain.
- sql_store.go โ GetAccount now loads account.Domains on both loaders (gorm Preload("Domains") + pgx goroutine via ListCustomDomains; errChan buffer bumped 12โ16). This was
the reason the deploy didn't work โ the relation was empty in prod.
- Tests โ custom-domain zone synthesis cases (apex resolution, free+custom separation, sibling collapse, cluster mismatch, mixed cluster/custom/public) + GetAccount
domain-preload tests on sqlite and Postgres.
---
.github/workflows/release.yml | 4 +-
management/server/store/sql_store.go | 14 +-
.../store/sql_store_get_account_test.go | 59 ++++++
management/server/types/account.go | 46 ++++-
.../types/account_private_zones_test.go | 177 ++++++++++++++++++
5 files changed, 290 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cae6aa873..b15185198 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -29,10 +29,10 @@ jobs:
persist-credentials: false
- name: Generate FreeBSD port diff
- run: bash release_files/freebsd-port-diff.sh
+ run: bash -x release_files/freebsd-port-diff.sh
- name: Generate FreeBSD port issue body
- run: bash release_files/freebsd-port-issue-body.sh
+ run: bash -x release_files/freebsd-port-issue-body.sh
- name: Check if diff was generated
id: check_diff
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index b6691ac79..c6ced2642 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -1216,6 +1216,7 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types
Preload("NetworkResources").
Preload("Onboarding").
Preload("Services.Targets").
+ Preload("Domains").
Take(&account, idQueryCondition, accountID)
if result.Error != nil {
log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error)
@@ -1302,7 +1303,7 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
}
var wg sync.WaitGroup
- errChan := make(chan error, 12)
+ errChan := make(chan error, 16)
wg.Add(1)
go func() {
@@ -1403,6 +1404,17 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
account.Services = services
}()
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ domains, err := s.ListCustomDomains(ctx, accountID)
+ if err != nil {
+ errChan <- err
+ return
+ }
+ account.Domains = domains
+ }()
+
wg.Add(1)
go func() {
defer wg.Done()
diff --git a/management/server/store/sql_store_get_account_test.go b/management/server/store/sql_store_get_account_test.go
index 9a9de8cdd..56f2a6c41 100644
--- a/management/server/store/sql_store_get_account_test.go
+++ b/management/server/store/sql_store_get_account_test.go
@@ -4,6 +4,8 @@ import (
"context"
"net"
"net/netip"
+ "os"
+ "runtime"
"testing"
"time"
@@ -21,6 +23,63 @@ import (
"github.com/netbirdio/netbird/route"
)
+// TestGetAccount_LoadsCustomDomains verifies GetAccount populates account.Domains.
+// SynthesizePrivateServiceZones depends on this relation to anchor a custom-domain
+// private service's DNS zone; without the preload the relation is empty and the
+// service is silently skipped, so a custom domain never resolves on clients.
+func TestGetAccount_LoadsCustomDomains(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("The SQLite store is not properly supported by Windows yet")
+ }
+
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
+ require.NoError(t, err)
+ defer cleanup()
+
+ assertGetAccountLoadsCustomDomains(t, store)
+}
+
+func TestPostgresql_GetAccount_LoadsCustomDomains(t *testing.T) {
+ if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
+ t.Skip("skip CI tests on darwin and windows")
+ }
+
+ t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine))
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
+ require.NoError(t, err)
+ t.Cleanup(cleanup)
+
+ assertGetAccountLoadsCustomDomains(t, store)
+}
+
+// assertGetAccountLoadsCustomDomains exercises both the gorm and pgx GetAccount
+// paths: it persists two custom domains and asserts the relation comes back
+// populated, which SynthesizePrivateServiceZones relies on.
+func assertGetAccountLoadsCustomDomains(t *testing.T, store Store) {
+ t.Helper()
+ ctx := context.Background()
+
+ accountID := "acct-custom-domains"
+ require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, accountID, "user-1", "")))
+
+ _, err := store.CreateCustomDomain(ctx, accountID, "example.com", "eu.proxy.netbird.io", true)
+ require.NoError(t, err, "creating the first custom domain must succeed")
+ _, err = store.CreateCustomDomain(ctx, accountID, "apps.acme.io", "us.proxy.netbird.io", false)
+ require.NoError(t, err, "creating the second custom domain must succeed")
+
+ account, err := store.GetAccount(ctx, accountID)
+ require.NoError(t, err)
+ require.Len(t, account.Domains, 2, "GetAccount must preload the account's custom domains")
+
+ byDomain := map[string]string{}
+ for _, d := range account.Domains {
+ require.NotNil(t, d)
+ byDomain[d.Domain] = d.TargetCluster
+ }
+ assert.Equal(t, "eu.proxy.netbird.io", byDomain["example.com"], "custom domain must carry its target cluster")
+ assert.Equal(t, "us.proxy.netbird.io", byDomain["apps.acme.io"], "custom domain must carry its target cluster")
+}
+
// TestGetAccount_ComprehensiveFieldValidation validates that GetAccount properly loads
// all fields and nested objects from the database, including deeply nested structures.
func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) {
diff --git a/management/server/types/account.go b/management/server/types/account.go
index 0d0893e28..d658f605d 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -273,7 +273,7 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
}
peerGroups := a.GetPeerGroups(peerID)
- zonesByCluster := map[string]*nbdns.CustomZone{}
+ zonesByApex := map[string]*nbdns.CustomZone{}
for _, svc := range a.Services {
if svc == nil || !svc.Enabled || !svc.Private {
@@ -290,19 +290,24 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
continue
}
- zone, exists := zonesByCluster[svc.ProxyCluster]
+ serviceDomainZone := a.privateServiceDomainZone(svc)
+ if serviceDomainZone == "" {
+ continue
+ }
+
+ zone, exists := zonesByApex[serviceDomainZone]
if !exists {
// NonAuthoritative makes this a match-only zone: queries for
// names without an explicit record fall through to the
// upstream resolver instead of returning NXDOMAIN. Without
// it, adding a single private service would black-hole every
- // other name under the cluster apex.
+ // other name under the zone apex.
zone = &nbdns.CustomZone{
- Domain: dns.Fqdn(svc.ProxyCluster),
+ Domain: dns.Fqdn(serviceDomainZone),
Records: []nbdns.SimpleRecord{},
NonAuthoritative: true,
}
- zonesByCluster[svc.ProxyCluster] = zone
+ zonesByApex[serviceDomainZone] = zone
}
emitted := 0
@@ -340,8 +345,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
}
}
- out := make([]nbdns.CustomZone, 0, len(zonesByCluster))
- for _, zone := range zonesByCluster {
+ out := make([]nbdns.CustomZone, 0, len(zonesByApex))
+ for _, zone := range zonesByApex {
if len(zone.Records) == 0 {
continue
}
@@ -357,6 +362,33 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
return out
}
+// privateServiceDomainZone returns the DNS zone name for the given private service domain by
+// looking at the proxy cluster domain then the custom domains.
+func (a *Account) privateServiceDomainZone(svc *service.Service) string {
+ if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
+ return svc.ProxyCluster
+ }
+
+ // Longest matching custom domain wins
+ zoneName := ""
+ for _, d := range a.Domains {
+ if d == nil || d.TargetCluster != svc.ProxyCluster {
+ continue
+ }
+ if domainFromSuffix(svc.Domain, d.Domain) && len(d.Domain) > len(zoneName) {
+ zoneName = d.Domain
+ }
+ }
+ return zoneName
+}
+
+func domainFromSuffix(domain, suffix string) bool {
+ if suffix == "" {
+ return false
+ }
+ return domain == suffix || strings.HasSuffix(domain, "."+suffix)
+}
+
// peerInDistributionGroups reports whether any of the peer's groups
// matches the service's bearer-auth distribution_groups.
func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string) bool {
diff --git a/management/server/types/account_private_zones_test.go b/management/server/types/account_private_zones_test.go
index 1d4f720b7..efbbbffaf 100644
--- a/management/server/types/account_private_zones_test.go
+++ b/management/server/types/account_private_zones_test.go
@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
+ proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
)
@@ -234,6 +235,113 @@ func TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone(t *testi
assert.False(t, ok, "peer outside the distribution_groups must not see the synth zone")
}
+func TestSynthesizePrivateServiceZones_CustomDomain_ZoneApexIsRegisteredDomain(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ // A custom-domain service: Domain is the custom FQDN, ProxyCluster
+ // is the cluster serving it, and account.Domains holds the registered
+ // custom domain. The synth zone apex must be the registered domain,
+ // not the cluster, or the client's match-only zone never intercepts
+ // the query.
+ account.Services[0].Domain = "app.example.com"
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "custom-domain service must still produce one zone")
+ zone := zones[0]
+ assert.Equal(t, "example.com.", zone.Domain, "zone apex must be the registered custom domain, not the cluster or the service FQDN")
+ assert.True(t, zone.NonAuthoritative, "synth zone must remain match-only")
+ require.Len(t, zone.Records, 1, "custom-domain service yields one A record")
+ rec := zone.Records[0]
+ assert.Equal(t, "app.example.com.", rec.Name, "record name is the custom service FQDN")
+ assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
+}
+
+func TestSynthesizePrivateServiceZones_CustomAndFreeDomain_SeparateZones(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+ account.Services = append(account.Services, &service.Service{
+ ID: "svc-2",
+ AccountID: "acct-1",
+ Name: "custom",
+ Domain: "app.example.com",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ })
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 2, "a free-domain and a custom-domain service must not collapse into one zone")
+
+ free, ok := findCustomZone(zones, "eu.proxy.netbird.io")
+ require.True(t, ok, "free-domain service keeps the shared cluster-apex zone")
+ require.Len(t, free.Records, 1, "cluster zone carries only the free-domain record")
+ assert.Equal(t, "myapp.eu.proxy.netbird.io.", free.Records[0].Name, "cluster zone record is the free-domain FQDN")
+
+ custom, ok := findCustomZone(zones, "example.com")
+ require.True(t, ok, "custom-domain service gets its own zone at the registered custom domain apex")
+ require.Len(t, custom.Records, 1, "custom zone carries only the custom-domain record")
+ assert.Equal(t, "app.example.com.", custom.Records[0].Name, "custom zone record is the custom-domain FQDN")
+}
+
+func TestSynthesizePrivateServiceZones_TwoServicesSameCustomDomain_OneZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+ account.Services[0].Domain = "a.example.com"
+ account.Services = append(account.Services, &service.Service{
+ ID: "svc-2",
+ AccountID: "acct-1",
+ Name: "bapp",
+ Domain: "b.example.com",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ })
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "two services under the same registered custom domain must share one zone")
+ assert.Equal(t, "example.com.", zones[0].Domain, "shared zone apex is the registered custom domain")
+ require.Len(t, zones[0].Records, 2, "both services surface as records in the shared custom-domain zone")
+ names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
+ assert.ElementsMatch(t, []string{"a.example.com.", "b.example.com."}, names, "both custom-domain service FQDNs must surface")
+}
+
+func TestSynthesizePrivateServiceZones_CustomDomainNotRegistered_NoZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ // Service domain is outside the cluster and no account.Domains entry
+ // covers it: there is no apex that would intercept the query, so the
+ // service must be skipped rather than emit an unmatchable record.
+ account.Services[0].Domain = "app.example.com"
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "a custom-domain service with no registered domain apex must not produce a zone")
+}
+
+func TestSynthesizePrivateServiceZones_CustomDomainClusterMismatch_NoZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ // The registered custom domain matches the service FQDN by suffix but
+ // targets a different cluster than the service's ProxyCluster. It must
+ // be ignored, leaving no apex to intercept the query โ otherwise the
+ // zone would point at this cluster's proxy peers under a domain owned
+ // by a different cluster.
+ account.Services[0].Domain = "app.example.com"
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "us.proxy.netbird.io", Validated: true},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "a custom domain targeting a different cluster must not anchor the service zone")
+}
+
func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing.T) {
account := privateZoneTestAccount(t)
account.Services = append(account.Services, &service.Service{
@@ -254,3 +362,72 @@ func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing
names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
assert.ElementsMatch(t, []string{"myapp.eu.proxy.netbird.io.", "anotherapp.eu.proxy.netbird.io."}, names, "both service domains must surface")
}
+
+func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+
+ privateService := func(id, domain string) *service.Service {
+ return &service.Service{
+ ID: id,
+ AccountID: "acct-1",
+ Name: id,
+ Domain: domain,
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ }
+ }
+ publicService := func(id, domain string) *service.Service {
+ s := privateService(id, domain)
+ s.Private = false
+ return s
+ }
+
+ account.Services = []*service.Service{
+ // 3 private services under the cluster suffix.
+ privateService("cluster-1", "cluster1.eu.proxy.netbird.io"),
+ privateService("cluster-2", "cluster2.eu.proxy.netbird.io"),
+ privateService("cluster-3", "cluster3.eu.proxy.netbird.io"),
+ // 4 private services under the custom domain suffix.
+ privateService("custom-1", "custom1.example.com"),
+ privateService("custom-2", "custom2.example.com"),
+ privateService("custom-3", "custom3.example.com"),
+ privateService("custom-4", "custom4.example.com"),
+ // 2 public services, one per suffix, must not surface.
+ publicService("public-cluster", "public.eu.proxy.netbird.io"),
+ publicService("public-custom", "public.example.com"),
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 2, "one zone per apex: the cluster apex and the custom domain apex")
+
+ cluster, ok := findCustomZone(zones, "eu.proxy.netbird.io")
+ require.True(t, ok, "cluster-suffix services collapse into the cluster-apex zone")
+ clusterNames := recordNames(cluster)
+ assert.ElementsMatch(t,
+ []string{"cluster1.eu.proxy.netbird.io.", "cluster2.eu.proxy.netbird.io.", "cluster3.eu.proxy.netbird.io."},
+ clusterNames,
+ "only the 3 private cluster services surface in the cluster zone (public one excluded)")
+
+ custom, ok := findCustomZone(zones, "example.com")
+ require.True(t, ok, "custom-suffix services collapse into the custom-domain-apex zone")
+ customNames := recordNames(custom)
+ assert.ElementsMatch(t,
+ []string{"custom1.example.com.", "custom2.example.com.", "custom3.example.com.", "custom4.example.com."},
+ customNames,
+ "only the 4 private custom services surface in the custom zone (public one excluded)")
+}
+
+// recordNames returns the record names of a zone for order-independent assertions.
+func recordNames(zone nbdns.CustomZone) []string {
+ names := make([]string, 0, len(zone.Records))
+ for _, r := range zone.Records {
+ names = append(names, r.Name)
+ }
+ return names
+}
From 60d2fa08b0b5e96355d82a28547833190d0c2586 Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Mon, 8 Jun 2026 13:17:04 +0200
Subject: [PATCH 36/44] [client] Mask sensitive data in debug bundle creation
(#6364)
* [client] Mask sensitive data in debug bundle creation
* Avoid nil reference in turn and use masked constant
---
client/internal/debug/debug.go | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index 5176c17d7..9ab18dd80 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -806,6 +806,8 @@ func (g *BundleGenerator) addSyncResponse() error {
AllowPartial: true,
}
+ g.maskSecrets()
+
jsonBytes, err := options.Marshal(g.syncResponse)
if err != nil {
return fmt.Errorf("generate json: %w", err)
@@ -818,6 +820,27 @@ func (g *BundleGenerator) addSyncResponse() error {
return nil
}
+func (g *BundleGenerator) maskSecrets() {
+ if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil {
+ return
+ }
+
+ if g.syncResponse.NetbirdConfig.Flow != nil {
+ g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue
+
+ }
+
+ if g.syncResponse.NetbirdConfig.Relay != nil {
+ g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue
+ }
+
+ for i := range g.syncResponse.NetbirdConfig.Turns {
+ if g.syncResponse.NetbirdConfig.Turns[i] != nil {
+ g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue
+ }
+ }
+}
+
func (g *BundleGenerator) addStateFile() error {
sm := profilemanager.NewServiceManager("")
path := sm.GetStatePath()
From d3b63c6be9e60779b24c53c3ee3298cc228b4279 Mon Sep 17 00:00:00 2001
From: PizzaLovingNerd
Date: Mon, 8 Jun 2026 12:38:46 -0700
Subject: [PATCH 37/44] [infrastructure] Better support for atomic distros in
install.sh, docker fixes in getting-started.sh (#6139)
* Made the docker check first for getting-started.sh, better atomic support for install.sh
* Check for docker socket perms
* Added fallback for systems without rpm-ostree or bootc.
* macOS fix for docker socket check
* Change error message for docker group.
No longer using a blanket recommendation for the docker group.
---
infrastructure_files/getting-started.sh | 45 ++++++++++++++++++++++++-
release_files/install.sh | 21 ++++++++++--
2 files changed, 62 insertions(+), 4 deletions(-)
diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh
index 910cea095..770cecc44 100755
--- a/infrastructure_files/getting-started.sh
+++ b/infrastructure_files/getting-started.sh
@@ -19,6 +19,46 @@ readonly MSG_SEPARATOR="=========================================="
# Utility Functions
############################################
+check_docker_sock_perms() {
+ local sock="${DOCKER_HOST:-unix:///var/run/docker.sock}"
+ sock="${sock#unix://}"
+
+ if [[ ! -S "$sock" ]]; then
+ return 0
+ fi
+
+ if [[ ! -r "$sock" ]] || [[ ! -w "$sock" ]]; then
+ local group
+ if [[ "${OSTYPE}" == "darwin"* ]]; then
+ group="$(stat -f '%Sg' "$sock")"
+ else
+ group="$(stat -c '%G' "$sock")"
+ fi
+
+ echo "Cannot access Docker socket: $sock" > /dev/stderr
+ echo "" > /dev/stderr
+ echo "Socket permissions:" > /dev/stderr
+ ls -l "$sock" > /dev/stderr
+ echo "" > /dev/stderr
+
+ if [[ "$group" == "docker" ]]; then
+ echo "Your user may need to be added to the '$group' group:" > /dev/stderr
+ echo " sudo usermod -aG $group \"$USER\"" > /dev/stderr
+ echo "Then log out and back in, or run this for the current shell:" > /dev/stderr
+ echo " newgrp $group" > /dev/stderr
+ echo "Note: newgrp is temporary; usermod is the permanent group change." > /dev/stderr
+ else
+ echo "The Docker socket is owned by the '$group' group, which is not the standard 'docker' group." > /dev/stderr
+ echo "For safety, this script will not suggest adding your user to '$group'." > /dev/stderr
+ echo "Instead, either run this script with appropriate privileges (for example, via sudo) or follow Docker's post-install steps to configure access via the 'docker' group:" > /dev/stderr
+ echo " https://docs.docker.com/engine/install/linux-postinstall/" > /dev/stderr
+ fi
+
+ exit 1
+ fi
+ return 0
+}
+
check_docker_compose() {
if command -v docker-compose &> /dev/null
then
@@ -581,12 +621,15 @@ start_services_and_show_instructions() {
}
init_environment() {
+ # Check if docker compose is installed using check_docker_compose function
+ DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+ check_docker_sock_perms
+
initialize_default_values
configure_domain
configure_reverse_proxy
check_jq
- DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
check_existing_installation
generate_configuration_files
diff --git a/release_files/install.sh b/release_files/install.sh
index 1e71936f3..a002de472 100755
--- a/release_files/install.sh
+++ b/release_files/install.sh
@@ -417,15 +417,30 @@ if type uname >/dev/null 2>&1; then
# Check the availability of a compatible package manager
if check_use_bin_variable; then
PACKAGE_MANAGER="bin"
+ elif [ -e /run/ostree-booted ]; then
+ if [ -x "$(command -v rpm-ostree)" ]; then
+ PACKAGE_MANAGER="rpm-ostree"
+ echo "The installation will be performed using rpm-ostree package manager"
+ elif [ -x "$(command -v bootc)" ]; then
+ echo "Detected bootc system without rpm-ostree." >&2
+ echo "NetBird cannot be installed via package manager on this system." >&2
+ echo "Options:" >&2
+ echo " 1. Install via Distrobox (instructions in the installation docs)" >&2
+ echo " 2. Rebuild your base image with rpm-ostree included" >&2
+ echo " 3. Bake NetBird into your Containerfile" >&2
+ exit 1
+ else
+ echo "Detected ostree-booted system without rpm-ostree or bootc." >&2
+ echo "NetBird cannot be installed automatically on this atomic system." >&2
+ echo "Please install NetBird by rebuilding your base image or use a supported package manager." >&2
+ exit 1
+ fi
elif [ -x "$(command -v apt-get)" ]; then
PACKAGE_MANAGER="apt"
echo "The installation will be performed using apt package manager"
elif [ -x "$(command -v dnf)" ]; then
PACKAGE_MANAGER="dnf"
echo "The installation will be performed using dnf package manager"
- elif [ -x "$(command -v rpm-ostree)" ]; then
- PACKAGE_MANAGER="rpm-ostree"
- echo "The installation will be performed using rpm-ostree package manager"
elif [ -x "$(command -v yum)" ]; then
PACKAGE_MANAGER="yum"
echo "The installation will be performed using yum package manager"
From 8e1d5b78c251837ed796fbcdf56eed43ab833933 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 9 Jun 2026 17:24:17 +0900
Subject: [PATCH 38/44] [client] Preserve user deselect-all across management
route sync (#6363)
---
client/internal/routemanager/manager.go | 7 ++
.../routemanager/selector_management_test.go | 71 +++++++++++++++++++
.../internal/routeselector/routeselector.go | 8 +++
3 files changed, 86 insertions(+)
create mode 100644 client/internal/routemanager/selector_management_test.go
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index 839ec14c0..f10a2b5e0 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -700,6 +700,13 @@ func resolveURLsToIPs(urls []string) []net.IP {
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
+ // An explicit user "deselect all" must not be overridden by management auto-apply.
+ // Auto-applying an exit node here would call SelectRoutes, which clears the
+ // deselect-all flag and re-enables every route the user turned off.
+ if m.routeSelector.IsDeselectAll() {
+ return
+ }
+
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
if len(exitNodeInfo.allIDs) == 0 {
return
diff --git a/client/internal/routemanager/selector_management_test.go b/client/internal/routemanager/selector_management_test.go
new file mode 100644
index 000000000..04659db65
--- /dev/null
+++ b/client/internal/routemanager/selector_management_test.go
@@ -0,0 +1,71 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap {
+ haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0")
+ return route.HAMap{
+ haID: []*route.Route{
+ {
+ ID: "r-" + route.ID(netID),
+ NetID: netID,
+ Network: netip.MustParsePrefix("0.0.0.0/0"),
+ NetworkType: route.IPv4Network,
+ Enabled: true,
+ SkipAutoApply: skipAutoApply,
+ },
+ },
+ }
+}
+
+func TestUpdateRouteSelectorFromManagement(t *testing.T) {
+ t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter")
+ })
+
+ t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out")
+ })
+
+ t.Run("user selection is not overridden by management", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"}))
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter")
+ })
+
+ t.Run("deselect-all is preserved across a management sync", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ m.routeSelector.DeselectAllRoutes()
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set")
+ })
+}
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index 2ddc24bf2..b9991cd37 100644
--- a/client/internal/routeselector/routeselector.go
+++ b/client/internal/routeselector/routeselector.go
@@ -116,6 +116,14 @@ func (rs *RouteSelector) DeselectAllRoutes() {
clear(rs.selectedRoutes)
}
+// IsDeselectAll reports whether the user has explicitly deselected all routes.
+func (rs *RouteSelector) IsDeselectAll() bool {
+ rs.mu.RLock()
+ defer rs.mu.RUnlock()
+
+ return rs.deselectAll
+}
+
// IsSelected checks if a specific route is selected.
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
rs.mu.RLock()
From 106527182ffda88676fc165f31d33062d789e2ca Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 9 Jun 2026 17:24:51 +0900
Subject: [PATCH 39/44] [client] Snapshot iptables rule maps before persisting
state (#6345)
---
client/firewall/iptables/acl_linux.go | 14 +++++++----
client/firewall/iptables/router_linux.go | 11 +++++++--
client/firewall/iptables/rulestore_linux.go | 26 ++++++++++++++++++++-
3 files changed, 44 insertions(+), 7 deletions(-)
diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go
index e5e19cec9..4b4cebf9c 100644
--- a/client/firewall/iptables/acl_linux.go
+++ b/client/firewall/iptables/acl_linux.go
@@ -3,6 +3,7 @@ package iptables
import (
"errors"
"fmt"
+ "maps"
"net"
"slices"
@@ -421,12 +422,17 @@ func (m *aclManager) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the maps so the persisted state holds a private snapshot. The
+ // live maps keep being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing them by reference races the two and aborts the process with a
+ // concurrent map iteration and write.
if m.v6 {
- currentState.ACLEntries6 = m.entries
- currentState.ACLIPsetStore6 = m.ipsetStore
+ currentState.ACLEntries6 = maps.Clone(m.entries)
+ currentState.ACLIPsetStore6 = m.ipsetStore.clone()
} else {
- currentState.ACLEntries = m.entries
- currentState.ACLIPsetStore = m.ipsetStore
+ currentState.ACLEntries = maps.Clone(m.entries)
+ currentState.ACLIPsetStore = m.ipsetStore.clone()
}
if err := m.stateManager.UpdateState(currentState); err != nil {
diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go
index 290e5da1e..42d305f5c 100644
--- a/client/firewall/iptables/router_linux.go
+++ b/client/firewall/iptables/router_linux.go
@@ -4,6 +4,7 @@ package iptables
import (
"fmt"
+ "maps"
"net/netip"
"strconv"
"strings"
@@ -749,11 +750,17 @@ func (r *router) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the rule map so the persisted state holds a private snapshot. The
+ // live map keeps being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing it by reference races the two and aborts the process with a
+ // concurrent map iteration and write. The ipset counter guards itself
+ // during marshaling, so it can be shared directly.
if r.v6 {
- currentState.RouteRules6 = r.rules
+ currentState.RouteRules6 = maps.Clone(r.rules)
currentState.RouteIPsetCounter6 = r.ipsetCounter
} else {
- currentState.RouteRules = r.rules
+ currentState.RouteRules = maps.Clone(r.rules)
currentState.RouteIPsetCounter = r.ipsetCounter
}
diff --git a/client/firewall/iptables/rulestore_linux.go b/client/firewall/iptables/rulestore_linux.go
index 004c512a4..a6d36540e 100644
--- a/client/firewall/iptables/rulestore_linux.go
+++ b/client/firewall/iptables/rulestore_linux.go
@@ -1,6 +1,9 @@
package iptables
-import "encoding/json"
+import (
+ "encoding/json"
+ "maps"
+)
type ipList struct {
ips map[string]struct{}
@@ -19,6 +22,14 @@ func (s *ipList) addIP(ip string) {
s.ips[ip] = struct{}{}
}
+// clone returns a deep copy of the ipList with its own ips map.
+func (s *ipList) clone() *ipList {
+ if s == nil {
+ return nil
+ }
+ return &ipList{ips: maps.Clone(s.ips)}
+}
+
// MarshalJSON implements json.Marshaler
func (s *ipList) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
@@ -55,6 +66,19 @@ func newIpsetStore() *ipsetStore {
}
}
+// clone returns a deep copy of the ipsetStore with its own ipsets map and
+// independent ipList entries.
+func (s *ipsetStore) clone() *ipsetStore {
+ if s == nil {
+ return nil
+ }
+ cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))}
+ for name, list := range s.ipsets {
+ cloned.ipsets[name] = list.clone()
+ }
+ return cloned
+}
+
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
r, ok := s.ipsets[ipsetName]
return r, ok
From 367d37050b6898032d6ed1295c5546d6e3777c31 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 9 Jun 2026 17:25:46 +0900
Subject: [PATCH 40/44] [relay, client] Fall back to WebSocket relay transport
on oversized QUIC datagrams (#6339)
---
shared/relay/client/client.go | 73 ++++++++-
shared/relay/client/dialer/capability.go | 18 +++
shared/relay/client/dialer/net/err.go | 5 +
shared/relay/client/dialer/quic/conn.go | 20 ++-
shared/relay/client/dialer/quic/quic.go | 30 ++++
shared/relay/client/dialer/race_dialer.go | 39 +++++
.../relay/client/dialer/race_dialer_test.go | 63 ++++++++
shared/relay/client/dialers_generic.go | 43 +++++-
shared/relay/client/dialers_generic_test.go | 101 +++++++++++++
shared/relay/client/dialers_js.go | 6 +-
shared/relay/client/manager.go | 16 +-
shared/relay/client/picker.go | 2 +
shared/relay/client/transport.go | 129 ++++++++++++++++
shared/relay/client/transport_test.go | 140 ++++++++++++++++++
14 files changed, 663 insertions(+), 22 deletions(-)
create mode 100644 shared/relay/client/dialer/capability.go
create mode 100644 shared/relay/client/dialers_generic_test.go
create mode 100644 shared/relay/client/transport.go
create mode 100644 shared/relay/client/transport_test.go
diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go
index 1800bddb2..002b8d134 100644
--- a/shared/relay/client/client.go
+++ b/shared/relay/client/client.go
@@ -9,12 +9,14 @@ import (
"net/url"
"strings"
"sync"
+ "sync/atomic"
"time"
log "github.com/sirupsen/logrus"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
"github.com/netbirdio/netbird/shared/relay/client/dialer"
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
"github.com/netbirdio/netbird/shared/relay/healthcheck"
"github.com/netbirdio/netbird/shared/relay/messages"
)
@@ -172,6 +174,19 @@ type Client struct {
stateSubscription *PeersStateSubscription
mtu uint16
+
+ // transportFallback, when set, records datagram-too-large failures so a
+ // datagram-sized transport is avoided on subsequent connects. Shared via
+ // the manager.
+ transportFallback *transportFallback
+ // datagramFallbackTriggered guards a single fallback per connection so a
+ // burst of oversized datagrams triggers one reconnect, not many.
+ datagramFallbackTriggered atomic.Bool
+}
+
+// SetTransportFallback wires the shared datagram-transport fallback tracker.
+func (c *Client) SetTransportFallback(tf *transportFallback) {
+ c.transportFallback = tf
}
// NewClient creates a new client for the relay server. The client is not connected to the server until the Connect
@@ -361,12 +376,13 @@ func (c *Client) Close() error {
}
func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
- dialers := c.getDialers()
+ mode := transportModeFromEnv()
+ dialers := c.getDialers(mode)
var conn net.Conn
if c.serverIP.IsValid() {
var err error
- conn, err = c.dialRaceDirect(ctx, dialers)
+ conn, err = c.dialRaceDirect(ctx, mode, dialers)
if err != nil {
c.log.Infof("dial via server IP %s failed, falling back to FQDN: %v", c.serverIP, err)
conn = nil
@@ -375,6 +391,9 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
if conn == nil {
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, c.connectionURL, dialers...)
+ if mode.sequential() {
+ rd.WithSequential()
+ }
var err error
conn, err = rd.Dial(ctx)
if err != nil {
@@ -382,6 +401,7 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
}
}
c.relayConn = conn
+ c.datagramFallbackTriggered.Store(false)
instanceURL, err := c.handShake(ctx)
if err != nil {
@@ -396,7 +416,7 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
}
// dialRaceDirect dials c.serverIP, preserving the original FQDN as the TLS ServerName for SNI.
-func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (net.Conn, error) {
+func (c *Client) dialRaceDirect(ctx context.Context, mode TransportMode, dialers []dialer.DialeFn) (net.Conn, error) {
directURL, serverName, err := substituteHost(c.connectionURL, c.serverIP)
if err != nil {
return nil, fmt.Errorf("substitute host: %w", err)
@@ -406,6 +426,9 @@ func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, directURL, dialers...).
WithServerName(serverName)
+ if mode.sequential() {
+ rd.WithSequential()
+ }
return rd.Dial(ctx)
}
@@ -631,13 +654,53 @@ func (c *Client) writeTo(containerRef *connContainer, dstID messages.PeerID, pay
}
// the write always return with 0 length because the underling does not support the size feedback.
- _, err = c.relayConn.Write(msg)
+ conn := c.relayConn
+ _, err = conn.Write(msg)
if err != nil {
- c.log.Errorf("failed to write transport message: %s", err)
+ if errors.Is(err, netErr.ErrDatagramTooLarge) {
+ c.onDatagramTooLarge(conn, err)
+ } else {
+ c.log.Errorf("failed to write transport message: %s", err)
+ }
}
return len(payload), err
}
+// onDatagramTooLarge reacts to a datagram rejected as too large for the path.
+// When a non-datagram transport is available, it records a fallback for this
+// server and closes the connection so the reconnect avoids datagram-sized
+// transports. A single fallback is triggered per connection regardless of how
+// many oversized datagrams arrive. cause carries the datagram size and budget.
+func (c *Client) onDatagramTooLarge(conn net.Conn, cause error) {
+ // Handle one oversized datagram per connection; a burst triggers a single
+ // fallback (and a single log line), not many.
+ if !c.datagramFallbackTriggered.CompareAndSwap(false, true) {
+ return
+ }
+
+ // If the selected mode offers no non-datagram transport (e.g. pinned to a
+ // datagram-sized transport), reconnecting would just re-fail, so leave the
+ // connection up rather than loop.
+ if len(nonDatagramSized(c.baseDialers(transportModeFromEnv()))) == 0 {
+ c.log.Warnf("%s, but no non-datagram transport is available, not falling back", cause)
+ return
+ }
+
+ // Without the shared tracker a reconnect would just select the same
+ // transport again and re-fail, so leave the connection up rather than loop.
+ if c.transportFallback == nil {
+ c.log.Debugf("%s, but no transport fallback configured, leaving connection up", cause)
+ return
+ }
+
+ window := c.transportFallback.recordFailure(c.connectionURL)
+ c.log.Warnf("%s, avoiding datagram-sized transport for %s", cause, window)
+
+ if err := conn.Close(); err != nil {
+ c.log.Debugf("close relay connection for transport fallback: %s", err)
+ }
+}
+
func (c *Client) listenForStopEvents(ctx context.Context, hc *healthcheck.Receiver, conn net.Conn, internalStopFlag *internalStopFlag) {
for {
select {
diff --git a/shared/relay/client/dialer/capability.go b/shared/relay/client/dialer/capability.go
new file mode 100644
index 000000000..511cb2ac7
--- /dev/null
+++ b/shared/relay/client/dialer/capability.go
@@ -0,0 +1,18 @@
+package dialer
+
+// DatagramSized is implemented by dialers whose connections carry each write in
+// a single datagram, so a write can be rejected when it exceeds the path's
+// datagram budget (e.g. QUIC). Transports without this capability (e.g.
+// WebSocket over TCP) impose no per-write size limit, so the relay client can
+// fall back to them when a datagram-sized transport rejects a write as too
+// large. The capability is advertised per dialer rather than hardcoded, so a
+// new transport only needs to declare whether it is datagram-sized.
+type DatagramSized interface {
+ DatagramSized()
+}
+
+// IsDatagramSized reports whether d produces datagram-sized connections.
+func IsDatagramSized(d DialeFn) bool {
+ _, ok := d.(DatagramSized)
+ return ok
+}
diff --git a/shared/relay/client/dialer/net/err.go b/shared/relay/client/dialer/net/err.go
index fee844963..c622420dc 100644
--- a/shared/relay/client/dialer/net/err.go
+++ b/shared/relay/client/dialer/net/err.go
@@ -4,4 +4,9 @@ import "errors"
var (
ErrClosedByServer = errors.New("closed by server")
+
+ // ErrDatagramTooLarge is returned when a transport message exceeds the
+ // QUIC datagram size the path to the relay can carry. The relay client
+ // treats it as a signal to fall back to a non-datagram transport.
+ ErrDatagramTooLarge = errors.New("datagram frame too large")
)
diff --git a/shared/relay/client/dialer/quic/conn.go b/shared/relay/client/dialer/quic/conn.go
index 1d90d7139..a5c982551 100644
--- a/shared/relay/client/dialer/quic/conn.go
+++ b/shared/relay/client/dialer/quic/conn.go
@@ -8,7 +8,6 @@ import (
"time"
"github.com/quic-go/quic-go"
- log "github.com/sirupsen/logrus"
netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
)
@@ -52,11 +51,8 @@ func (c *Conn) Read(b []byte) (n int, err error) {
}
func (c *Conn) Write(b []byte) (int, error) {
- err := c.session.SendDatagram(b)
- if err != nil {
- err = c.remoteCloseErrHandling(err)
- log.Errorf("failed to write to QUIC stream: %v", err)
- return 0, err
+ if err := c.session.SendDatagram(b); err != nil {
+ return 0, c.writeErrHandling(err, len(b))
}
return len(b), nil
}
@@ -95,3 +91,15 @@ func (c *Conn) remoteCloseErrHandling(err error) error {
}
return err
}
+
+// writeErrHandling normalizes SendDatagram errors. A datagram that exceeds the
+// path's QUIC packet budget is mapped to ErrDatagramTooLarge (annotated with the
+// datagram size and path budget) so the relay client can fall back to a
+// non-datagram transport.
+func (c *Conn) writeErrHandling(err error, size int) error {
+ var tooLarge *quic.DatagramTooLargeError
+ if errors.As(err, &tooLarge) {
+ return fmt.Errorf("%w: %d byte datagram over path budget %d", netErr.ErrDatagramTooLarge, size, tooLarge.MaxDatagramPayloadSize)
+ }
+ return c.remoteCloseErrHandling(err)
+}
diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go
index 86f6f178d..5e1758a1c 100644
--- a/shared/relay/client/dialer/quic/quic.go
+++ b/shared/relay/client/dialer/quic/quic.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/quic-go/quic-go"
+ "github.com/quic-go/quic-go/logging"
log "github.com/sirupsen/logrus"
nbnet "github.com/netbirdio/netbird/client/net"
@@ -23,6 +24,12 @@ func (d Dialer) Protocol() string {
return Network
}
+// DatagramSized marks QUIC as a datagram-sized transport: relay traffic is
+// carried in QUIC DATAGRAM frames, which must fit a single packet.
+func (d Dialer) DatagramSized() {
+ // Intentional marker method; presence is the capability signal.
+}
+
func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) {
quicURL, err := prepareURL(address)
if err != nil {
@@ -47,6 +54,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
MaxIdleTimeout: 4 * time.Minute,
EnableDatagrams: true,
InitialPacketSize: nbRelay.QUICInitialPacketSize,
+ Tracer: connectionTracer(quicURL),
}
udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{Port: 0})
@@ -74,6 +82,28 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
return conn, nil
}
+// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
+// reason a relay connection closed, so the path MTU settled on and teardown
+// cause are visible in logs. Lines carry the relay address as a structured
+// field, matching the rest of the relay client logging.
+func connectionTracer(addr string) func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
+ relayLog := log.WithField("relay", addr)
+ return func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
+ return &logging.ConnectionTracer{
+ UpdatedMTU: func(mtu logging.ByteCount, done bool) {
+ if done {
+ relayLog.Infof("QUIC path MTU settled at %d", mtu)
+ return
+ }
+ relayLog.Debugf("QUIC path MTU probing at %d", mtu)
+ },
+ ClosedConnection: func(err error) {
+ relayLog.Debugf("QUIC connection closed: %v", err)
+ },
+ }
+ }
+}
+
func prepareURL(address string) (string, error) {
var host string
var defaultPort string
diff --git a/shared/relay/client/dialer/race_dialer.go b/shared/relay/client/dialer/race_dialer.go
index 15208b858..aef1ef464 100644
--- a/shared/relay/client/dialer/race_dialer.go
+++ b/shared/relay/client/dialer/race_dialer.go
@@ -32,6 +32,7 @@ type RaceDial struct {
serverName string
dialerFns []DialeFn
connectionTimeout time.Duration
+ sequential bool
}
func NewRaceDial(log *log.Entry, connectionTimeout time.Duration, serverURL string, dialerFns ...DialeFn) *RaceDial {
@@ -53,7 +54,21 @@ func (r *RaceDial) WithServerName(serverName string) *RaceDial {
return r
}
+// WithSequential makes Dial try the dialers in order, falling back to the next
+// only when one fails to connect, instead of racing them concurrently.
+//
+// Mutates the receiver and is not safe for concurrent reconfiguration; a
+// RaceDial is intended to be constructed per dial and discarded.
+func (r *RaceDial) WithSequential() *RaceDial {
+ r.sequential = true
+ return r
+}
+
func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
+ if r.sequential {
+ return r.dialSequential(ctx)
+ }
+
connChan := make(chan dialResult, len(r.dialerFns))
winnerConn := make(chan net.Conn, 1)
abortCtx, abort := context.WithCancel(ctx)
@@ -72,6 +87,30 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
return conn, nil
}
+// dialSequential tries each dialer in order, returning the first connection and
+// falling back to the next on failure.
+func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) {
+ for _, dfn := range r.dialerFns {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ attemptCtx, cancel := context.WithTimeout(ctx, r.connectionTimeout)
+ r.log.Infof("dialing Relay server via %s", dfn.Protocol())
+ conn, err := dfn.Dial(attemptCtx, r.serverURL, r.serverName)
+ cancel()
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil, err
+ }
+ r.log.Errorf("failed to dial via %s: %s", dfn.Protocol(), err)
+ continue
+ }
+ r.log.Infof("successfully dialed via: %s", dfn.Protocol())
+ return conn, nil
+ }
+ return nil, errors.New("failed to dial to Relay server on any protocol")
+}
+
func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dialResult) {
ctx, cancel := context.WithTimeout(abortCtx, r.connectionTimeout)
defer cancel()
diff --git a/shared/relay/client/dialer/race_dialer_test.go b/shared/relay/client/dialer/race_dialer_test.go
index a53edc00e..bd2f4bb85 100644
--- a/shared/relay/client/dialer/race_dialer_test.go
+++ b/shared/relay/client/dialer/race_dialer_test.go
@@ -250,3 +250,66 @@ func TestRaceDialFirstSuccessfulDialerWins(t *testing.T) {
}
}
}
+
+func TestRaceDialSequentialFallback(t *testing.T) {
+ logger := logrus.NewEntry(logrus.New())
+ serverURL := "test.server.com"
+
+ var firstDialed, secondDialed bool
+ preferred := &MockDialer{
+ protocolStr: "quic",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ firstDialed = true
+ return nil, errors.New("quic unreachable")
+ },
+ }
+ fallbackConn := &MockConn{remoteAddr: &MockAddr{network: "ws"}}
+ fallback := &MockDialer{
+ protocolStr: "ws",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ secondDialed = true
+ return fallbackConn, nil
+ },
+ }
+
+ rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential()
+ conn, err := rd.Dial(context.Background())
+ if err != nil {
+ t.Fatalf("expected fallback to succeed, got %v", err)
+ }
+ if conn != fallbackConn {
+ t.Errorf("expected fallback connection, got %v", conn)
+ }
+ if !firstDialed || !secondDialed {
+ t.Errorf("expected both dialers attempted in order, first=%v second=%v", firstDialed, secondDialed)
+ }
+}
+
+func TestRaceDialSequentialPreferredWins(t *testing.T) {
+ logger := logrus.NewEntry(logrus.New())
+ serverURL := "test.server.com"
+
+ preferredConn := &MockConn{remoteAddr: &MockAddr{network: "quic"}}
+ preferred := &MockDialer{
+ protocolStr: "quic",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ return preferredConn, nil
+ },
+ }
+ fallback := &MockDialer{
+ protocolStr: "ws",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ t.Errorf("fallback dialer must not be tried when preferred succeeds")
+ return nil, errors.New("should not happen")
+ },
+ }
+
+ rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential()
+ conn, err := rd.Dial(context.Background())
+ if err != nil {
+ t.Fatalf("expected preferred to succeed, got %v", err)
+ }
+ if conn != preferredConn {
+ t.Errorf("expected preferred connection, got %v", conn)
+ }
+}
diff --git a/shared/relay/client/dialers_generic.go b/shared/relay/client/dialers_generic.go
index a8ed79961..95e319338 100644
--- a/shared/relay/client/dialers_generic.go
+++ b/shared/relay/client/dialers_generic.go
@@ -9,11 +9,42 @@ import (
"github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
)
-// getDialers returns the list of dialers to use for connecting to the relay server.
-func (c *Client) getDialers() []dialer.DialeFn {
- if c.mtu > 0 && c.mtu > iface.DefaultMTU {
- c.log.Infof("MTU %d exceeds default (%d), forcing WebSocket transport to avoid DATAGRAM frame size issues", c.mtu, iface.DefaultMTU)
- return []dialer.DialeFn{ws.Dialer{}}
+// getDialers returns the ordered dialers for connecting to the relay server. It
+// applies the datagram fallback generically: if this server recently rejected a
+// datagram-sized transport, those dialers are dropped, leaving the rest.
+func (c *Client) getDialers(mode TransportMode) []dialer.DialeFn {
+ dialers := c.baseDialers(mode)
+
+ if c.transportFallback != nil && c.transportFallback.avoidDatagramSized(c.connectionURL) {
+ if filtered := nonDatagramSized(dialers); len(filtered) > 0 {
+ c.log.Infof("relay recently rejected a datagram-sized transport, avoiding it")
+ return filtered
+ }
}
- return []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}}
+ return dialers
+}
+
+// baseDialers returns the ordered dialers for the mode, before any datagram
+// fallback filtering. For racing modes (auto) the order is irrelevant; for
+// prefer modes the first entry is tried before falling back to the second.
+func (c *Client) baseDialers(mode TransportMode) []dialer.DialeFn {
+ switch mode {
+ case TransportModeWS:
+ c.log.Infof("%s=ws, using WebSocket transport", EnvRelayTransport)
+ return []dialer.DialeFn{ws.Dialer{}}
+ case TransportModeQUIC:
+ c.log.Infof("%s=quic, using QUIC transport", EnvRelayTransport)
+ return []dialer.DialeFn{quic.Dialer{}}
+ }
+
+ all := []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}}
+ if mode == TransportModePreferWS {
+ all = []dialer.DialeFn{ws.Dialer{}, quic.Dialer{}}
+ }
+
+ if c.mtu > 0 && c.mtu > iface.DefaultMTU {
+ c.log.Infof("MTU %d exceeds default (%d), avoiding datagram-sized transports", c.mtu, iface.DefaultMTU)
+ return nonDatagramSized(all)
+ }
+ return all
}
diff --git a/shared/relay/client/dialers_generic_test.go b/shared/relay/client/dialers_generic_test.go
new file mode 100644
index 000000000..c4ef9cc59
--- /dev/null
+++ b/shared/relay/client/dialers_generic_test.go
@@ -0,0 +1,101 @@
+//go:build !js
+
+package client
+
+import (
+ "os"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer"
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer/quic"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
+)
+
+// TestDatagramSizedCapability locks the capability the generic fallback relies
+// on: QUIC is datagram-sized, WebSocket is not.
+func TestDatagramSizedCapability(t *testing.T) {
+ assert.True(t, dialer.IsDatagramSized(quic.Dialer{}), "QUIC must advertise datagram-sized")
+ assert.False(t, dialer.IsDatagramSized(ws.Dialer{}), "WebSocket must not advertise datagram-sized")
+}
+
+func protocols(dialers []dialer.DialeFn) []string {
+ out := make([]string, len(dialers))
+ for i, d := range dialers {
+ out[i] = d.Protocol()
+ }
+ return out
+}
+
+func TestGetDialers(t *testing.T) {
+ const url = "rels://relay.example:443"
+
+ tests := []struct {
+ name string
+ mode string
+ mtu uint16
+ preferWS bool
+ want []string
+ }{
+ {name: "auto races quic and ws", mode: "auto", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}},
+ {name: "ws pinned", mode: "ws", mtu: iface.DefaultMTU, want: []string{"WS"}},
+ {name: "quic pinned", mode: "quic", mtu: iface.DefaultMTU, want: []string{"quic"}},
+ {name: "prefer-quic orders quic first", mode: "prefer-quic", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}},
+ {name: "prefer-ws orders ws first", mode: "prefer-ws", mtu: iface.DefaultMTU, want: []string{"WS", "quic"}},
+ {name: "mtu above default forces ws", mode: "auto", mtu: iface.DefaultMTU + 100, want: []string{"WS"}},
+ {name: "sticky fallback forces ws in auto", mode: "auto", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}},
+ {name: "sticky fallback forces ws in prefer-quic", mode: "prefer-quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}},
+ {name: "quic pin overrides sticky fallback", mode: "quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"quic"}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(EnvRelayTransport, tc.mode)
+ if tc.mode == "" {
+ os.Unsetenv(EnvRelayTransport)
+ }
+
+ tf := newTransportFallback()
+ if tc.preferWS {
+ tf.recordFailure(url)
+ }
+
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ mtu: tc.mtu,
+ transportFallback: tf,
+ }
+
+ assert.Equal(t, tc.want, protocols(c.getDialers(transportModeFromEnv())))
+ })
+ }
+}
+
+// TestStickyFallbackAfterDatagramTooLarge verifies the full chain: an oversized
+// datagram records a fallback that makes the next dial pick WebSocket, the way a
+// reconnect would after the connection is closed.
+func TestStickyFallbackAfterDatagramTooLarge(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeAuto))
+
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ mtu: iface.DefaultMTU,
+ transportFallback: newTransportFallback(),
+ }
+
+ // First dial races both transports.
+ assert.Equal(t, []string{"quic", "WS"}, protocols(c.getDialers(transportModeFromEnv())))
+
+ // An oversized datagram records the fallback for this server.
+ c.onDatagramTooLarge(&closeTrackingConn{}, netErr.ErrDatagramTooLarge)
+
+ // The reconnect now sticks to WebSocket.
+ assert.Equal(t, []string{"WS"}, protocols(c.getDialers(transportModeFromEnv())))
+}
diff --git a/shared/relay/client/dialers_js.go b/shared/relay/client/dialers_js.go
index 6bd0e6696..c93787729 100644
--- a/shared/relay/client/dialers_js.go
+++ b/shared/relay/client/dialers_js.go
@@ -7,7 +7,11 @@ import (
"github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
)
-func (c *Client) getDialers() []dialer.DialeFn {
+func (c *Client) getDialers(_ TransportMode) []dialer.DialeFn {
// JS/WASM build only uses WebSocket transport
return []dialer.DialeFn{ws.Dialer{}}
}
+
+func (c *Client) baseDialers(_ TransportMode) []dialer.DialeFn {
+ return []dialer.DialeFn{ws.Dialer{}}
+}
diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go
index 3858b3c83..f87da15de 100644
--- a/shared/relay/client/manager.go
+++ b/shared/relay/client/manager.go
@@ -79,23 +79,30 @@ type Manager struct {
cleanupInterval time.Duration
keepUnusedServerTime time.Duration
+
+ // transportFallback is shared across home and foreign relay clients so a
+ // datagram-too-large failure makes that server avoid datagram-sized transports across reconnects.
+ transportFallback *transportFallback
}
// NewManager creates a new manager instance.
// The serverURL address can be empty. In this case, the manager will not serve.
func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uint16, opts ...ManagerOption) *Manager {
tokenStore := &relayAuth.TokenStore{}
+ tf := newTransportFallback()
m := &Manager{
- ctx: ctx,
- peerID: peerID,
- tokenStore: tokenStore,
- mtu: mtu,
+ ctx: ctx,
+ peerID: peerID,
+ tokenStore: tokenStore,
+ mtu: mtu,
+ transportFallback: tf,
serverPicker: &ServerPicker{
TokenStore: tokenStore,
PeerID: peerID,
MTU: mtu,
ConnectionTimeout: defaultConnectionTimeout,
+ TransportFallback: tf,
},
relayClients: make(map[string]*RelayTrack),
onDisconnectedListeners: make(map[string]*list.List),
@@ -287,6 +294,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string
m.relayClientsMutex.Unlock()
relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu)
+ relayClient.SetTransportFallback(m.transportFallback)
err := relayClient.Connect(m.ctx)
if err != nil {
rt.err = err
diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go
index 39d0ba072..992e48114 100644
--- a/shared/relay/client/picker.go
+++ b/shared/relay/client/picker.go
@@ -29,6 +29,7 @@ type ServerPicker struct {
PeerID string
MTU uint16
ConnectionTimeout time.Duration
+ TransportFallback *transportFallback
}
func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
@@ -70,6 +71,7 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan connResult, url string) {
log.Infof("try to connecting to relay server: %s", url)
relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
+ relayClient.SetTransportFallback(sp.TransportFallback)
err := relayClient.Connect(ctx)
resultChan <- connResult{
RelayClient: relayClient,
diff --git a/shared/relay/client/transport.go b/shared/relay/client/transport.go
new file mode 100644
index 000000000..002707401
--- /dev/null
+++ b/shared/relay/client/transport.go
@@ -0,0 +1,129 @@
+package client
+
+import (
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/shared/relay/client/dialer"
+)
+
+// EnvRelayTransport pins the relay transport. Valid values: "auto" (default,
+// race QUIC and WebSocket), "quic" (QUIC only), "ws" (WebSocket only),
+// "prefer-quic" / "prefer-ws" (try the preferred transport first, fall back to
+// the other only if it fails to connect; no race). The prefer modes trade a
+// slower connect when the preferred transport is blackholed for deterministic
+// transport selection.
+const EnvRelayTransport = "NB_RELAY_TRANSPORT"
+
+const (
+ // transportFallbackBase is the initial window a relay server avoids
+ // datagram-sized transports after a datagram is rejected as too large.
+ transportFallbackBase = 10 * time.Minute
+ // transportFallbackMax caps the pinned window when failures repeat.
+ transportFallbackMax = 60 * time.Minute
+)
+
+// TransportMode selects which relay dialers are used.
+type TransportMode string
+
+const (
+ TransportModeAuto TransportMode = "auto"
+ TransportModeQUIC TransportMode = "quic"
+ TransportModeWS TransportMode = "ws"
+ TransportModePreferQUIC TransportMode = "prefer-quic"
+ TransportModePreferWS TransportMode = "prefer-ws"
+)
+
+// transportModeFromEnv reads EnvRelayTransport, defaulting to auto for an empty
+// or unrecognized value.
+func transportModeFromEnv() TransportMode {
+ switch TransportMode(strings.ToLower(strings.TrimSpace(os.Getenv(EnvRelayTransport)))) {
+ case "", TransportModeAuto:
+ return TransportModeAuto
+ case TransportModeQUIC:
+ return TransportModeQUIC
+ case TransportModeWS:
+ return TransportModeWS
+ case TransportModePreferQUIC:
+ return TransportModePreferQUIC
+ case TransportModePreferWS:
+ return TransportModePreferWS
+ default:
+ log.Warnf("invalid %s value %q, using %q", EnvRelayTransport, os.Getenv(EnvRelayTransport), TransportModeAuto)
+ return TransportModeAuto
+ }
+}
+
+// sequential reports whether the mode tries dialers in order with fallback
+// instead of racing them concurrently.
+func (m TransportMode) sequential() bool {
+ return m == TransportModePreferQUIC || m == TransportModePreferWS
+}
+
+// transportFallback tracks relay servers that have rejected a datagram-sized
+// transport (a write too large for the path) and should temporarily avoid such
+// transports. It is shared across the relay manager so the preference survives
+// client recreation (foreign relay clients are evicted and rebuilt on
+// disconnect). Entries are keyed by server URL and expire after a window that
+// grows on repeated failures.
+type transportFallback struct {
+ mu sync.Mutex
+ entries map[string]*fallbackEntry
+}
+
+type fallbackEntry struct {
+ until time.Time
+ duration time.Duration
+}
+
+func newTransportFallback() *transportFallback {
+ return &transportFallback{entries: make(map[string]*fallbackEntry)}
+}
+
+// avoidDatagramSized reports whether serverURL is currently within a window
+// where datagram-sized transports should be avoided.
+func (f *transportFallback) avoidDatagramSized(serverURL string) bool {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ e := f.entries[serverURL]
+ return e != nil && time.Now().Before(e.until)
+}
+
+// recordFailure makes serverURL avoid datagram-sized transports for a window:
+// transportFallbackBase on the first failure, doubling up to transportFallbackMax
+// when a datagram transport fails again after a previous window expired. It
+// returns the active window duration.
+func (f *transportFallback) recordFailure(serverURL string) time.Duration {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ now := time.Now()
+ e := f.entries[serverURL]
+ switch {
+ case e == nil:
+ e = &fallbackEntry{duration: transportFallbackBase}
+ f.entries[serverURL] = e
+ case now.Before(e.until):
+ return time.Until(e.until)
+ default:
+ e.duration = min(e.duration*2, transportFallbackMax)
+ }
+ e.until = now.Add(e.duration)
+ return e.duration
+}
+
+// nonDatagramSized returns the dialers from in that are not datagram-sized,
+// preserving order.
+func nonDatagramSized(in []dialer.DialeFn) []dialer.DialeFn {
+ out := make([]dialer.DialeFn, 0, len(in))
+ for _, d := range in {
+ if !dialer.IsDatagramSized(d) {
+ out = append(out, d)
+ }
+ }
+ return out
+}
diff --git a/shared/relay/client/transport_test.go b/shared/relay/client/transport_test.go
new file mode 100644
index 000000000..8e10c8d42
--- /dev/null
+++ b/shared/relay/client/transport_test.go
@@ -0,0 +1,140 @@
+package client
+
+import (
+ "net"
+ "os"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
+)
+
+// closeTrackingConn records whether Close was called; only Close is exercised.
+type closeTrackingConn struct {
+ net.Conn
+ closed bool
+}
+
+func (c *closeTrackingConn) Close() error {
+ c.closed = true
+ return nil
+}
+
+func TestTransportModeFromEnv(t *testing.T) {
+ tests := []struct {
+ value string
+ want TransportMode
+ }{
+ {"", TransportModeAuto},
+ {"auto", TransportModeAuto},
+ {"quic", TransportModeQUIC},
+ {"QUIC", TransportModeQUIC},
+ {"ws", TransportModeWS},
+ {" Ws ", TransportModeWS},
+ {"prefer-quic", TransportModePreferQUIC},
+ {"prefer-ws", TransportModePreferWS},
+ {"garbage", TransportModeAuto},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.value, func(t *testing.T) {
+ t.Setenv(EnvRelayTransport, tc.value)
+ if tc.value == "" {
+ os.Unsetenv(EnvRelayTransport)
+ }
+ assert.Equal(t, tc.want, transportModeFromEnv())
+ })
+ }
+}
+
+func TestTransportFallbackRecordAndExpiry(t *testing.T) {
+ const url = "rels://relay.example:443"
+ f := newTransportFallback()
+
+ assert.False(t, f.avoidDatagramSized(url), "no fallback recorded yet")
+
+ d := f.recordFailure(url)
+ assert.Equal(t, transportFallbackBase, d, "first failure pins for the base window")
+ assert.True(t, f.avoidDatagramSized(url), "datagram-sized transport avoided within the window")
+
+ // A second failure while still inside the window must not grow the window.
+ d = f.recordFailure(url)
+ assert.LessOrEqual(t, d, transportFallbackBase, "still within the active window")
+ require.NotNil(t, f.entries[url])
+ assert.Equal(t, transportFallbackBase, f.entries[url].duration, "duration unchanged inside window")
+
+ // Expire the window: datagram-sized transport allowed again.
+ f.entries[url].until = time.Now().Add(-time.Second)
+ assert.False(t, f.avoidDatagramSized(url), "window expired, datagram-sized transport allowed")
+}
+
+func TestTransportFallbackGrowsOnRepeat(t *testing.T) {
+ const url = "rels://relay.example:443"
+ f := newTransportFallback()
+
+ want := transportFallbackBase
+ for i := range 6 {
+ d := f.recordFailure(url)
+ assert.Equal(t, want, d, "window after %d expiries", i)
+
+ // expire the window so the next failure is treated as a repeat
+ f.entries[url].until = time.Now().Add(-time.Second)
+
+ want = min(want*2, transportFallbackMax)
+ }
+
+ assert.Equal(t, transportFallbackMax, f.entries[url].duration, "window caps at the max")
+}
+
+func TestOnDatagramTooLargeAuto(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeAuto))
+
+ tf := newTransportFallback()
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ transportFallback: tf,
+ }
+ conn := &closeTrackingConn{}
+
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+
+ assert.True(t, conn.closed, "connection closed to force reconnect")
+ assert.True(t, tf.avoidDatagramSized(url), "fallback recorded for the server")
+
+ // A second oversized datagram on the same connection must not re-close.
+ conn.closed = false
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+ assert.False(t, conn.closed, "single fallback per connection")
+}
+
+func TestOnDatagramTooLargeQUICPinned(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeQUIC))
+
+ tf := newTransportFallback()
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ transportFallback: tf,
+ }
+ conn := &closeTrackingConn{}
+
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+
+ assert.False(t, conn.closed, "QUIC pin keeps the connection, no fallback redial")
+ assert.False(t, tf.avoidDatagramSized(url), "QUIC pin records no fallback")
+}
+
+func TestTransportFallbackPerServer(t *testing.T) {
+ f := newTransportFallback()
+ f.recordFailure("rels://a.example:443")
+
+ assert.True(t, f.avoidDatagramSized("rels://a.example:443"))
+ assert.False(t, f.avoidDatagramSized("rels://b.example:443"), "fallback is scoped to one server")
+}
From d56859dc5deb0c421d3831ea74a4ae6471539b43 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 9 Jun 2026 19:26:03 +0900
Subject: [PATCH 41/44] [client] Filter DNS fallback upstreams matching our
server IP to prevent loops (#6183)
---
client/internal/dns/server.go | 20 +++++++++++++-------
1 file changed, 13 insertions(+), 7 deletions(-)
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index 7a35e56d8..dcd4cb9d0 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -777,13 +777,24 @@ func (s *DefaultServer) applyHostConfig() {
// context is released rather than leaked until GC.
func (s *DefaultServer) registerFallback() {
originalNameservers := s.hostManager.getOriginalNameservers()
- if len(originalNameservers) == 0 {
+
+ serverIP := s.service.RuntimeIP()
+ var servers []netip.AddrPort
+ for _, ns := range originalNameservers {
+ if ns == serverIP {
+ log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP)
+ continue
+ }
+ servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
+ }
+
+ if len(servers) == 0 {
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
s.clearFallback()
return
}
- log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback)
+ log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback)
handler, err := newUpstreamResolver(
s.ctx,
@@ -797,11 +808,6 @@ func (s *DefaultServer) registerFallback() {
return
}
handler.selectedRoutes = s.selectedRoutes
-
- var servers []netip.AddrPort
- for _, ns := range originalNameservers {
- servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
- }
handler.addRace(servers)
prev := s.fallbackHandler
From ed7a9363aa29ef4ffe053cdf3f9dc60da200bd7f Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 9 Jun 2026 20:26:43 +0900
Subject: [PATCH 42/44] [management] Emit IPv6 default permit firewall rule for
exit node routes (#6368)
---
.../server/types/networkmap_components.go | 8 +++-
.../networkmap_components_correctness_test.go | 43 +++++++++++++++++++
2 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go
index 3a7e20ec5..b5514e19b 100644
--- a/management/server/types/networkmap_components.go
+++ b/management/server/types/networkmap_components.go
@@ -557,7 +557,6 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
return enabledRoutes, disabledRoutes
}
-
func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
var filteredRoutes []*route.Route
for _, r := range routes {
@@ -628,9 +627,14 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
rules := []*RouteFirewallRule{&rule}
- if includeIPv6 && r.IsDynamic() {
+ isDefaultV4 := r.Network.Addr().Is4() && r.Network.Bits() == 0
+ if includeIPv6 && (r.IsDynamic() || isDefaultV4) {
ruleV6 := rule
ruleV6.SourceRanges = []string{"::/0"}
+ if isDefaultV4 {
+ ruleV6.Destination = "::/0"
+ ruleV6.RouteID = r.ID + "-v6-default"
+ }
rules = append(rules, &ruleV6)
}
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index bcfb6fdf9..3785a7399 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"net/netip"
+ "slices"
"testing"
"time"
@@ -1029,6 +1030,48 @@ func TestComponents_RouteDefaultPermit(t *testing.T) {
assert.True(t, hasDefaultPermit, "route without ACG should have default permit rule with 0.0.0.0/0 source")
}
+// TestComponents_ExitNodeDefaultPermitIPv6 verifies that a default exit node route
+// (0.0.0.0/0) without AccessControlGroups also emits an IPv6 default permit rule
+// (::/0 source and destination) for peers that support IPv6, mirroring the route
+// the client installs. Without it, IPv6 traffic is routed to the exit node but
+// dropped at the forward chain.
+func TestComponents_ExitNodeDefaultPermitIPv6(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+
+ routingPeerID := "peer-5"
+ routingPeer := account.Peers[routingPeerID]
+ routingPeer.IPv6 = netip.MustParseAddr("fd00::5")
+ routingPeer.Meta.Capabilities = append(routingPeer.Meta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay)
+
+ account.Routes["route-exit"] = &route.Route{
+ ID: "route-exit", Network: netip.MustParsePrefix("0.0.0.0/0"),
+ PeerID: routingPeerID, Peer: routingPeer.Key,
+ Enabled: true, Groups: []string{"group-all"}, PeerGroups: []string{"group-0"},
+ AccessControlGroups: []string{},
+ AccountID: "test-account",
+ }
+
+ nm := componentsNetworkMap(account, routingPeerID, validatedPeers)
+ require.NotNil(t, nm)
+
+ hasV4 := false
+ hasV6 := false
+ for _, rfr := range nm.RoutesFirewallRules {
+ switch rfr.Destination {
+ case "0.0.0.0/0":
+ if slices.Contains(rfr.SourceRanges, "0.0.0.0/0") {
+ hasV4 = true
+ }
+ case "::/0":
+ if slices.Contains(rfr.SourceRanges, "::/0") {
+ hasV6 = true
+ }
+ }
+ }
+ assert.True(t, hasV4, "exit node route should have an IPv4 default permit rule (0.0.0.0/0)")
+ assert.True(t, hasV6, "exit node route should have an IPv6 default permit rule (::/0)")
+}
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// 15. MULTIPLE ROUTERS PER NETWORK
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
From 13200265d8c1aabbe72f19fc30a3c82d2324958b Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Tue, 9 Jun 2026 13:57:17 +0200
Subject: [PATCH 43/44] [proxy] Add no-blocking mapping updates (#6369)
---
management/internals/shared/grpc/proxy.go | 2 +
proxy/cmd/proxy/cmd/root.go | 1 +
proxy/internal/roundtrip/netbird.go | 164 +++++++++----
proxy/internal/roundtrip/netbird_test.go | 131 +++++++++-
proxy/lifecycle.go | 5 +
proxy/mapping_stall_test.go | 282 ++++++++++++++++++++++
proxy/server.go | 81 ++++++-
7 files changed, 607 insertions(+), 59 deletions(-)
create mode 100644 proxy/mapping_stall_test.go
diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go
index 72735b210..0feb807f6 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -666,8 +666,10 @@ func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error)
case resp := <-conn.sendChan:
if err := conn.sendResponse(resp); err != nil {
errChan <- err
+ log.WithContext(conn.ctx).Tracef("Failed to send response to proxy %s: %v", conn.proxyID, err)
return
}
+ log.WithContext(conn.ctx).Tracef("Send response to proxy %s", conn.proxyID)
case <-conn.ctx.Done():
return
}
diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go
index d0e11517e..ad8e1b7c0 100644
--- a/proxy/cmd/proxy/cmd/root.go
+++ b/proxy/cmd/proxy/cmd/root.go
@@ -249,6 +249,7 @@ func runServer(cmd *cobra.Command, args []string) error {
Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
+ MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go
index 1d1e68f4a..13d386da2 100644
--- a/proxy/internal/roundtrip/netbird.go
+++ b/proxy/internal/roundtrip/netbird.go
@@ -28,6 +28,10 @@ import (
const deviceNamePrefix = "ingress-proxy-"
+const clientStopTimeout = 30 * time.Second
+
+const createProxyPeerTimeout = 30 * time.Second
+
// backendKey identifies a backend by its host:port from the target URL.
type backendKey string
@@ -162,6 +166,7 @@ type NetBird struct {
clientsMux sync.RWMutex
clients map[types.AccountID]*clientEntry
+ lifecycleMu sync.Map
initLogOnce sync.Once
statusNotifier statusNotifier
// readyHandler runs after the embedded client for an account reports
@@ -177,6 +182,10 @@ type NetBird struct {
// (i.e. when a new client was actually created, not when an existing one
// was reused). The duration covers keygen + gRPC CreateProxyPeer + embed.New.
OnAddPeer func(d time.Duration, err error)
+
+ // startClient runs the post-create client startup. Nil uses runClientStartup;
+ // tests override it to avoid a real embed client.Start.
+ startClient func(accountID types.AccountID, client *embed.Client)
}
// ClientDebugInfo contains debug information about a client.
@@ -200,31 +209,20 @@ type skipTLSVerifyContextKey struct{}
func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error {
si := serviceInfo{serviceID: serviceID}
- n.clientsMux.Lock()
+ if n.registerExistingClient(accountID, key, si) {
+ return nil
+ }
- entry, exists := n.clients[accountID]
- if exists {
- entry.services[key] = si
- started := entry.started
- n.clientsMux.Unlock()
-
- n.logger.WithFields(log.Fields{
- "account_id": accountID,
- "service_key": key,
- }).Debug("registered service with existing client")
-
- if started && n.statusNotifier != nil {
- // Use a background context, not the caller's: the management
- // connection notification must land even if the request /
- // stream that triggered this registration is cancelled.
- // Mirrors the async runClientStartup path.
- if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, serviceID, true); err != nil {
- n.logger.WithFields(log.Fields{
- "account_id": accountID,
- "service_key": key,
- }).WithError(err).Warn("failed to notify status for existing client")
- }
+ lifecycle := n.accountLifecycle(accountID)
+ lifecycle.Lock()
+ transferred := false
+ defer func() {
+ if !transferred {
+ lifecycle.Unlock()
}
+ }()
+
+ if n.registerExistingClient(accountID, key, si) {
return nil
}
@@ -234,10 +232,10 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
n.OnAddPeer(time.Since(createStart), err)
}
if err != nil {
- n.clientsMux.Unlock()
return err
}
+ n.clientsMux.Lock()
n.clients[accountID] = entry
n.clientsMux.Unlock()
@@ -246,17 +244,64 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
"service_key": key,
}).Info("created new client for account")
- // Attempt to start the client in the background; if this fails we will
- // retry on the first request via RoundTrip. runClientStartup uses its
- // own background context so the caller's request-scoped ctx can't
- // cancel the inbound bring-up.
- go n.runClientStartup(accountID, entry.client)
+ transferred = true
+ go func() {
+ defer lifecycle.Unlock()
+ n.startClientStartup(accountID, entry.client)
+ }()
return nil
}
+func (n *NetBird) startClientStartup(accountID types.AccountID, client *embed.Client) {
+ if n.startClient != nil {
+ n.startClient(accountID, client)
+ return
+ }
+ n.runClientStartup(accountID, client)
+}
+
+// registerExistingClient registers the service against an already-present
+// client for the account and returns true when it did. It notifies management
+// of the new service when the client is already started.
+func (n *NetBird) registerExistingClient(accountID types.AccountID, key ServiceKey, si serviceInfo) bool {
+ n.clientsMux.Lock()
+ entry, exists := n.clients[accountID]
+ if !exists {
+ n.clientsMux.Unlock()
+ return false
+ }
+ entry.services[key] = si
+ started := entry.started
+ n.clientsMux.Unlock()
+
+ n.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "service_key": key,
+ }).Debug("registered service with existing client")
+
+ if started && n.statusNotifier != nil {
+ if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, si.serviceID, true); err != nil {
+ n.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "service_key": key,
+ }).WithError(err).Warn("failed to notify status for existing client")
+ }
+ }
+ return true
+}
+
+// accountLifecycle returns the per-account lifecycle mutex, serialising client
+// creation against teardown so a slow client.Stop cannot race a new
+// client.Start for the same account, without blocking clientsMux.
+func (n *NetBird) accountLifecycle(accountID types.AccountID) *sync.Mutex {
+ mu, _ := n.lifecycleMu.LoadOrStore(accountID, &sync.Mutex{})
+ return mu.(*sync.Mutex)
+}
+
// createClientEntry generates a WireGuard keypair, authenticates with management,
-// and creates an embedded NetBird client. Must be called with clientsMux held.
+// and creates an embedded NetBird client. Must be called with the account's
+// lifecycle mutex held.
func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) {
serviceID := si.serviceID
n.logger.WithFields(log.Fields{
@@ -276,7 +321,9 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
"public_key": publicKey.String(),
}).Debug("authenticating new proxy peer with management")
- resp, err := n.mgmtClient.CreateProxyPeer(ctx, &proto.CreateProxyPeerRequest{
+ createCtx, cancel := context.WithTimeout(ctx, createProxyPeerTimeout)
+ defer cancel()
+ resp, err := n.mgmtClient.CreateProxyPeer(createCtx, &proto.CreateProxyPeerRequest{
ServiceId: string(serviceID),
AccountId: string(accountID),
Token: authToken,
@@ -444,6 +491,15 @@ func (n *NetBird) notifyClientReady(accountID types.AccountID, client *embed.Cli
// RemovePeer unregisters a service from an account. The client is only stopped
// when no services are using it anymore.
func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key ServiceKey) error {
+ lifecycle := n.accountLifecycle(accountID)
+ lifecycle.Lock()
+ transferred := false
+ defer func() {
+ if !transferred {
+ lifecycle.Unlock()
+ }
+ }()
+
n.clientsMux.Lock()
entry, exists := n.clients[accountID]
@@ -466,17 +522,8 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
delete(entry.services, key)
stopClient := len(entry.services) == 0
- var client *embed.Client
- var transport, insecureTransport *http.Transport
- var inbound any
- var stopHandler func(types.AccountID, any)
if stopClient {
n.logger.WithField("account_id", accountID).Info("stopping client, no more services")
- client = entry.client
- transport = entry.transport
- insecureTransport = entry.insecureTransport
- inbound = entry.inbound
- stopHandler = n.stopHandler
delete(n.clients, accountID)
} else {
n.logger.WithFields(log.Fields{
@@ -490,19 +537,40 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
n.notifyDisconnect(ctx, accountID, key, si.serviceID)
if stopClient {
- if inbound != nil && stopHandler != nil {
- stopHandler(accountID, inbound)
- }
- transport.CloseIdleConnections()
- insecureTransport.CloseIdleConnections()
- if err := client.Stop(ctx); err != nil {
- n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client")
- }
+ transferred = true
+ go n.stopClientLocked(accountID, lifecycle, entry)
}
return nil
}
+// stopClientLocked releases a client's resources off the caller's goroutine so a
+// slow client.Stop cannot wedge the mapping receive loop (which calls RemovePeer
+// synchronously). It unlocks lifecycle when done so a new client.Start for the
+// same account waits for this teardown.
+func (n *NetBird) stopClientLocked(accountID types.AccountID, lifecycle *sync.Mutex, entry *clientEntry) {
+ defer lifecycle.Unlock()
+
+ if entry.inbound != nil && n.stopHandler != nil {
+ n.stopHandler(accountID, entry.inbound)
+ }
+ if entry.transport != nil {
+ entry.transport.CloseIdleConnections()
+ }
+ if entry.insecureTransport != nil {
+ entry.insecureTransport.CloseIdleConnections()
+ }
+ if entry.client == nil {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), clientStopTimeout)
+ defer cancel()
+ if err := entry.client.Stop(ctx); err != nil {
+ n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client")
+ }
+}
+
func (n *NetBird) notifyDisconnect(ctx context.Context, accountID types.AccountID, key ServiceKey, serviceID types.ServiceID) {
if n.statusNotifier == nil {
return
diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go
index b1c36b465..700cca83e 100644
--- a/proxy/internal/roundtrip/netbird_test.go
+++ b/proxy/internal/roundtrip/netbird_test.go
@@ -6,6 +6,7 @@ import (
"net/netip"
"sync"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -22,6 +23,18 @@ func (m *mockMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxy
return &proto.CreateProxyPeerResponse{Success: true}, nil
}
+// signalMgmtClient closes entered the first time CreateProxyPeer is called, so
+// tests can detect AddPeer reaching client creation.
+type signalMgmtClient struct {
+ entered chan struct{}
+ once sync.Once
+}
+
+func (m *signalMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ m.once.Do(func() { close(m.entered) })
+ return &proto.CreateProxyPeerResponse{Success: true}, nil
+}
+
type mockStatusNotifier struct {
mu sync.Mutex
statuses []statusCall
@@ -52,11 +65,15 @@ func (m *mockStatusNotifier) calls() []statusCall {
// mockNetBird creates a NetBird instance for testing without actually connecting.
// It uses an invalid management URL to prevent real connections.
func mockNetBird() *NetBird {
- return NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
}, nil, nil, &mockMgmtClient{})
+ // Skip the real embed client.Start, which would hang against the unreachable
+ // mgmt URL and (now that the lifecycle lock spans startup) serialise removes.
+ nb.startClient = func(types.AccountID, *embed.Client) {}
+ return nb
}
func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) {
@@ -288,6 +305,7 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
WGPort: 0,
PreSharedKey: "",
}, nil, notifier, &mockMgmtClient{})
+ nb.startClient = func(types.AccountID, *embed.Client) {}
accountID := types.AccountID("account-1")
// Add first service โ creates a new client entry.
@@ -372,6 +390,117 @@ func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
assert.False(t, calls[0].connected)
}
+// TestNetBird_RemovePeer_TeardownIsAsync proves the fix for the receive-loop
+// stall: RemovePeer must return promptly even when the client teardown blocks,
+// because teardown runs off the caller's goroutine. The receive loop calls
+// RemovePeer synchronously, so a blocking teardown inline would wedge it.
+func TestNetBird_RemovePeer_TeardownIsAsync(t *testing.T) {
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, &mockStatusNotifier{}, &mockMgmtClient{})
+
+ accountID := types.AccountID("acct-async-teardown")
+ key := DomainServiceKey("svc.example")
+
+ teardownEntered := make(chan struct{})
+ releaseTeardown := make(chan struct{})
+ nb.SetClientLifecycle(nil, func(types.AccountID, any) {
+ close(teardownEntered)
+ <-releaseTeardown
+ })
+
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}},
+ started: true,
+ inbound: struct{}{},
+ }
+ nb.clientsMux.Unlock()
+
+ done := make(chan error, 1)
+ go func() { done <- nb.RemovePeer(context.Background(), accountID, key) }()
+
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(2 * time.Second):
+ t.Fatal("RemovePeer did not return while teardown was blocked โ teardown is not async")
+ }
+
+ select {
+ case <-teardownEntered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("teardown never ran")
+ }
+
+ close(releaseTeardown)
+}
+
+// TestNetBird_AddPeer_WaitsForTeardown proves the lifecycle lock serialises a
+// new client bringup behind an in-flight teardown for the same account, so a
+// slow client.Stop can never race a new client.Start for that account.
+//
+// It targets the handoff race specifically: AddPeer is launched immediately
+// after RemovePeer returns, WITHOUT waiting for the teardown goroutine to start.
+// This only passes if RemovePeer acquires the lifecycle lock synchronously
+// (before returning) and hands it to the teardown goroutine โ if the goroutine
+// acquired the lock itself, AddPeer could win the lock in this window and start
+// a replacement client while the old teardown is still pending.
+func TestNetBird_AddPeer_WaitsForTeardown(t *testing.T) {
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, &mockStatusNotifier{}, &mockMgmtClient{})
+ nb.startClient = func(types.AccountID, *embed.Client) {}
+
+ accountID := types.AccountID("acct-serialize")
+ key := DomainServiceKey("svc.example")
+
+ addEntered := make(chan struct{})
+ releaseTeardown := make(chan struct{})
+ nb.SetClientLifecycle(nil, func(types.AccountID, any) {
+ // Block teardown until released. If AddPeer ever reaches createClientEntry
+ // (signalled via the mgmt client below) while we hold the lock, the lock
+ // failed to serialise and the test fails before we release.
+ <-releaseTeardown
+ })
+
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}},
+ started: true,
+ inbound: struct{}{},
+ }
+ nb.clientsMux.Unlock()
+
+ // createClientEntry calls CreateProxyPeer; closing addEntered there tells us
+ // AddPeer got past the lifecycle lock and into client creation.
+ nb.mgmtClient = &signalMgmtClient{entered: addEntered}
+
+ require.NoError(t, nb.RemovePeer(context.Background(), accountID, key))
+
+ // Launch AddPeer with NO synchronisation against the teardown goroutine.
+ addReturned := make(chan struct{})
+ go func() {
+ _ = nb.AddPeer(context.Background(), accountID, DomainServiceKey("svc2.example"), "key-2", types.ServiceID("svc-2"))
+ close(addReturned)
+ }()
+
+ select {
+ case <-addEntered:
+ t.Fatal("AddPeer entered client creation while teardown held the lifecycle lock โ handoff race not closed")
+ case <-addReturned:
+ t.Fatal("AddPeer completed while teardown held the lifecycle lock โ not serialised")
+ case <-time.After(300 * time.Millisecond):
+ }
+
+ close(releaseTeardown)
+ select {
+ case <-addReturned:
+ case <-time.After(2 * time.Second):
+ t.Fatal("AddPeer never completed after teardown released the lifecycle lock")
+ }
+}
+
// TestNotifyClientReady_UsesBackgroundCtx pins the contract that the
// post-Start hooks (readyHandler + statusNotifier.NotifyStatus) run on
// a fresh context.Background() rather than inheriting the AddPeer
diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go
index 41d4bc496..0d4aded9c 100644
--- a/proxy/lifecycle.go
+++ b/proxy/lifecycle.go
@@ -114,6 +114,10 @@ type Config struct {
MaxDialTimeout time.Duration
// MaxSessionIdleTimeout caps the per-service session idle timeout.
MaxSessionIdleTimeout time.Duration
+ // MappingBatchWatchdog bounds how long a single mapping batch may spend
+ // being applied before the receive loop reconnects to resync. Zero falls
+ // back to the internal default.
+ MappingBatchWatchdog time.Duration
// GeoDataDir is the directory containing GeoLite2 MMDB files.
GeoDataDir string
@@ -164,6 +168,7 @@ func New(ctx context.Context, cfg Config) *Server {
Private: cfg.Private,
MaxDialTimeout: cfg.MaxDialTimeout,
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
+ MappingBatchWatchdog: cfg.MappingBatchWatchdog,
GeoDataDir: cfg.GeoDataDir,
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
diff --git a/proxy/mapping_stall_test.go b/proxy/mapping_stall_test.go
new file mode 100644
index 000000000..acf313d19
--- /dev/null
+++ b/proxy/mapping_stall_test.go
@@ -0,0 +1,282 @@
+package proxy
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+
+ "github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// blockingMgmtClient implements roundtrip's managementClient interface.
+// CreateProxyPeer parks until release is closed, signalling entry on entered.
+// This reproduces the confirmed real-world stall: createClientEntry calls
+// CreateProxyPeer synchronously while holding clientsMux, and the proxy's
+// receive loop calls that path synchronously inside processMappings.
+type blockingMgmtClient struct {
+ entered chan struct{}
+ once sync.Once
+}
+
+func (b *blockingMgmtClient) CreateProxyPeer(ctx context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ b.once.Do(func() { close(b.entered) })
+ // Park until the caller's context is cancelled. In production this ctx is
+ // the gRPC mapping-stream context with no per-call timeout, so a slow or
+ // unresponsive CreateProxyPeer parks the receive loop here indefinitely.
+ <-ctx.Done()
+ return nil, ctx.Err()
+}
+
+// gatedMappingStream is a mock GetMappingUpdate client stream that hands out a
+// pre-seeded list of messages, then records how many times Recv advanced. It
+// lets the test observe whether the single-threaded receive loop ever gets
+// past the first (blocking) batch to pull the second message.
+type gatedMappingStream struct {
+ grpc.ClientStream
+ messages []*proto.GetMappingUpdateResponse
+ idx int32
+}
+
+func (g *gatedMappingStream) Recv() (*proto.GetMappingUpdateResponse, error) {
+ i := int(atomic.LoadInt32(&g.idx))
+ if i >= len(g.messages) {
+ // Block instead of returning EOF so the loop doesn't exit; we only
+ // care whether the loop ever reaches this second Recv at all.
+ select {}
+ }
+ msg := g.messages[i]
+ atomic.AddInt32(&g.idx, 1)
+ return msg, nil
+}
+
+func (g *gatedMappingStream) deliveredCount() int32 { return atomic.LoadInt32(&g.idx) }
+
+func (g *gatedMappingStream) Header() (metadata.MD, error) { return nil, nil } //nolint:nilnil
+func (g *gatedMappingStream) Trailer() metadata.MD { return nil }
+func (g *gatedMappingStream) CloseSend() error { return nil }
+func (g *gatedMappingStream) Context() context.Context { return context.Background() }
+func (g *gatedMappingStream) SendMsg(any) error { return nil }
+func (g *gatedMappingStream) RecvMsg(any) error { return nil }
+
+// noopNotifier satisfies roundtrip's statusNotifier interface.
+type noopNotifier struct{}
+
+func (noopNotifier) NotifyStatus(context.Context, types.AccountID, types.ServiceID, bool) error {
+ return nil
+}
+
+// noopProxyClient is a proto.ProxyServiceClient that no-ops the one method the
+// teardown unwind reaches (SendStatusUpdate, via notifyError when the parked
+// AddPeer is cancelled). The embedded nil interface satisfies the rest at
+// compile time; none of those methods are called by this test.
+type noopProxyClient struct {
+ proto.ProxyServiceClient
+}
+
+func (noopProxyClient) SendStatusUpdate(context.Context, *proto.SendStatusUpdateRequest, ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) {
+ return &proto.SendStatusUpdateResponse{}, nil
+}
+
+// TestMappingStream_StallsWhenApplyBlocks proves the deadlock: the proxy's
+// mapping receive loop processes batches strictly serially, so when applying
+// one batch blocks (here: createClientEntry parked on a synchronous
+// CreateProxyPeer call, exactly as observed in production), the loop never
+// advances to Recv the next batch. Management can keep sending updates onto
+// the stream with no error and no channel overflow, yet the proxy applies
+// nothing further โ it is stuck.
+func TestMappingStream_StallsWhenApplyBlocks(t *testing.T) {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+
+ mgmt := &blockingMgmtClient{
+ entered: make(chan struct{}),
+ }
+
+ nb := roundtrip.NewNetBird(
+ context.Background(),
+ "proxy-test",
+ "proxy.example.com",
+ roundtrip.ClientConfig{},
+ logger,
+ noopNotifier{},
+ mgmt,
+ )
+
+ s := &Server{
+ Logger: logger,
+ netbird: nb,
+ mgmtClient: noopProxyClient{},
+ routerReady: closedChan(),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ }
+
+ // First batch: a CREATED mapping for a brand-new account. addMapping ->
+ // netbird.AddPeer -> createClientEntry -> CreateProxyPeer, which blocks.
+ // Empty Path keeps setupHTTPMapping a no-op (it returns early), so the
+ // ONLY blocking point is the synchronous CreateProxyPeer in AddPeer โ
+ // no routers/auth need wiring. The second batch exists only to detect
+ // whether the loop ever advances past the blocked first batch.
+ stream := &gatedMappingStream{
+ messages: []*proto.GetMappingUpdateResponse{
+ {
+ Mapping: []*proto.ProxyMapping{
+ {
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-1",
+ AccountId: "acct-1",
+ AuthToken: "token-1",
+ },
+ },
+ },
+ {
+ Mapping: []*proto.ProxyMapping{
+ {
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-2",
+ AccountId: "acct-2",
+ AuthToken: "token-2",
+ },
+ },
+ },
+ },
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ // Unblock the parked apply on teardown via ctx (CreateProxyPeer returns
+ // ctx.Err()), so the wedged loop goroutine unwinds before embed.New โ
+ // avoiding any dependency on collaborators this test deliberately leaves
+ // nil. The deadlock is fully proven before this fires.
+ t.Cleanup(cancel)
+
+ loopDone := make(chan struct{})
+ syncDone := false
+ go func() {
+ defer close(loopDone)
+ _ = s.handleMappingStream(ctx, stream, &syncDone, time.Time{})
+ }()
+
+ // The loop must reach the blocking apply for the first batch.
+ select {
+ case <-mgmt.entered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("receive loop never reached CreateProxyPeer for the first batch")
+ }
+
+ // THE DEADLOCK: while the first batch is parked in CreateProxyPeer, the
+ // single-threaded loop cannot advance. The second batch is never pulled,
+ // even though it is already available on the stream. Give it ample time.
+ // deliveredCount is atomic; syncDone is intentionally not read here because
+ // the loop goroutine owns it (reading it from the test would race).
+ time.Sleep(500 * time.Millisecond)
+ assert.Equal(t, int32(1), stream.deliveredCount(),
+ "loop must NOT consume the second batch while the first is blocked in apply โ proxy is stuck")
+
+ select {
+ case <-loopDone:
+ t.Fatal("receive loop returned while it should be wedged in apply")
+ default:
+ // Still wedged, as expected.
+ }
+}
+
+// TestMappingStream_StallsWhenRemoveBlocks proves the deadlock for the REMOVE
+// path observed in production: a mapping remove tears down the account's last
+// embedded client via netbird.RemovePeer -> client.Stop -> Engine.Stop, whose
+// jobExecutorWG.Wait() is unbounded. Because the receive loop is single-
+// threaded, a blocked remove wedges the loop: no further mapping updates of any
+// kind (create/modify/remove) are applied, while management keeps sending them
+// successfully (no send error, no channel-full). Matches the reported symptom:
+// the last log line is a remove that stops a client, then silence.
+func TestMappingStream_StallsWhenRemoveBlocks(t *testing.T) {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+
+ enteredRemove := make(chan struct{})
+ blockRemove := make(chan struct{})
+ var once sync.Once
+
+ s := &Server{
+ Logger: logger,
+ mgmtClient: noopProxyClient{},
+ routerReady: closedChan(),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ // Stand in for netbird.RemovePeer -> client.Stop hanging on
+ // Engine.Stop's unbounded jobExecutorWG.Wait(). Only the first remove
+ // blocks; later removes return immediately so the recovery assertion
+ // can observe the loop advancing.
+ removePeer: func(ctx context.Context, _ types.AccountID, _ roundtrip.ServiceKey) error {
+ first := false
+ once.Do(func() {
+ first = true
+ close(enteredRemove)
+ })
+ if !first {
+ return nil
+ }
+ select {
+ case <-blockRemove:
+ case <-ctx.Done():
+ }
+ return nil
+ },
+ }
+
+ // Batch 1 removes a service (blocks in teardown). Batch 2 is a later update
+ // that must never be applied while the remove is wedged.
+ stream := &gatedMappingStream{
+ messages: []*proto.GetMappingUpdateResponse{
+ {
+ Mapping: []*proto.ProxyMapping{
+ {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-1", AccountId: "acct-1"},
+ },
+ },
+ {
+ Mapping: []*proto.ProxyMapping{
+ {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-2", AccountId: "acct-1"},
+ },
+ },
+ },
+ }
+
+ loopDone := make(chan struct{})
+ syncDone := false
+ go func() {
+ defer close(loopDone)
+ _ = s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
+ }()
+
+ select {
+ case <-enteredRemove:
+ case <-time.After(2 * time.Second):
+ t.Fatal("receive loop never reached the blocking remove for the first batch")
+ }
+
+ // THE DEADLOCK: the loop is parked in the blocked remove and cannot advance.
+ // syncDone is owned by the loop goroutine, so it is not read here.
+ time.Sleep(500 * time.Millisecond)
+ assert.Equal(t, int32(1), stream.deliveredCount(),
+ "loop must NOT consume the second batch while the first remove is blocked โ proxy is stuck")
+
+ select {
+ case <-loopDone:
+ t.Fatal("receive loop returned while it should be wedged on the remove")
+ default:
+ }
+
+ // Unblock and confirm the wedge was solely the blocked remove: the loop
+ // then advances and consumes the next batch.
+ close(blockRemove)
+ assert.Eventually(t, func() bool {
+ return stream.deliveredCount() >= 2
+ }, 2*time.Second, 5*time.Millisecond,
+ "once the remove unblocks, the loop must advance and consume the next batch")
+}
diff --git a/proxy/server.go b/proxy/server.go
index 1f5e0abd6..6d5acfe46 100644
--- a/proxy/server.go
+++ b/proxy/server.go
@@ -118,6 +118,9 @@ type Server struct {
// The mapping worker waits on this before processing updates.
routerReady chan struct{}
+ // removePeer defaults to netbird.RemovePeer; overridable in tests.
+ removePeer func(ctx context.Context, accountID types.AccountID, key roundtrip.ServiceKey) error
+
// inbound, when non-nil, manages per-account inbound listeners. Set by
// initPrivateInbound only when Private is true so the standalone
// proxy keeps its zero-overhead default path.
@@ -227,6 +230,10 @@ type Server struct {
// Zero means no cap (the proxy honors whatever management sends).
// Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments.
MaxSessionIdleTimeout time.Duration
+ // MappingBatchWatchdog bounds how long a single mapping batch may spend
+ // in processMappings before the receive loop reconnects to resync.
+ // Zero uses defaultMappingBatchWatchdog.
+ MappingBatchWatchdog time.Duration
}
// clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured.
@@ -1172,24 +1179,30 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
s.healthChecker.SetManagementConnected(false)
}
+ connected := false
+ onConnected := func() { connected = true }
+
var streamErr error
if syncSupported {
- streamErr = s.trySyncMappings(ctx, client, &initialSyncDone)
+ streamErr = s.trySyncMappings(ctx, client, &initialSyncDone, onConnected)
if isSyncUnimplemented(streamErr) {
syncSupported = false
s.Logger.Info("management does not support SyncMappings, falling back to GetMappingUpdate")
- streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone)
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected)
}
} else {
- streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone)
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected)
}
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(false)
}
- // Stream established โ reset backoff so the next failure retries quickly.
- bo.Reset()
+ // Reset backoff only when a stream actually connected, so immediate
+ // connect failures still back off instead of spinning.
+ if connected {
+ bo.Reset()
+ }
if streamErr == nil {
return fmt.Errorf("stream closed by server")
@@ -1221,7 +1234,7 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
}
}
-func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error {
+func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error {
connectTime := time.Now()
mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
ProxyId: s.ID,
@@ -1234,6 +1247,7 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ
return fmt.Errorf("create mapping stream: %w", err)
}
+ onConnected()
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(true)
}
@@ -1242,7 +1256,7 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ
return s.handleMappingStream(ctx, mappingClient, initialSyncDone, connectTime)
}
-func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error {
+func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error {
connectTime := time.Now()
stream, err := client.SyncMappings(ctx)
if err != nil {
@@ -1263,6 +1277,7 @@ func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceC
return fmt.Errorf("send sync init: %w", err)
}
+ onConnected()
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(true)
}
@@ -1307,7 +1322,9 @@ func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.Prox
batchStart := time.Now()
s.Logger.Debug("Received mapping update, starting processing")
- s.processMappings(ctx, msg.GetMapping())
+ if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil {
+ return err
+ }
s.Logger.Debug("Processing mapping update completed")
tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
@@ -1391,7 +1408,9 @@ func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.Pr
batchStart := time.Now()
s.Logger.Debug("Received mapping update, starting processing")
- s.processMappings(ctx, msg.GetMapping())
+ if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil {
+ return err
+ }
s.Logger.Debug("Processing mapping update completed")
tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
}
@@ -1456,6 +1475,44 @@ func redactMappingForLog(m *proto.ProxyMapping) *proto.ProxyMapping {
return c
}
+const defaultMappingBatchWatchdog = 2 * time.Minute
+
+// mappingBatchWatchdog returns the configured batch watchdog or the default.
+func (s *Server) mappingBatchWatchdog() time.Duration {
+ if s.MappingBatchWatchdog > 0 {
+ return s.MappingBatchWatchdog
+ }
+ return defaultMappingBatchWatchdog
+}
+
+// processMappingsGuarded applies a batch under a watchdog, returning an error
+// if processing exceeds the watchdog so the caller reconnects and resyncs
+// instead of wedging silently.
+func (s *Server) processMappingsGuarded(ctx context.Context, mappings []*proto.ProxyMapping) error {
+ batchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ s.processMappings(batchCtx, mappings)
+ }()
+
+ watchdog := s.mappingBatchWatchdog()
+ timer := time.NewTimer(watchdog)
+ defer timer.Stop()
+
+ select {
+ case <-done:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ s.Logger.Errorf("processing mapping batch exceeded %s, cancelling and reconnecting to resync", watchdog)
+ return fmt.Errorf("mapping batch processing stalled after %s", watchdog)
+ }
+}
+
func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) {
debug := s.Logger != nil && s.Logger.IsLevelEnabled(log.DebugLevel)
for _, mapping := range mappings {
@@ -1951,7 +2008,11 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
func (s *Server) removeMapping(ctx context.Context, mapping *proto.ProxyMapping) {
accountID := types.AccountID(mapping.GetAccountId())
svcKey := s.serviceKeyForMapping(mapping)
- if err := s.netbird.RemovePeer(ctx, accountID, svcKey); err != nil {
+ removePeer := s.removePeer
+ if removePeer == nil {
+ removePeer = s.netbird.RemovePeer
+ }
+ if err := removePeer(ctx, accountID, svcKey); err != nil {
s.Logger.WithFields(log.Fields{
"account_id": accountID,
"service_id": mapping.GetId(),
From a40028092debb75a7a42e89c126d49882045fd65 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Tue, 9 Jun 2026 15:24:26 +0200
Subject: [PATCH 44/44] [management] log user agent and return request id
(#6380)
---
formatter/hook/hook.go | 3 ++
management/server/context/keys.go | 1 +
.../server/telemetry/http_api_metrics.go | 6 ++++
shared/context/keys.go | 1 +
shared/management/http/api/openapi.yml | 32 +++++++++++++++++++
5 files changed, 43 insertions(+)
diff --git a/formatter/hook/hook.go b/formatter/hook/hook.go
index f0ee509f8..69758566d 100644
--- a/formatter/hook/hook.go
+++ b/formatter/hook/hook.go
@@ -99,6 +99,9 @@ func addFields(entry *logrus.Entry) {
if ctxAccountID, ok := entry.Context.Value(context.AccountIDKey).(string); ok {
entry.Data[context.AccountIDKey] = ctxAccountID
}
+ if ctxUserAgent, ok := entry.Context.Value(context.UserAgentKey).(string); ok {
+ entry.Data[context.UserAgentKey] = ctxUserAgent
+ }
if ctxInitiatorID, ok := entry.Context.Value(context.UserIDKey).(string); ok {
entry.Data[context.UserIDKey] = ctxInitiatorID
}
diff --git a/management/server/context/keys.go b/management/server/context/keys.go
index 7a65afbbd..aa534c5d9 100644
--- a/management/server/context/keys.go
+++ b/management/server/context/keys.go
@@ -12,6 +12,7 @@ const (
RoleKey = nbcontext.RoleKey
UserIDKey = nbcontext.UserIDKey
PeerIDKey = nbcontext.PeerIDKey
+ UserAgentKey = nbcontext.UserAgentKey
)
// RoleFromContext returns the role stored in ctx, or empty string and false if absent.
diff --git a/management/server/telemetry/http_api_metrics.go b/management/server/telemetry/http_api_metrics.go
index e48e6d64a..360d36949 100644
--- a/management/server/telemetry/http_api_metrics.go
+++ b/management/server/telemetry/http_api_metrics.go
@@ -21,6 +21,8 @@ const (
httpRequestCounterPrefix = "management.http.request.counter"
httpResponseCounterPrefix = "management.http.response.counter"
httpRequestDurationPrefix = "management.http.request.duration.ms"
+
+ RequestIDHeader = "X-Request-Id"
)
// WrappedResponseWriter is a wrapper for http.ResponseWriter that allows the
@@ -172,6 +174,10 @@ func (m *HTTPMiddleware) Handler(h http.Handler) http.Handler {
reqID := xid.New().String()
//nolint
ctx = context.WithValue(ctx, nbContext.RequestIDKey, reqID)
+ //nolint
+ ctx = context.WithValue(ctx, nbContext.UserAgentKey, r.UserAgent())
+
+ rw.Header().Set(RequestIDHeader, reqID)
log.WithContext(ctx).Tracef("HTTP request %v: %v %v", reqID, r.Method, r.URL)
diff --git a/shared/context/keys.go b/shared/context/keys.go
index ca56be67e..3287a6366 100644
--- a/shared/context/keys.go
+++ b/shared/context/keys.go
@@ -6,4 +6,5 @@ const (
RoleKey = "role"
UserIDKey = "userID"
PeerIDKey = "peerID"
+ UserAgentKey = "userAgent"
)
diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml
index 03e30e6b7..f8c687b7b 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -5107,31 +5107,63 @@ components:
responses:
not_found:
description: Resource not found
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
validation_failed_simple:
description: Validation failed
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
bad_request:
description: Bad Request
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
internal_error:
description: Internal Server Error
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
validation_failed:
description: Validation failed
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
forbidden:
description: Forbidden
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
requires_authentication:
description: Requires authentication
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
conflict:
description: Conflict
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
+ headers:
+ X-Request-Id:
+ description: |
+ Unique identifier assigned to the request by the server and set on every
+ response. Useful for correlating client requests with server-side logs.
+ schema:
+ type: string
+ example: cot7r4n3l3vh3qj4qveg
securitySchemes:
BearerAuth:
type: http