mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-24 16:41:30 +02:00
People who only ever reach private services through the reverse proxy were invisible to activity accounting. Active users are counted from user.LastLogin or from the LastSeen of a peer they own, and neither column was written on the proxy paths — so a person signing in via SSO to a proxied service, or a peer serving one over the mesh, never showed up in the 24 hour numbers. Both writes now happen where the proxy already authenticates: - GenerateSessionToken stamps LastLogin after the session token is signed, the same column and the same way the dashboard and device login paths do. - ValidateTunnelPeer stamps the calling peer's LastSeen, the column its owner activates through. The policy lives in a new reverseproxy/activity manager rather than in the gRPC service, matching the module layout the other reverse proxy domains use. It skips what can never count — service users, embedded proxy peers and WASM clients — and throttles peer writes to once an hour, well inside the window accounting asks about and far above the proxy's five minute tunnel cache. The peer write is a single indexed UPDATE that touches only peer_status_last_seen. Connected and SessionStartedAt are left alone so the session-ownership fencing MarkPeerConnectedIfNewerSession relies on is never disturbed, and the timestamp comes from the database clock rather than the caller, for the same reason the other status writers take it from there. The caller's cutoff travels into the statement's WHERE, so concurrent requests for one peer collapse into a single write instead of each acting on its own stale read, and a peer that was never seen — NULL last seen, since Status is an embedded pointer — still records its first activity. Nothing outside the reverse proxy changes behaviour: the only addition elsewhere is the RefreshPeerLastSeen store method the manager calls.
362 lines
14 KiB
Go
362 lines
14 KiB
Go
package server
|
|
|
|
// @note this file includes all the lower level dependencies, db, http and grpc BaseServer, metrics, logger, etc.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
grpcMiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2"
|
|
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip"
|
|
"github.com/rs/cors"
|
|
"github.com/rs/xid"
|
|
log "github.com/sirupsen/logrus"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/keepalive"
|
|
|
|
cachestore "github.com/eko/gocache/lib/v4/store"
|
|
|
|
"github.com/netbirdio/netbird/encryption"
|
|
"github.com/netbirdio/netbird/formatter/hook"
|
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
|
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
|
proxyactivity "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
|
|
proxyactivitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager"
|
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
|
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
|
"github.com/netbirdio/netbird/management/server/activity"
|
|
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
|
nbContext "github.com/netbirdio/netbird/management/server/context"
|
|
nbhttp "github.com/netbirdio/netbird/management/server/http"
|
|
"github.com/netbirdio/netbird/management/server/http/middleware"
|
|
"github.com/netbirdio/netbird/management/server/idp"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/telemetry"
|
|
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
|
"github.com/netbirdio/netbird/util/crypt"
|
|
)
|
|
|
|
const apiPrefix = "/api"
|
|
|
|
var (
|
|
kaep = keepalive.EnforcementPolicy{
|
|
MinTime: 15 * time.Second,
|
|
PermitWithoutStream: true,
|
|
}
|
|
|
|
kasp = keepalive.ServerParameters{
|
|
MaxConnectionIdle: 15 * time.Second,
|
|
MaxConnectionAgeGrace: 5 * time.Second,
|
|
Time: 5 * time.Second,
|
|
Timeout: 2 * time.Second,
|
|
}
|
|
)
|
|
|
|
func (s *BaseServer) Metrics() telemetry.AppMetrics {
|
|
return Create(s, func() telemetry.AppMetrics {
|
|
appMetrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
|
if err != nil {
|
|
log.Fatalf("error while creating app metrics: %s", err)
|
|
}
|
|
return appMetrics
|
|
})
|
|
}
|
|
|
|
// CacheStore returns a shared cache store backed by Redis or in-memory depending on the environment.
|
|
// All consumers should reuse this store to avoid creating multiple Redis connections.
|
|
func (s *BaseServer) CacheStore() cachestore.StoreInterface {
|
|
return Create(s, func() cachestore.StoreInterface {
|
|
cs, err := nbcache.NewStore(context.Background(), nbcache.DefaultStoreMaxTimeout, nbcache.DefaultStoreCleanupInterval, nbcache.DefaultStoreMaxConn)
|
|
if err != nil {
|
|
log.Fatalf("failed to create shared cache store: %v", err)
|
|
}
|
|
return cs
|
|
})
|
|
}
|
|
|
|
func (s *BaseServer) Store() store.Store {
|
|
return Create(s, func() store.Store {
|
|
store, err := store.NewStore(context.Background(), s.Config.StoreConfig.Engine, s.Config.Datadir, s.Metrics(), false)
|
|
if err != nil {
|
|
log.Fatalf("failed to create store: %v", err)
|
|
}
|
|
|
|
if s.Config.DataStoreEncryptionKey != "" {
|
|
fieldEncrypt, err := crypt.NewFieldEncrypt(s.Config.DataStoreEncryptionKey)
|
|
if err != nil {
|
|
log.Fatalf("failed to create field encryptor: %v", err)
|
|
}
|
|
store.SetFieldEncrypt(fieldEncrypt)
|
|
}
|
|
|
|
return store
|
|
})
|
|
}
|
|
|
|
func (s *BaseServer) EventStore() activity.Store {
|
|
return Create(s, func() activity.Store {
|
|
var err error
|
|
key := s.Config.DataStoreEncryptionKey
|
|
if key == "" {
|
|
log.Debugf("generate new activity store encryption key")
|
|
key, err = crypt.GenerateKey()
|
|
if err != nil {
|
|
log.Fatalf("failed to generate event store encryption key: %v", err)
|
|
}
|
|
}
|
|
|
|
eventStore, err := activitystore.NewSqlStore(context.Background(), s.Config.Datadir, key)
|
|
if err != nil {
|
|
log.Fatalf("failed to initialize event store: %v", err)
|
|
}
|
|
|
|
return eventStore
|
|
})
|
|
}
|
|
|
|
func (s *BaseServer) APIHandler() http.Handler {
|
|
return Create(s, func() http.Handler {
|
|
httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount, s.AgentNetworkManager())
|
|
if err != nil {
|
|
log.Fatalf("failed to create API handler: %v", err)
|
|
}
|
|
return httpAPIHandler
|
|
})
|
|
}
|
|
|
|
// IDPHandler returns the HTTP handler for the embedded IdP (Dex), or nil if
|
|
// the deployment isn't using the embedded variant.
|
|
func (s *BaseServer) IDPHandler() http.Handler {
|
|
embeddedIdP, ok := s.IdpManager().(*idp.EmbeddedIdPManager)
|
|
if !ok || embeddedIdP == nil {
|
|
return nil
|
|
}
|
|
return cors.AllowAll().Handler(embeddedIdP.Handler())
|
|
}
|
|
|
|
func (s *BaseServer) Router() *mux.Router {
|
|
return Create(s, func() *mux.Router {
|
|
return mux.NewRouter().PathPrefix(apiPrefix).Subrouter()
|
|
})
|
|
}
|
|
|
|
func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter {
|
|
return Create(s, func() *middleware.APIRateLimiter {
|
|
cfg, enabled := middleware.RateLimiterConfigFromEnv()
|
|
limiter := middleware.NewAPIRateLimiter(cfg)
|
|
limiter.SetEnabled(enabled)
|
|
return limiter
|
|
})
|
|
}
|
|
|
|
func (s *BaseServer) GRPCServer() *grpc.Server {
|
|
return Create(s, func() *grpc.Server {
|
|
trustedPeers := s.Config.ReverseProxy.TrustedPeers
|
|
defaultTrustedPeers := []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}
|
|
if len(trustedPeers) == 0 || slices.Equal[[]netip.Prefix](trustedPeers, defaultTrustedPeers) {
|
|
log.WithContext(context.Background()).Warn("TrustedPeers are configured to default value '0.0.0.0/0', '::/0'. This allows connection IP spoofing.")
|
|
trustedPeers = defaultTrustedPeers
|
|
}
|
|
trustedHTTPProxies := s.Config.ReverseProxy.TrustedHTTPProxies
|
|
trustedProxiesCount := s.Config.ReverseProxy.TrustedHTTPProxiesCount
|
|
if len(trustedHTTPProxies) > 0 && trustedProxiesCount > 0 {
|
|
log.WithContext(context.Background()).Warn("TrustedHTTPProxies and TrustedHTTPProxiesCount both are configured. " +
|
|
"This is not recommended way to extract X-Forwarded-For. Consider using one of these options.")
|
|
}
|
|
realipOpts := []realip.Option{
|
|
realip.WithTrustedPeers(trustedPeers),
|
|
realip.WithTrustedProxies(trustedHTTPProxies),
|
|
realip.WithTrustedProxiesCount(trustedProxiesCount),
|
|
realip.WithHeaders([]string{realip.XForwardedFor, realip.XRealIp}),
|
|
}
|
|
proxyUnary, proxyStream, proxyAuthClose := nbgrpc.NewProxyAuthInterceptors(s.Store())
|
|
s.proxyAuthClose = proxyAuthClose
|
|
gRPCOpts := []grpc.ServerOption{
|
|
grpc.KeepaliveEnforcementPolicy(kaep),
|
|
grpc.KeepaliveParams(kasp),
|
|
grpc.ChainUnaryInterceptor(realip.UnaryServerInterceptorOpts(realipOpts...), unaryInterceptor, proxyUnary),
|
|
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream),
|
|
}
|
|
|
|
// Append interceptors contributed by registered gRPC extensions. These
|
|
// run after the built-in chain (ChainUnaryInterceptor is additive).
|
|
gRPCOpts = appendExtensionInterceptors(gRPCOpts, s.grpcExtensions)
|
|
|
|
if s.Config.HttpConfig.LetsEncryptDomain != "" {
|
|
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
|
|
if err != nil {
|
|
log.Fatalf("failed to create certificate service: %v", err)
|
|
}
|
|
transportCredentials := credentials.NewTLS(certManager.TLSConfig())
|
|
gRPCOpts = append(gRPCOpts, grpc.Creds(transportCredentials))
|
|
} else if s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "" {
|
|
tlsConfig, err := loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey)
|
|
if err != nil {
|
|
log.Fatalf("cannot load TLS credentials: %v", err)
|
|
}
|
|
transportCredentials := credentials.NewTLS(tlsConfig)
|
|
gRPCOpts = append(gRPCOpts, grpc.Creds(transportCredentials))
|
|
}
|
|
|
|
gRPCAPIHandler := grpc.NewServer(gRPCOpts...)
|
|
srv, err := nbgrpc.NewServer(s.Config, s.AccountManager(), s.SettingsManager(), s.JobManager(), s.SecretsManager(), s.Metrics(), s.AuthManager(), s.IntegratedValidator(), s.NetworkMapController(), s.OAuthConfigProvider(), s.SessionStore())
|
|
if err != nil {
|
|
log.Fatalf("failed to create management server: %v", err)
|
|
}
|
|
serviceMgr := s.ServiceManager()
|
|
srv.SetReverseProxyManager(serviceMgr)
|
|
if serviceMgr != nil {
|
|
serviceMgr.StartExposeReaper(context.Background())
|
|
}
|
|
mgmtProto.RegisterManagementServiceServer(gRPCAPIHandler, srv)
|
|
|
|
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
|
|
log.Info("ProxyService registered on gRPC server")
|
|
|
|
// Register services contributed by external modules via the extension seam.
|
|
registerExtensions(gRPCAPIHandler, s.grpcExtensions)
|
|
|
|
return gRPCAPIHandler
|
|
})
|
|
}
|
|
|
|
func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer {
|
|
return Create(s, func() *nbgrpc.ProxyServiceServer {
|
|
proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store())
|
|
s.AfterInit(func(s *BaseServer) {
|
|
proxyService.SetServiceManager(s.ServiceManager())
|
|
proxyService.SetActivityManager(s.ProxyActivityManager())
|
|
proxyService.SetProxyController(s.ServiceProxyController())
|
|
proxyService.SetAgentNetworkSynthesizer(newAgentNetworkSynthesizer(s.Store()))
|
|
proxyService.SetAgentNetworkLimitsService(s.AgentNetworkManager())
|
|
})
|
|
return proxyService
|
|
})
|
|
}
|
|
|
|
// agentNetworkSynthesizerAdapter implements nbgrpc.AgentNetworkSynthesizer by
|
|
// delegating to the agentnetwork package's store-backed synthesiser.
|
|
type agentNetworkSynthesizerAdapter struct {
|
|
store store.Store
|
|
}
|
|
|
|
func newAgentNetworkSynthesizer(s store.Store) *agentNetworkSynthesizerAdapter {
|
|
return &agentNetworkSynthesizerAdapter{store: s}
|
|
}
|
|
|
|
func (a *agentNetworkSynthesizerAdapter) SynthesizeServicesForCluster(ctx context.Context, clusterAddr string) ([]*rpservice.Service, error) {
|
|
return agentnetwork.SynthesizeServicesForCluster(ctx, a.store, clusterAddr)
|
|
}
|
|
|
|
func (a *agentNetworkSynthesizerAdapter) SynthesizeServicesForAccount(ctx context.Context, accountID string) ([]*rpservice.Service, error) {
|
|
return agentnetwork.SynthesizeServices(ctx, a.store, accountID)
|
|
}
|
|
|
|
func (a *agentNetworkSynthesizerAdapter) SynthesizeServiceForDomain(ctx context.Context, domain string) (*rpservice.Service, error) {
|
|
return agentnetwork.SynthesizeServiceForDomain(ctx, a.store, domain)
|
|
}
|
|
|
|
func (s *BaseServer) proxyOIDCConfig() nbgrpc.ProxyOIDCConfig {
|
|
return Create(s, func() nbgrpc.ProxyOIDCConfig {
|
|
return nbgrpc.ProxyOIDCConfig{
|
|
Issuer: s.Config.HttpConfig.AuthIssuer,
|
|
// todo: double check auth clientID value
|
|
ClientID: s.Config.HttpConfig.AuthClientID, // Reuse dashboard client
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
CallbackURL: s.Config.HttpConfig.AuthCallbackURL,
|
|
HMACKey: []byte(s.Config.DataStoreEncryptionKey), // Use the datastore encryption key for OIDC state HMACs, this should ensure all management instances are using the same key.
|
|
Audience: s.Config.HttpConfig.AuthAudience,
|
|
KeysLocation: s.Config.HttpConfig.AuthKeysLocation,
|
|
}
|
|
})
|
|
}
|
|
|
|
func (s *BaseServer) ProxyTokenStore() *nbgrpc.OneTimeTokenStore {
|
|
return Create(s, func() *nbgrpc.OneTimeTokenStore {
|
|
tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), s.CacheStore())
|
|
log.Info("One-time token store initialized for proxy authentication")
|
|
return tokenStore
|
|
})
|
|
}
|
|
|
|
func (s *BaseServer) PKCEVerifierStore() *nbgrpc.PKCEVerifierStore {
|
|
return Create(s, func() *nbgrpc.PKCEVerifierStore {
|
|
return nbgrpc.NewPKCEVerifierStore(context.Background(), s.CacheStore())
|
|
})
|
|
}
|
|
|
|
// ProxyActivityManager records reverse proxy usage for activity accounting.
|
|
func (s *BaseServer) ProxyActivityManager() proxyactivity.Manager {
|
|
return Create(s, func() proxyactivity.Manager {
|
|
return proxyactivitymanager.NewManager(s.Store())
|
|
})
|
|
}
|
|
|
|
func (s *BaseServer) AccessLogsManager() accesslogs.Manager {
|
|
return Create(s, func() accesslogs.Manager {
|
|
accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager())
|
|
accessLogManager.StartPeriodicCleanup(
|
|
context.Background(),
|
|
s.Config.ReverseProxy.AccessLogRetentionDays,
|
|
s.Config.ReverseProxy.AccessLogCleanupIntervalHours,
|
|
)
|
|
return accessLogManager
|
|
})
|
|
}
|
|
|
|
func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) {
|
|
// Load server's certificate and private key
|
|
serverCert, err := tls.LoadX509KeyPair(certFile, certKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// NewDefaultAppMetrics the credentials and return it
|
|
config := &tls.Config{
|
|
Certificates: []tls.Certificate{serverCert},
|
|
ClientAuth: tls.NoClientCert,
|
|
NextProtos: []string{
|
|
"h2", "http/1.1", // enable HTTP/2
|
|
},
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
func unaryInterceptor(
|
|
ctx context.Context,
|
|
req interface{},
|
|
info *grpc.UnaryServerInfo,
|
|
handler grpc.UnaryHandler,
|
|
) (interface{}, error) {
|
|
reqID := xid.New().String()
|
|
//nolint
|
|
ctx = context.WithValue(ctx, hook.ExecutionContextKey, hook.GRPCSource)
|
|
//nolint
|
|
ctx = context.WithValue(ctx, nbContext.RequestIDKey, reqID)
|
|
return handler(ctx, req)
|
|
}
|
|
|
|
func streamInterceptor(
|
|
srv interface{},
|
|
ss grpc.ServerStream,
|
|
info *grpc.StreamServerInfo,
|
|
handler grpc.StreamHandler,
|
|
) error {
|
|
reqID := xid.New().String()
|
|
wrapped := grpcMiddleware.WrapServerStream(ss)
|
|
//nolint
|
|
ctx := context.WithValue(ss.Context(), hook.ExecutionContextKey, hook.GRPCSource)
|
|
//nolint
|
|
wrapped.WrappedContext = context.WithValue(ctx, nbContext.RequestIDKey, reqID)
|
|
return handler(srv, wrapped)
|
|
}
|