mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-21 08:09:55 +00:00
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.
128 lines
3.6 KiB
Go
128 lines
3.6 KiB
Go
package proxy
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
|
)
|
|
|
|
// PathRewriteMode controls how the request path is rewritten before forwarding.
|
|
type PathRewriteMode int
|
|
|
|
const (
|
|
// PathRewriteDefault strips the matched prefix and joins with the target path.
|
|
PathRewriteDefault PathRewriteMode = iota
|
|
// PathRewritePreserve keeps the full original request path as-is.
|
|
PathRewritePreserve
|
|
)
|
|
|
|
// PathTarget holds a backend URL and per-target behavioral options.
|
|
type PathTarget struct {
|
|
URL *url.URL
|
|
SkipTLSVerify bool
|
|
RequestTimeout time.Duration
|
|
PathRewrite PathRewriteMode
|
|
CustomHeaders map[string]string
|
|
// DirectUpstream selects the stdlib HTTP transport (host network stack)
|
|
// over the embedded NetBird WireGuard client when forwarding requests
|
|
// to this target. Default false → embedded client (existing behaviour).
|
|
DirectUpstream bool
|
|
}
|
|
|
|
// Mapping describes how a domain is routed by the HTTP reverse proxy.
|
|
type Mapping struct {
|
|
ID types.ServiceID
|
|
AccountID types.AccountID
|
|
Host string
|
|
Paths map[string]*PathTarget
|
|
PassHostHeader bool
|
|
RewriteRedirects bool
|
|
// StripAuthHeaders are header names used for header-based auth.
|
|
// These headers are stripped from requests before forwarding.
|
|
StripAuthHeaders []string
|
|
// sortedPaths caches the paths sorted by length (longest first).
|
|
sortedPaths []string
|
|
}
|
|
|
|
type targetResult struct {
|
|
target *PathTarget
|
|
matchedPath string
|
|
serviceID types.ServiceID
|
|
accountID types.AccountID
|
|
passHostHeader bool
|
|
rewriteRedirects bool
|
|
stripAuthHeaders []string
|
|
}
|
|
|
|
func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bool) {
|
|
p.mappingsMux.RLock()
|
|
defer p.mappingsMux.RUnlock()
|
|
|
|
// Strip port from host if present (e.g., "external.test:8443" -> "external.test")
|
|
host := req.Host
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
|
|
m, exists := p.mappings[host]
|
|
if !exists {
|
|
p.logger.Debugf("no mapping found for host: %s", host)
|
|
return targetResult{}, false
|
|
}
|
|
|
|
for _, path := range m.sortedPaths {
|
|
if strings.HasPrefix(req.URL.Path, path) {
|
|
pt := m.Paths[path]
|
|
if pt == nil || pt.URL == nil {
|
|
p.logger.Warnf("invalid mapping for host: %s, path: %s (nil target)", host, path)
|
|
continue
|
|
}
|
|
p.logger.Debugf("matched host: %s, path: %s -> %s", host, path, pt.URL)
|
|
return targetResult{
|
|
target: pt,
|
|
matchedPath: path,
|
|
serviceID: m.ID,
|
|
accountID: m.AccountID,
|
|
passHostHeader: m.PassHostHeader,
|
|
rewriteRedirects: m.RewriteRedirects,
|
|
stripAuthHeaders: m.StripAuthHeaders,
|
|
}, true
|
|
}
|
|
}
|
|
p.logger.Debugf("no path match for host: %s, path: %s", host, req.URL.Path)
|
|
return targetResult{}, false
|
|
}
|
|
|
|
// AddMapping registers a host-to-backend mapping for the reverse proxy.
|
|
func (p *ReverseProxy) AddMapping(m Mapping) {
|
|
// Sort paths longest-first to match the most specific route first.
|
|
paths := make([]string, 0, len(m.Paths))
|
|
for path := range m.Paths {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Slice(paths, func(i, j int) bool {
|
|
return len(paths[i]) > len(paths[j])
|
|
})
|
|
m.sortedPaths = paths
|
|
|
|
p.mappingsMux.Lock()
|
|
defer p.mappingsMux.Unlock()
|
|
p.mappings[m.Host] = m
|
|
}
|
|
|
|
// RemoveMapping removes the mapping for the given host and reports whether it existed.
|
|
func (p *ReverseProxy) RemoveMapping(m Mapping) bool {
|
|
p.mappingsMux.Lock()
|
|
defer p.mappingsMux.Unlock()
|
|
if _, ok := p.mappings[m.Host]; !ok {
|
|
return false
|
|
}
|
|
delete(p.mappings, m.Host)
|
|
return true
|
|
}
|