When a proxy connects with capabilities, the registerProxyConnection
path constructs a *proxy.Capabilities literal but only copies three of
the four fields (SupportsCustomPorts, RequireSubdomain, SupportsCrowdsec)
— Private is silently dropped. Effect: the DB's proxies.private column
stays NULL for every connection, GetClusterSupportsPrivate sees no
proxy reported the capability and returns nil, and the cluster API
response strips "private" via openapi omitempty. The dashboard then
can't tell a private cluster apart from a centralised one.
Add the missing field to the struct literal. No schema change — the
Capabilities.Private column already exists (gorm:"embedded"); only
the write path was broken.
After redeploy + a proxy reconnect, /api/reverse-proxies/clusters now
returns "private": true for clusters where an embedded `netbird proxy`
is connected.
- status_test.go TestStatus_PeerStateByIP: replace
require := assert.New(t) shadowing pattern with req := require.New(t)
so setup assertions are fail-fast and the require package isn't shadowed.
Add TestStatus_PeerStateByIP_MatchesIPv6 for the IPv6-only path.
- status.go PeerStateByIP: match against both State.IP and State.IPv6 so
IPv6-only peers are found by the private-service tunnel lookup. Empty
input short-circuits before the loop and empty State.IP/State.IPv6
fields are treated as non-matches.
- proxy.go ValidateTunnelPeer: call enforceAccountScope(ctx, service.AccountID)
after the service lookup, mirroring ValidateSession. Without it, an
account-scoped (BYOP) proxy token could mint session JWTs for another
account's domain.
- sql_store.go getClusterCapability: thread the caller's context into the
GORM query via WithContext(ctx) so the lookup is cancellable and honours
request deadlines. (Pre-existing on origin/main; included here because
GetClusterSupportsPrivate added by this PR is now a caller.)
Skipped:
- proxyAcceptsMapping SupportsCustomPorts == true: the existing != nil
check is intentional. The accompanying test in this PR
(TestSendServiceUpdateToCluster_FiltersOnCapability) explicitly asserts
"new proxy with SupportsCustomPorts=false should still receive mapping"
— the non-nil check encodes "proxy is new enough to understand the
protocol", not "proxy can bind custom ports". Tightening to *bool==true
would break that design and the test.
Reinstates the SyncMappings RPC that landed on origin/main and the
client-side fallback to GetMappingUpdate.
- proto: SyncMappings RPC + SyncMappingsRequest{Init|Ack} +
SyncMappingsResponse messages.
- management proxy.go: SyncMappings server handler, recvSyncInit,
sendSnapshotSync (per-batch send-then-wait-for-ack), drainRecv,
waitForAck; proxyConnection.syncStream + sendResponse routes the
same sendChan onto the bidi stream when set.
- proxy/server.go: trySyncMappings + handleSyncMappingsStream that
acks after each batch is processed; outer loop tries SyncMappings
first and falls back to GetMappingUpdate on Unimplemented.
Capabilities lifted into proxyCapabilities() so both code paths
use the same flags.
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.