[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.
This commit is contained in:
Maycon Santos
2026-05-25 17:41:50 +02:00
committed by GitHub
parent 0358be2313
commit 7aebdd69dd
84 changed files with 7810 additions and 933 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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())

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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"`

File diff suppressed because it is too large Load Diff

View File

@@ -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 <tunnel_ip>:<https_port> 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;
}

View File

@@ -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{
{