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.
213 lines
7.1 KiB
Go
213 lines
7.1 KiB
Go
package manager
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
|
"github.com/netbirdio/netbird/management/server/geolocation"
|
|
"github.com/netbirdio/netbird/management/server/permissions"
|
|
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
|
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/shared/management/status"
|
|
)
|
|
|
|
type managerImpl struct {
|
|
store store.Store
|
|
permissionsManager permissions.Manager
|
|
geo geolocation.Geolocation
|
|
cleanupCancel context.CancelFunc
|
|
}
|
|
|
|
func NewManager(store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager {
|
|
return &managerImpl{
|
|
store: store,
|
|
permissionsManager: permissionsManager,
|
|
geo: geo,
|
|
}
|
|
}
|
|
|
|
// SaveAccessLog saves an access log entry to the database after enriching it
|
|
func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error {
|
|
if m.geo != nil && logEntry.GeoLocation.ConnectionIP != nil {
|
|
location, err := m.geo.Lookup(logEntry.GeoLocation.ConnectionIP)
|
|
if err != nil {
|
|
log.WithContext(ctx).Warnf("failed to get location for access log source IP [%s]: %v", logEntry.GeoLocation.ConnectionIP.String(), err)
|
|
} else {
|
|
logEntry.GeoLocation.CountryCode = location.Country.ISOCode
|
|
logEntry.GeoLocation.CityName = location.City.Names.En
|
|
logEntry.GeoLocation.GeoNameID = location.City.GeonameID
|
|
if len(location.Subdivisions) > 0 {
|
|
logEntry.SubdivisionCode = location.Subdivisions[0].ISOCode
|
|
}
|
|
}
|
|
}
|
|
|
|
m.enrichUserGroups(ctx, logEntry)
|
|
|
|
if err := m.store.CreateAccessLog(ctx, logEntry); err != nil {
|
|
log.WithContext(ctx).WithFields(log.Fields{
|
|
"service_id": logEntry.ServiceID,
|
|
"method": logEntry.Method,
|
|
"host": logEntry.Host,
|
|
"path": logEntry.Path,
|
|
"status": logEntry.StatusCode,
|
|
}).Errorf("failed to save access log: %v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetAllAccessLogs retrieves access logs for an account with pagination and filtering
|
|
func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) {
|
|
ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
|
|
if err != nil {
|
|
return nil, 0, status.NewPermissionValidationError(err)
|
|
}
|
|
if !ok {
|
|
return nil, 0, status.NewPermissionDeniedError()
|
|
}
|
|
|
|
if err := m.resolveUserFilters(ctx, accountID, filter); err != nil {
|
|
log.WithContext(ctx).Warnf("failed to resolve user filters: %v", err)
|
|
}
|
|
|
|
logs, totalCount, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID, *filter)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return logs, totalCount, nil
|
|
}
|
|
|
|
// CleanupOldAccessLogs deletes access logs older than the specified retention period
|
|
func (m *managerImpl) CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) {
|
|
if retentionDays <= 0 {
|
|
log.WithContext(ctx).Debug("access log cleanup skipped: retention days is 0 or negative")
|
|
return 0, nil
|
|
}
|
|
|
|
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
|
|
deletedCount, err := m.store.DeleteOldAccessLogs(ctx, cutoffTime)
|
|
if err != nil {
|
|
log.WithContext(ctx).Errorf("failed to cleanup old access logs: %v", err)
|
|
return 0, err
|
|
}
|
|
|
|
if deletedCount > 0 {
|
|
log.WithContext(ctx).Infof("cleaned up %d access logs older than %d days", deletedCount, retentionDays)
|
|
}
|
|
|
|
return deletedCount, nil
|
|
}
|
|
|
|
// StartPeriodicCleanup starts a background goroutine that periodically cleans up old access logs
|
|
func (m *managerImpl) StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) {
|
|
if retentionDays < 0 {
|
|
log.WithContext(ctx).Debug("periodic access log cleanup disabled: retention days is negative")
|
|
return
|
|
}
|
|
|
|
if retentionDays == 0 {
|
|
retentionDays = 7
|
|
log.WithContext(ctx).Debugf("no retention days specified for access log cleanup, defaulting to %d days", retentionDays)
|
|
} else {
|
|
log.WithContext(ctx).Debugf("access log retention period set to %d days", retentionDays)
|
|
}
|
|
|
|
if cleanupIntervalHours <= 0 {
|
|
cleanupIntervalHours = 24
|
|
log.WithContext(ctx).Debugf("no cleanup interval specified for access log cleanup, defaulting to %d hours", cleanupIntervalHours)
|
|
} else {
|
|
log.WithContext(ctx).Debugf("access log cleanup interval set to %d hours", cleanupIntervalHours)
|
|
}
|
|
|
|
cleanupCtx, cancel := context.WithCancel(ctx)
|
|
m.cleanupCancel = cancel
|
|
|
|
cleanupInterval := time.Duration(cleanupIntervalHours) * time.Hour
|
|
ticker := time.NewTicker(cleanupInterval)
|
|
|
|
go func() {
|
|
defer ticker.Stop()
|
|
|
|
// Run cleanup immediately on startup
|
|
log.WithContext(cleanupCtx).Infof("starting access log cleanup routine (retention: %d days, interval: %d hours)", retentionDays, cleanupIntervalHours)
|
|
if _, err := m.CleanupOldAccessLogs(cleanupCtx, retentionDays); err != nil {
|
|
log.WithContext(cleanupCtx).Errorf("initial access log cleanup failed: %v", err)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-cleanupCtx.Done():
|
|
log.WithContext(cleanupCtx).Info("stopping access log cleanup routine")
|
|
return
|
|
case <-ticker.C:
|
|
if _, err := m.CleanupOldAccessLogs(cleanupCtx, retentionDays); err != nil {
|
|
log.WithContext(cleanupCtx).Errorf("periodic access log cleanup failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// StopPeriodicCleanup stops the periodic cleanup routine
|
|
func (m *managerImpl) StopPeriodicCleanup() {
|
|
if m.cleanupCancel != nil {
|
|
m.cleanupCancel()
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return nil
|
|
}
|
|
|
|
users, err := m.store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var matchingUserIDs []string
|
|
for _, user := range users {
|
|
if filter.UserEmail != nil && strings.Contains(strings.ToLower(user.Email), strings.ToLower(*filter.UserEmail)) {
|
|
matchingUserIDs = append(matchingUserIDs, user.Id)
|
|
continue
|
|
}
|
|
if filter.UserName != nil && strings.Contains(strings.ToLower(user.Name), strings.ToLower(*filter.UserName)) {
|
|
matchingUserIDs = append(matchingUserIDs, user.Id)
|
|
}
|
|
}
|
|
|
|
if len(matchingUserIDs) > 0 {
|
|
filter.UserID = &matchingUserIDs[0]
|
|
}
|
|
|
|
return nil
|
|
}
|