[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
+101 -29
View File
@@ -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()
}
}