feat(private-service): expose NetBird-only services over tunnel peers

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:
mlsmaycon
2026-05-20 21:39:22 +02:00
parent 37052fd5bc
commit 167ee08e14
72 changed files with 6584 additions and 2586 deletions

View File

@@ -2896,6 +2896,11 @@ components:
additionalProperties:
type: string
description: "Extra context about the request (e.g. crowdsec_verdict)"
user_groups:
type: array
items:
type: string
description: "Group IDs the user belonged to when the entry was written"
required:
- id
- service_id
@@ -3067,6 +3072,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 +3163,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 +3212,14 @@ 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 +3230,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 +3474,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 +3533,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:
@@ -3783,6 +3786,9 @@ type ProxyAccessLog struct {
// Timestamp Timestamp when the request was made
Timestamp time.Time `json:"timestamp"`
// UserGroups Group IDs the user belonged to when the entry was written
UserGroups *[]string `json:"user_groups,omitempty"`
// UserId ID of the authenticated user, if applicable
UserId *string `json:"user_id,omitempty"`
}
@@ -3819,6 +3825,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 +3905,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 +4097,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 +4129,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 +4174,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 +4199,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 +4248,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). Default false.
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"`