mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-10 23:49:09 +02:00
Add CrowdSec AppSec request inspection to the reverse proxy
This commit is contained in:
@@ -23,6 +23,9 @@ type Domain struct {
|
||||
// SupportsCrowdSec is populated at query time from proxy cluster capabilities.
|
||||
// Not persisted.
|
||||
SupportsCrowdSec *bool `gorm:"-"`
|
||||
// SupportsAppSec is populated at query time from proxy cluster capabilities.
|
||||
// Not persisted.
|
||||
SupportsAppSec *bool `gorm:"-"`
|
||||
// SupportsPrivate is populated at query time from proxy cluster capabilities. Not persisted.
|
||||
SupportsPrivate *bool `gorm:"-"`
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
|
||||
SupportsCustomPorts: d.SupportsCustomPorts,
|
||||
RequireSubdomain: d.RequireSubdomain,
|
||||
SupportsCrowdsec: d.SupportsCrowdSec,
|
||||
SupportsAppsec: d.SupportsAppSec,
|
||||
SupportsPrivate: d.SupportsPrivate,
|
||||
}
|
||||
if d.TargetCluster != "" {
|
||||
|
||||
@@ -39,6 +39,7 @@ type proxyManager interface {
|
||||
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
}
|
||||
|
||||
@@ -98,6 +99,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
|
||||
d.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, cluster)
|
||||
d.RequireSubdomain = m.proxyManager.ClusterRequireSubdomain(ctx, cluster)
|
||||
d.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, cluster)
|
||||
d.SupportsAppSec = m.proxyManager.ClusterSupportsAppSec(ctx, cluster)
|
||||
d.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, cluster)
|
||||
ret = append(ret, d)
|
||||
}
|
||||
@@ -115,6 +117,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
|
||||
if d.TargetCluster != "" {
|
||||
cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster)
|
||||
cd.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, d.TargetCluster)
|
||||
cd.SupportsAppSec = m.proxyManager.ClusterSupportsAppSec(ctx, d.TargetCluster)
|
||||
cd.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, d.TargetCluster)
|
||||
}
|
||||
// Custom domains never require a subdomain by default since
|
||||
|
||||
@@ -46,6 +46,10 @@ func (m *mockProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockProxyManager) ClusterSupportsAppSec(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Manager interface {
|
||||
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
CleanupStale(ctx context.Context, inactivityDuration time.Duration) error
|
||||
GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error)
|
||||
|
||||
@@ -21,6 +21,7 @@ type store interface {
|
||||
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
|
||||
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
|
||||
@@ -138,6 +139,13 @@ func (m Manager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string
|
||||
return m.store.GetClusterSupportsCrowdSec(ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterSupportsAppSec returns whether all active proxies in the cluster have
|
||||
// a CrowdSec AppSec endpoint configured (unanimous). Returns nil when no proxy
|
||||
// has reported capabilities.
|
||||
func (m Manager) ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
|
||||
return m.store.GetClusterSupportsAppSec(ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterSupportsPrivate reports whether any active proxy claims the private capability (nil = unreported).
|
||||
func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
|
||||
return m.store.GetClusterSupportsPrivate(ctx, clusterAddr)
|
||||
|
||||
@@ -99,6 +99,9 @@ func (m *mockStore) GetClusterRequireSubdomain(_ context.Context, _ string) *boo
|
||||
func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
func (m *mockStore) GetClusterSupportsAppSec(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -50,20 +50,6 @@ func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration interfac
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration)
|
||||
}
|
||||
|
||||
// ClusterSupportsCustomPorts mocks base method.
|
||||
func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr)
|
||||
ret0, _ := ret[0].(*bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts.
|
||||
func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterRequireSubdomain mocks base method.
|
||||
func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -78,6 +64,20 @@ func (mr *MockManagerMockRecorder) ClusterRequireSubdomain(ctx, clusterAddr inte
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterRequireSubdomain", reflect.TypeOf((*MockManager)(nil).ClusterRequireSubdomain), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterSupportsAppSec mocks base method.
|
||||
func (m *MockManager) ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ClusterSupportsAppSec", ctx, clusterAddr)
|
||||
ret0, _ := ret[0].(*bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ClusterSupportsAppSec indicates an expected call of ClusterSupportsAppSec.
|
||||
func (mr *MockManagerMockRecorder) ClusterSupportsAppSec(ctx, clusterAddr interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsAppSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsAppSec), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterSupportsCrowdSec mocks base method.
|
||||
func (m *MockManager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -92,6 +92,20 @@ func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr inte
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCrowdSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCrowdSec), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterSupportsCustomPorts mocks base method.
|
||||
func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr)
|
||||
ret0, _ := ret[0].(*bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts.
|
||||
func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterSupportsPrivate mocks base method.
|
||||
func (m *MockManager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -121,6 +135,35 @@ func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddre
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities)
|
||||
}
|
||||
|
||||
// CountAccountProxies mocks base method.
|
||||
func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CountAccountProxies indicates an expected call of CountAccountProxies.
|
||||
func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID)
|
||||
}
|
||||
|
||||
// DeleteAccountCluster mocks base method.
|
||||
func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteAccountCluster indicates an expected call of DeleteAccountCluster.
|
||||
func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID)
|
||||
}
|
||||
|
||||
// Disconnect mocks base method.
|
||||
func (m *MockManager) Disconnect(ctx context.Context, proxyID, sessionID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -135,6 +178,21 @@ func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID, sessionID interface{
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID, sessionID)
|
||||
}
|
||||
|
||||
// GetAccountProxy mocks base method.
|
||||
func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID)
|
||||
ret0, _ := ret[0].(*Proxy)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAccountProxy indicates an expected call of GetAccountProxy.
|
||||
func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID)
|
||||
}
|
||||
|
||||
// GetActiveClusterAddresses mocks base method.
|
||||
func (m *MockManager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -150,6 +208,7 @@ func (mr *MockManagerMockRecorder) GetActiveClusterAddresses(ctx interface{}) *g
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddresses", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddresses), ctx)
|
||||
}
|
||||
|
||||
// GetActiveClusterAddressesForAccount mocks base method.
|
||||
func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetActiveClusterAddressesForAccount", ctx, accountID)
|
||||
@@ -158,6 +217,7 @@ func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, a
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetActiveClusterAddressesForAccount indicates an expected call of GetActiveClusterAddressesForAccount.
|
||||
func (mr *MockManagerMockRecorder) GetActiveClusterAddressesForAccount(ctx, accountID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddressesForAccount", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddressesForAccount), ctx, accountID)
|
||||
@@ -177,36 +237,6 @@ func (mr *MockManagerMockRecorder) Heartbeat(ctx, p interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, p)
|
||||
}
|
||||
|
||||
// GetAccountProxy mocks base method.
|
||||
func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID)
|
||||
ret0, _ := ret[0].(*Proxy)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAccountProxy indicates an expected call of GetAccountProxy.
|
||||
func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID)
|
||||
}
|
||||
|
||||
// CountAccountProxies mocks base method.
|
||||
func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CountAccountProxies indicates an expected call of CountAccountProxies.
|
||||
func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID)
|
||||
}
|
||||
|
||||
// IsClusterAddressAvailable mocks base method.
|
||||
func (m *MockManager) IsClusterAddressAvailable(ctx context.Context, clusterAddress, accountID string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -222,20 +252,6 @@ func (mr *MockManagerMockRecorder) IsClusterAddressAvailable(ctx, clusterAddress
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAddressAvailable", reflect.TypeOf((*MockManager)(nil).IsClusterAddressAvailable), ctx, clusterAddress, accountID)
|
||||
}
|
||||
|
||||
// DeleteAccountCluster mocks base method.
|
||||
func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteAccountCluster indicates an expected call of DeleteAccountCluster.
|
||||
func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID)
|
||||
}
|
||||
|
||||
// MockController is a mock of Controller interface.
|
||||
type MockController struct {
|
||||
ctrl *gomock.Controller
|
||||
|
||||
@@ -20,6 +20,9 @@ type Capabilities struct {
|
||||
RequireSubdomain *bool
|
||||
// SupportsCrowdsec indicates whether this proxy has CrowdSec configured.
|
||||
SupportsCrowdsec *bool
|
||||
// SupportsAppsec indicates whether this proxy has a CrowdSec AppSec (WAF)
|
||||
// endpoint configured.
|
||||
SupportsAppsec *bool
|
||||
// Private indicates whether this proxy supports inbound access via Wireguard
|
||||
// tunnel and netbird-only authentication policies
|
||||
Private *bool
|
||||
@@ -74,5 +77,6 @@ type Cluster struct {
|
||||
SupportsCustomPorts *bool
|
||||
RequireSubdomain *bool
|
||||
SupportsCrowdSec *bool
|
||||
SupportsAppSec *bool
|
||||
Private *bool
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ func (h *handler) getClusters(w http.ResponseWriter, r *http.Request) {
|
||||
SupportsCustomPorts: c.SupportsCustomPorts,
|
||||
RequireSubdomain: c.RequireSubdomain,
|
||||
SupportsCrowdsec: c.SupportsCrowdSec,
|
||||
SupportsAppsec: c.SupportsAppSec,
|
||||
Private: c.Private,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ type CapabilityProvider interface {
|
||||
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
}
|
||||
|
||||
@@ -137,6 +138,7 @@ func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]
|
||||
clusters[i].SupportsCustomPorts = m.capabilities.ClusterSupportsCustomPorts(ctx, clusters[i].Address)
|
||||
clusters[i].RequireSubdomain = m.capabilities.ClusterRequireSubdomain(ctx, clusters[i].Address)
|
||||
clusters[i].SupportsCrowdSec = m.capabilities.ClusterSupportsCrowdSec(ctx, clusters[i].Address)
|
||||
clusters[i].SupportsAppSec = m.capabilities.ClusterSupportsAppSec(ctx, clusters[i].Address)
|
||||
clusters[i].Private = m.capabilities.ClusterSupportsPrivate(ctx, clusters[i].Address)
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,18 @@ type AccessRestrictions struct {
|
||||
// matching every allowlist (AND), "any" requires matching at least one (OR).
|
||||
// Empty is treated as "all" for backward compatibility with existing records.
|
||||
AllowMatch string `json:"allow_match,omitempty" gorm:"serializer:json"`
|
||||
// AppSecMode is the CrowdSec AppSec (WAF) request inspection mode: "",
|
||||
// "off", "enforce", or "observe". HTTP services only.
|
||||
AppSecMode string `json:"appsec_mode,omitempty" gorm:"serializer:json"`
|
||||
}
|
||||
|
||||
// isEmpty reports whether no restriction is configured. Both conversions drop
|
||||
// the object entirely in that case, so a field missing from this check is
|
||||
// silently discarded on the way to the API and the proxy.
|
||||
func (r AccessRestrictions) isEmpty() bool {
|
||||
return len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
|
||||
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
|
||||
r.CrowdSecMode == "" && r.AllowMatch == "" && r.AppSecMode == ""
|
||||
}
|
||||
|
||||
// Copy returns a deep copy of the AccessRestrictions.
|
||||
@@ -180,6 +192,7 @@ func (r AccessRestrictions) Copy() AccessRestrictions {
|
||||
BlockedCountries: slices.Clone(r.BlockedCountries),
|
||||
CrowdSecMode: r.CrowdSecMode,
|
||||
AllowMatch: r.AllowMatch,
|
||||
AppSecMode: r.AppSecMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,13 +832,17 @@ func restrictionsFromAPI(r *api.AccessRestrictions) (AccessRestrictions, error)
|
||||
}
|
||||
res.AllowMatch = string(*r.AllowMatch)
|
||||
}
|
||||
if r.AppsecMode != nil {
|
||||
if !r.AppsecMode.Valid() {
|
||||
return AccessRestrictions{}, fmt.Errorf("invalid appsec_mode %q", *r.AppsecMode)
|
||||
}
|
||||
res.AppSecMode = string(*r.AppsecMode)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
|
||||
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
|
||||
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
|
||||
r.CrowdSecMode == "" && r.AllowMatch == "" {
|
||||
if r.isEmpty() {
|
||||
return nil
|
||||
}
|
||||
res := &api.AccessRestrictions{}
|
||||
@@ -849,13 +866,15 @@ func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
|
||||
match := api.AccessRestrictionsAllowMatch(r.AllowMatch)
|
||||
res.AllowMatch = &match
|
||||
}
|
||||
if r.AppSecMode != "" {
|
||||
mode := api.AccessRestrictionsAppsecMode(r.AppSecMode)
|
||||
res.AppsecMode = &mode
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
|
||||
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
|
||||
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
|
||||
r.CrowdSecMode == "" && r.AllowMatch == "" {
|
||||
if r.isEmpty() {
|
||||
return nil
|
||||
}
|
||||
return &proto.AccessRestrictions{
|
||||
@@ -865,6 +884,7 @@ func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
|
||||
BlockedCountries: r.BlockedCountries,
|
||||
CrowdsecMode: r.CrowdSecMode,
|
||||
AllowMatch: r.AllowMatch,
|
||||
AppsecMode: r.AppSecMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,6 +910,11 @@ func (s *Service) Validate() error {
|
||||
if err := validateAccessRestrictions(&s.Restrictions); err != nil {
|
||||
return err
|
||||
}
|
||||
// AppSec inspects HTTP requests, so it cannot apply to the L4 modes, which
|
||||
// forward opaque byte streams.
|
||||
if appSecEnabled(s.Restrictions.AppSecMode) && s.Mode != ModeHTTP {
|
||||
return fmt.Errorf("appsec_mode is only supported for HTTP services, got mode %q", s.Mode)
|
||||
}
|
||||
if err := s.validatePrivateRequirements(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1267,6 +1292,20 @@ func validateAllowMatch(mode string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func validateAppSecMode(mode string) error {
|
||||
switch mode {
|
||||
case "", "off", "enforce", "observe":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("appsec_mode %q is invalid", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// appSecEnabled reports whether the mode asks for request inspection.
|
||||
func appSecEnabled(mode string) bool {
|
||||
return mode == "enforce" || mode == "observe"
|
||||
}
|
||||
|
||||
func validateAccessRestrictions(r *AccessRestrictions) error {
|
||||
if err := validateCrowdSecMode(r.CrowdSecMode); err != nil {
|
||||
return err
|
||||
@@ -1274,6 +1313,9 @@ func validateAccessRestrictions(r *AccessRestrictions) error {
|
||||
if err := validateAllowMatch(r.AllowMatch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAppSecMode(r.AppSecMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(r.AllowedCIDRs) > maxCIDREntries {
|
||||
return fmt.Errorf("allowed_cidrs: exceeds maximum of %d entries", maxCIDREntries)
|
||||
|
||||
@@ -26,6 +26,17 @@ func validProxy() *Service {
|
||||
}
|
||||
}
|
||||
|
||||
// validL4Proxy returns a service that passes validation in one of the L4 modes.
|
||||
func validL4Proxy(mode string) *Service {
|
||||
rp := validProxy()
|
||||
rp.Mode = mode
|
||||
rp.ListenPort = 9000
|
||||
rp.Targets = []*Target{
|
||||
{TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 5432, Protocol: mode, Enabled: true},
|
||||
}
|
||||
return rp
|
||||
}
|
||||
|
||||
func TestValidate_Valid(t *testing.T) {
|
||||
require.NoError(t, validProxy().Validate())
|
||||
}
|
||||
@@ -1374,3 +1385,68 @@ func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
|
||||
}}
|
||||
assert.ErrorContains(t, rp.Validate(), "HTTP")
|
||||
}
|
||||
|
||||
func TestRestrictions_AppSecMode_RoundTrip(t *testing.T) {
|
||||
mode := api.AccessRestrictionsAppsecModeEnforce
|
||||
apiIn := &api.AccessRestrictions{AppsecMode: &mode}
|
||||
|
||||
model, err := restrictionsFromAPI(apiIn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "enforce", model.AppSecMode)
|
||||
|
||||
// appsec_mode alone must keep the restrictions object alive on both the API
|
||||
// and proto legs: it is meaningful without any CIDR or country entry.
|
||||
apiOut := restrictionsToAPI(model)
|
||||
require.NotNil(t, apiOut, "appsec_mode alone must not collapse the restrictions to nil")
|
||||
require.NotNil(t, apiOut.AppsecMode)
|
||||
assert.Equal(t, api.AccessRestrictionsAppsecModeEnforce, *apiOut.AppsecMode)
|
||||
|
||||
protoOut := restrictionsToProto(model)
|
||||
require.NotNil(t, protoOut, "appsec_mode alone must reach the proxy")
|
||||
assert.Equal(t, "enforce", protoOut.AppsecMode)
|
||||
}
|
||||
|
||||
func TestRestrictions_AppSecMode_EmptyIsOmitted(t *testing.T) {
|
||||
model, err := restrictionsFromAPI(&api.AccessRestrictions{
|
||||
AllowedCidrs: &[]string{"203.0.113.0/24"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, model.AppSecMode)
|
||||
|
||||
apiOut := restrictionsToAPI(model)
|
||||
require.NotNil(t, apiOut)
|
||||
assert.Nil(t, apiOut.AppsecMode, "empty appsec_mode is omitted from the API response")
|
||||
}
|
||||
|
||||
func TestRestrictions_AppSecMode_CopyIsDeep(t *testing.T) {
|
||||
original := AccessRestrictions{AppSecMode: "observe", CrowdSecMode: "enforce"}
|
||||
assert.Equal(t, original, original.Copy(), "Copy must carry every mode field")
|
||||
}
|
||||
|
||||
func TestValidate_RejectsInvalidAppSecMode(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Restrictions = AccessRestrictions{AppSecMode: "sometimes"}
|
||||
assert.ErrorContains(t, rp.Validate(), "appsec_mode")
|
||||
}
|
||||
|
||||
func TestValidate_RejectsAppSecOnL4Modes(t *testing.T) {
|
||||
// AppSec inspects HTTP requests, so the L4 modes cannot honor it. Accepting
|
||||
// the field there would report protection that never runs.
|
||||
for _, mode := range []string{ModeTCP, ModeUDP, ModeTLS} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
rp := validL4Proxy(mode)
|
||||
rp.Restrictions = AccessRestrictions{AppSecMode: "enforce"}
|
||||
assert.ErrorContains(t, rp.Validate(), "appsec_mode is only supported for HTTP services")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AllowsAppSecOffOnL4Modes(t *testing.T) {
|
||||
for _, mode := range []string{ModeTCP, ModeUDP, ModeTLS} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
rp := validL4Proxy(mode)
|
||||
rp.Restrictions = AccessRestrictions{AppSecMode: "off"}
|
||||
require.NoError(t, rp.Validate(), "an explicit off must not be rejected on L4 services")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +562,7 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params
|
||||
SupportsCustomPorts: c.SupportsCustomPorts,
|
||||
RequireSubdomain: c.RequireSubdomain,
|
||||
SupportsCrowdsec: c.SupportsCrowdsec,
|
||||
SupportsAppsec: c.SupportsAppsec,
|
||||
Private: c.Private,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6491,6 +6491,7 @@ var validCapabilityColumns = map[string]struct{}{
|
||||
"supports_custom_ports": {},
|
||||
"require_subdomain": {},
|
||||
"supports_crowdsec": {},
|
||||
"supports_appsec": {},
|
||||
"private": {},
|
||||
}
|
||||
|
||||
@@ -6521,6 +6522,14 @@ func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr s
|
||||
return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec")
|
||||
}
|
||||
|
||||
// GetClusterSupportsAppSec returns whether all active proxies in the cluster
|
||||
// have a CrowdSec AppSec endpoint configured. Returns nil when no proxy
|
||||
// reported the capability. Unanimous for the same reason as CrowdSec: a single
|
||||
// proxy without AppSec would let requests through uninspected.
|
||||
func (s *SqlStore) GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
|
||||
return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_appsec")
|
||||
}
|
||||
|
||||
// getClusterUnanimousCapability returns an aggregated boolean capability
|
||||
// requiring all active proxies in the cluster to report true.
|
||||
func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
)
|
||||
|
||||
// Capabilities travel proxy → gRPC → embedded gorm columns → aggregation → API.
|
||||
// A field dropped at any of those hops reads as "capability absent", which is
|
||||
// indistinguishable from a proxy that never reported it: the dashboard simply
|
||||
// hides the feature and nothing fails. These assertions cover the persistence
|
||||
// and aggregation hops.
|
||||
func TestSqlStore_ClusterCapabilityAggregation(t *testing.T) {
|
||||
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
|
||||
t.Skip("skip CI tests on darwin and windows")
|
||||
}
|
||||
|
||||
yes, no := true, false
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reported []*bool // one entry per connected proxy in the cluster
|
||||
wantAppSec *bool
|
||||
wantAssertion string
|
||||
}{
|
||||
{
|
||||
name: "unreported stays unknown",
|
||||
reported: []*bool{nil},
|
||||
wantAppSec: nil,
|
||||
wantAssertion: "an unreported capability must not read as false",
|
||||
},
|
||||
{
|
||||
name: "single proxy reporting true",
|
||||
reported: []*bool{&yes},
|
||||
wantAppSec: &yes,
|
||||
wantAssertion: "a reported capability must survive persistence",
|
||||
},
|
||||
{
|
||||
name: "one proxy without it disables the cluster",
|
||||
reported: []*bool{&yes, &no},
|
||||
wantAppSec: &no,
|
||||
wantAssertion: "capability must be unanimous, so a rolling upgrade cannot leave traffic uninspected",
|
||||
},
|
||||
{
|
||||
name: "one proxy yet to report disables the cluster",
|
||||
reported: []*bool{&yes, nil},
|
||||
wantAppSec: &no,
|
||||
wantAssertion: "a proxy that has not reported must not count as capable",
|
||||
},
|
||||
}
|
||||
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
ctx := context.Background()
|
||||
for i, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cluster := fmt.Sprintf("cluster-%d.proxy.example", i)
|
||||
for j, reported := range tt.reported {
|
||||
require.NoError(t, store.SaveProxy(ctx, &proxy.Proxy{
|
||||
ID: fmt.Sprintf("proxy-%d-%d", i, j),
|
||||
ClusterAddress: cluster,
|
||||
Status: proxy.StatusConnected,
|
||||
LastSeen: time.Now(),
|
||||
Capabilities: proxy.Capabilities{SupportsAppsec: reported},
|
||||
}))
|
||||
}
|
||||
|
||||
got := store.GetClusterSupportsAppSec(ctx, cluster)
|
||||
if tt.wantAppSec == nil {
|
||||
assert.Nil(t, got, tt.wantAssertion)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, got, tt.wantAssertion)
|
||||
assert.Equal(t, *tt.wantAppSec, *got, tt.wantAssertion)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// AppSec and IP reputation are separate endpoints, so a cluster can have either
|
||||
// without the other. Gating one on the other would silently disable a feature
|
||||
// the operator configured.
|
||||
func TestSqlStore_ClusterCapabilitiesAreIndependent(t *testing.T) {
|
||||
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
|
||||
t.Skip("skip CI tests on darwin and windows")
|
||||
}
|
||||
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
ctx := context.Background()
|
||||
const cluster = "independent.proxy.example"
|
||||
yes, no := true, false
|
||||
|
||||
require.NoError(t, store.SaveProxy(ctx, &proxy.Proxy{
|
||||
ID: "proxy-independent",
|
||||
ClusterAddress: cluster,
|
||||
Status: proxy.StatusConnected,
|
||||
LastSeen: time.Now(),
|
||||
Capabilities: proxy.Capabilities{
|
||||
SupportsAppsec: &yes,
|
||||
SupportsCrowdsec: &no,
|
||||
},
|
||||
}))
|
||||
|
||||
appsec := store.GetClusterSupportsAppSec(ctx, cluster)
|
||||
crowdsec := store.GetClusterSupportsCrowdSec(ctx, cluster)
|
||||
require.NotNil(t, appsec)
|
||||
require.NotNil(t, crowdsec)
|
||||
assert.True(t, *appsec, "AppSec must not be gated on CrowdSec")
|
||||
assert.False(t, *crowdsec, "CrowdSec must not be implied by AppSec")
|
||||
})
|
||||
}
|
||||
@@ -155,6 +155,8 @@ func TestSqlStore_GetAccount_ServiceRestrictionsRoundtrip(t *testing.T) {
|
||||
AllowedCIDRs: []string{"203.0.113.0/24"},
|
||||
AllowedCountries: []string{"US"},
|
||||
AllowMatch: "any",
|
||||
CrowdSecMode: "observe",
|
||||
AppSecMode: "enforce",
|
||||
},
|
||||
}
|
||||
require.NoError(t, store.CreateService(ctx, svc))
|
||||
@@ -164,11 +166,13 @@ func TestSqlStore_GetAccount_ServiceRestrictionsRoundtrip(t *testing.T) {
|
||||
require.Len(t, loaded.Services, 1)
|
||||
|
||||
// Restrictions are stored as a JSON blob; confirm the whole struct,
|
||||
// including allow_match, survives the read path (Postgres pgx path
|
||||
// included via runTestForAllEngines).
|
||||
// including allow_match and the CrowdSec modes, survives the read path
|
||||
// (Postgres pgx path included via runTestForAllEngines).
|
||||
got := loaded.Services[0].Restrictions
|
||||
assert.Equal(t, []string{"203.0.113.0/24"}, got.AllowedCIDRs)
|
||||
assert.Equal(t, []string{"US"}, got.AllowedCountries)
|
||||
assert.Equal(t, "any", got.AllowMatch)
|
||||
assert.Equal(t, "observe", got.CrowdSecMode)
|
||||
assert.Equal(t, "enforce", got.AppSecMode)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -329,6 +329,7 @@ type Store interface {
|
||||
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
|
||||
GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error)
|
||||
|
||||
@@ -1878,6 +1878,20 @@ func (mr *MockStoreMockRecorder) GetClusterRequireSubdomain(ctx, clusterAddr int
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterRequireSubdomain", reflect.TypeOf((*MockStore)(nil).GetClusterRequireSubdomain), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// GetClusterSupportsAppSec mocks base method.
|
||||
func (m *MockStore) GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetClusterSupportsAppSec", ctx, clusterAddr)
|
||||
ret0, _ := ret[0].(*bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetClusterSupportsAppSec indicates an expected call of GetClusterSupportsAppSec.
|
||||
func (mr *MockStoreMockRecorder) GetClusterSupportsAppSec(ctx, clusterAddr interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsAppSec", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsAppSec), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// GetClusterSupportsCrowdSec mocks base method.
|
||||
func (m *MockStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Reference in New Issue
Block a user