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 22:46:18 +02:00
parent 37052fd5bc
commit 167ee08e14
72 changed files with 6584 additions and 2586 deletions
@@ -39,6 +39,10 @@ type AccessLogEntry struct {
BytesDownload int64 `gorm:"index"`
Protocol AccessLogProtocol `gorm:"index"`
Metadata map[string]string `gorm:"serializer:json"`
// UserGroups captures the group IDs the user (UserId) belonged to at
// the time the entry was written, so the dashboard can render group
// context without reverse-resolving stale memberships.
UserGroups []string `gorm:"serializer:json"`
}
// FromProto creates an AccessLogEntry from a proto.AccessLog
@@ -125,6 +129,12 @@ func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog {
metadata = &a.Metadata
}
var userGroups *[]string
if len(a.UserGroups) > 0 {
groups := append([]string(nil), a.UserGroups...)
userGroups = &groups
}
return &api.ProxyAccessLog{
Id: a.ID,
ServiceId: a.ServiceID,
@@ -145,5 +155,6 @@ func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog {
BytesDownload: a.BytesDownload,
Protocol: protocol,
Metadata: metadata,
UserGroups: userGroups,
}
}
@@ -0,0 +1,41 @@
package manager
import (
"context"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
func TestSaveAccessLog_EnrichesUserGroups(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockStore(ctrl)
user := &types.User{Id: "u1", AutoGroups: []string{"g1", "g2"}}
mockStore.EXPECT().
GetUserByUserID(gomock.Any(), store.LockingStrengthNone, "u1").
Return(user, nil)
var captured *accesslogs.AccessLogEntry
mockStore.EXPECT().
CreateAccessLog(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, e *accesslogs.AccessLogEntry) error {
captured = e
return nil
})
m := &managerImpl{store: mockStore}
entry := &accesslogs.AccessLogEntry{AccountID: "acc-1", UserId: "u1"}
require.NoError(t, m.SaveAccessLog(context.Background(), entry))
require.NotNil(t, captured, "CreateAccessLog must receive the entry")
assert.Equal(t, []string{"g1", "g2"}, captured.UserGroups, "UserGroups should be hydrated from the user record")
}
@@ -47,6 +47,8 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac
}
}
m.enrichUserGroups(ctx, logEntry)
if err := m.store.CreateAccessLog(ctx, logEntry); err != nil {
log.WithContext(ctx).WithFields(log.Fields{
"service_id": logEntry.ServiceID,
@@ -161,6 +163,25 @@ func (m *managerImpl) StopPeriodicCleanup() {
}
}
// enrichUserGroups attaches the user's auto-group memberships to the entry.
// Best-effort: errors are logged at debug and never block the save.
func (m *managerImpl) enrichUserGroups(ctx context.Context, logEntry *accesslogs.AccessLogEntry) {
if logEntry.UserId == "" {
return
}
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, logEntry.UserId)
if err != nil {
log.WithContext(ctx).Debugf("access log user-group enrichment skipped for user %s: %v", logEntry.UserId, err)
return
}
if user == nil {
return
}
logEntry.UserGroups = append([]string(nil), user.AutoGroups...)
}
// resolveUserFilters converts user email/name filters to user ID filter
func (m *managerImpl) resolveUserFilters(ctx context.Context, accountID string, filter *accesslogs.AccessLogFilter) error {
if filter.UserEmail == nil && filter.UserName == nil {