mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-26 10:21:28 +02:00
Compare commits
8 Commits
v0.75.0
...
reverse-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2651504d2 | ||
|
|
172914e5ee | ||
|
|
2243d1c3bb | ||
|
|
40cdfda878 | ||
|
|
1e5b0a5c89 | ||
|
|
b65ec8b68a | ||
|
|
e13bcdbd44 | ||
|
|
46568f7af8 |
@@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_ConnectPeers(t *testing.T) {
|
||||
t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
|
||||
|
||||
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
|
||||
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
|
||||
peer1Key, _ := wgtypes.GeneratePrivateKey()
|
||||
@@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
localIP, err := getLocalIP()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
|
||||
localIP1 := "127.0.0.1"
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
|
||||
localIP2 := "127.0.0.1"
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -621,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
|
||||
}
|
||||
return wgtypes.Peer{}, fmt.Errorf("peer not found")
|
||||
}
|
||||
|
||||
func getLocalIP() (string, error) {
|
||||
// Get all interfaces
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
|
||||
if ipNet.IP.To4() == nil {
|
||||
continue
|
||||
}
|
||||
return ipNet.IP.String(), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no local IP found")
|
||||
}
|
||||
|
||||
@@ -49,11 +49,21 @@ type ConnMgr struct {
|
||||
// engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
|
||||
lazyConnMgrMu sync.RWMutex
|
||||
|
||||
// reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is
|
||||
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
|
||||
reconcileRoutedIPs func(peerKey string) error
|
||||
|
||||
wg sync.WaitGroup
|
||||
lazyCtx context.Context
|
||||
lazyCtxCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when
|
||||
// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts.
|
||||
func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
|
||||
e.reconcileRoutedIPs = fn
|
||||
}
|
||||
|
||||
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
|
||||
e := &ConnMgr{
|
||||
peerStore: peerStore,
|
||||
@@ -291,6 +301,7 @@ func (e *ConnMgr) Close() {
|
||||
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
||||
cfg := manager.Config{
|
||||
InactivityThreshold: inactivityThresholdEnv(),
|
||||
ReconcileAllowedIPs: e.reconcileRoutedIPs,
|
||||
}
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
|
||||
@@ -663,6 +663,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
iceCfg := e.createICEConfig()
|
||||
|
||||
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
|
||||
e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error {
|
||||
if e.routeManager == nil {
|
||||
return nil
|
||||
}
|
||||
return e.routeManager.ReconcilePeerAllowedIPs(peerKey)
|
||||
})
|
||||
e.connMgr.Start(e.ctx)
|
||||
|
||||
// Wire DNS-time lazy-connection warm-up now that the connection manager
|
||||
|
||||
@@ -29,6 +29,11 @@ type managedPeer struct {
|
||||
|
||||
type Config struct {
|
||||
InactivityThreshold *time.Duration
|
||||
// ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is
|
||||
// armed. The activity listener creates the wake peer with the overlay /32 only; without the
|
||||
// routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an
|
||||
// idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile.
|
||||
ReconcileAllowedIPs func(peerKey string) error
|
||||
}
|
||||
|
||||
// Manager manages lazy connections
|
||||
@@ -56,6 +61,9 @@ type Manager struct {
|
||||
peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to
|
||||
haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group
|
||||
routesMu sync.RWMutex
|
||||
|
||||
// reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed.
|
||||
reconcileAllowedIPs func(peerKey string) error
|
||||
}
|
||||
|
||||
// NewManager creates a new lazy connection manager
|
||||
@@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S
|
||||
activityManager: activity.NewManager(wgIface),
|
||||
peerToHAGroups: make(map[string][]route.HAUniqueID),
|
||||
haGroupToPeers: make(map[route.HAUniqueID][]string),
|
||||
reconcileAllowedIPs: config.ReconcileAllowedIPs,
|
||||
}
|
||||
|
||||
if wgIface.IsUserspaceBind() {
|
||||
@@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
|
||||
if err := m.armActivityListener(peerCfg); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) {
|
||||
|
||||
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
|
||||
|
||||
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
|
||||
if err := m.armActivityListener(*mp.peerCfg); err != nil {
|
||||
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -465,6 +474,31 @@ func (m *Manager) close() {
|
||||
}
|
||||
|
||||
// shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements
|
||||
// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake
|
||||
// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without
|
||||
// this the routed prefixes would be missing and traffic to a routed subnet could not wake the
|
||||
// idle routing peer. It is a no-op when no reconciler is configured.
|
||||
// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then
|
||||
// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing
|
||||
// peer. The routed prefixes must be re-applied after the wake endpoint exists because the
|
||||
// listener creates it with the overlay /32 only.
|
||||
func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error {
|
||||
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
m.armRoutedAllowedIPs(&peerCfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) {
|
||||
if m.reconcileAllowedIPs == nil {
|
||||
return
|
||||
}
|
||||
if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil {
|
||||
peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool {
|
||||
m.routesMu.RLock()
|
||||
defer m.routesMu.RUnlock()
|
||||
@@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
|
||||
|
||||
mp.peerCfg.Log.Infof("start activity monitor")
|
||||
|
||||
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
|
||||
if err := m.armActivityListener(*mp.peerCfg); err != nil {
|
||||
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ type Manager interface {
|
||||
InitialRouteRange() []string
|
||||
SetFirewall(firewall.Manager) error
|
||||
SetDNSForwarderPort(port uint16)
|
||||
ReconcilePeerAllowedIPs(peerKey string) error
|
||||
Stop(stateManager *statemanager.Manager)
|
||||
}
|
||||
|
||||
@@ -232,6 +233,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||
)
|
||||
}
|
||||
|
||||
// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer
|
||||
// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to
|
||||
// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a
|
||||
// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates
|
||||
// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter
|
||||
// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is
|
||||
// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so
|
||||
// prefixes are re-added to an existing peer and an absent peer is left untouched.
|
||||
func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error {
|
||||
if m.allowedIPsRefCounter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.allowedIPsRefCounter.ReapplyMatching(
|
||||
func(out string) bool { return out == peerKey },
|
||||
func(prefix netip.Prefix) error {
|
||||
if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil {
|
||||
return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Init sets up the routing
|
||||
func (m *DefaultManager) Init() error {
|
||||
m.routeSelector = m.initSelector()
|
||||
|
||||
@@ -112,6 +112,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error {
|
||||
func (m *MockManager) SetDNSForwarderPort(port uint16) {
|
||||
}
|
||||
|
||||
// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface
|
||||
func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop mock implementation of Stop from Manager interface
|
||||
func (m *MockManager) Stop(stateManager *statemanager.Manager) {
|
||||
if m.StopFunc != nil {
|
||||
|
||||
90
client/internal/routemanager/reconcile_test.go
Normal file
90
client/internal/routemanager/reconcile_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
//go:build !windows
|
||||
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
|
||||
)
|
||||
|
||||
// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
|
||||
// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
|
||||
type reconcileWGMock struct {
|
||||
mu sync.Mutex
|
||||
adds map[string][]netip.Prefix
|
||||
}
|
||||
|
||||
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.adds == nil {
|
||||
m.adds = map[string][]netip.Prefix{}
|
||||
}
|
||||
m.adds[peerKey] = append(m.adds[peerKey], allowedIP)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.adds[peerKey]
|
||||
}
|
||||
|
||||
func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil }
|
||||
func (m *reconcileWGMock) Name() string { return "utun-test" }
|
||||
func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
|
||||
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
|
||||
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
|
||||
func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil }
|
||||
func (m *reconcileWGMock) GetNet() *netstack.Net { return nil }
|
||||
|
||||
// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix
|
||||
// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer.
|
||||
func TestReconcilePeerAllowedIPs(t *testing.T) {
|
||||
wg := &reconcileWGMock{}
|
||||
m := &DefaultManager{wgInterface: wg}
|
||||
m.allowedIPsRefCounter = refcounter.New[netip.Prefix, string, string](
|
||||
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
|
||||
func(netip.Prefix, string) error { return nil },
|
||||
)
|
||||
|
||||
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
|
||||
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
|
||||
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
|
||||
|
||||
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
|
||||
_, err := m.allowedIPsRefCounter.Increment(prefix, peer)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// Extra reference: reconcile must still re-apply the prefix even though its refcount never
|
||||
// hit 0 again (the exact case the plain incremental path skips).
|
||||
_, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
|
||||
|
||||
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"),
|
||||
"reconcile must re-apply all routed prefixes of the peer")
|
||||
assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes")
|
||||
}
|
||||
|
||||
// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is
|
||||
// set up.
|
||||
func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) {
|
||||
wg := &reconcileWGMock{}
|
||||
m := &DefaultManager{wgInterface: wg}
|
||||
|
||||
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
|
||||
assert.Empty(t, wg.added("peerA"))
|
||||
}
|
||||
@@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) {
|
||||
return ref, ok
|
||||
}
|
||||
|
||||
// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the
|
||||
// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect
|
||||
// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its
|
||||
// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied.
|
||||
// pred and apply are invoked under the lock, so they must not call back into the counter.
|
||||
func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
var merr *multierror.Error
|
||||
for key, ref := range rm.refCountMap {
|
||||
if pred(ref.Out) {
|
||||
if err := apply(key); err != nil {
|
||||
merr = multierror.Append(merr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
// Increment increments the reference count for the given key.
|
||||
// If this is the first reference to the key, the AddFunc is called.
|
||||
func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) {
|
||||
|
||||
47
client/internal/routemanager/refcounter/refcounter_test.go
Normal file
47
client/internal/routemanager/refcounter/refcounter_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package refcounter
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored
|
||||
// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive
|
||||
// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes.
|
||||
func TestReapplyMatching(t *testing.T) {
|
||||
rc := New[netip.Prefix, string, string](
|
||||
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
|
||||
func(netip.Prefix, string) error { return nil },
|
||||
)
|
||||
|
||||
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
|
||||
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
|
||||
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
|
||||
|
||||
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
|
||||
_, err := rc.Increment(prefix, peer)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// a second reference must not make the key applied twice
|
||||
_, err := rc.Increment(peerA1, "peerA")
|
||||
require.NoError(t, err)
|
||||
|
||||
var applied []netip.Prefix
|
||||
err = rc.ReapplyMatching(
|
||||
func(out string) bool { return out == "peerA" },
|
||||
func(key netip.Prefix) error { applied = append(applied, key); return nil },
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied)
|
||||
|
||||
var none []netip.Prefix
|
||||
err = rc.ReapplyMatching(
|
||||
func(out string) bool { return out == "missing" },
|
||||
func(key netip.Prefix) error { none = append(none, key); return nil },
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, none)
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -35,6 +35,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
|
||||
}
|
||||
|
||||
@@ -94,6 +95,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)
|
||||
}
|
||||
@@ -111,6 +113,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
|
||||
|
||||
@@ -40,6 +40,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,13 @@ type AccessRestrictions struct {
|
||||
AllowedCountries []string `json:"allowed_countries,omitempty" gorm:"serializer:json"`
|
||||
BlockedCountries []string `json:"blocked_countries,omitempty" gorm:"serializer:json"`
|
||||
CrowdSecMode string `json:"crowdsec_mode,omitempty" gorm:"serializer:json"`
|
||||
// AllowMatch controls how the allowlists combine: "" or "all" require
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Copy returns a deep copy of the AccessRestrictions.
|
||||
@@ -175,6 +182,8 @@ func (r AccessRestrictions) Copy() AccessRestrictions {
|
||||
AllowedCountries: slices.Clone(r.AllowedCountries),
|
||||
BlockedCountries: slices.Clone(r.BlockedCountries),
|
||||
CrowdSecMode: r.CrowdSecMode,
|
||||
AllowMatch: r.AllowMatch,
|
||||
AppSecMode: r.AppSecMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,13 +817,25 @@ func restrictionsFromAPI(r *api.AccessRestrictions) (AccessRestrictions, error)
|
||||
}
|
||||
res.CrowdSecMode = string(*r.CrowdsecMode)
|
||||
}
|
||||
if r.AllowMatch != nil {
|
||||
if !r.AllowMatch.Valid() {
|
||||
return AccessRestrictions{}, fmt.Errorf("invalid allow_match %q", *r.AllowMatch)
|
||||
}
|
||||
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.CrowdSecMode == "" && r.AllowMatch == "" && r.AppSecMode == "" {
|
||||
return nil
|
||||
}
|
||||
res := &api.AccessRestrictions{}
|
||||
@@ -834,13 +855,21 @@ func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
|
||||
mode := api.AccessRestrictionsCrowdsecMode(r.CrowdSecMode)
|
||||
res.CrowdsecMode = &mode
|
||||
}
|
||||
if r.AllowMatch != "" {
|
||||
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.CrowdSecMode == "" && r.AllowMatch == "" && r.AppSecMode == "" {
|
||||
return nil
|
||||
}
|
||||
return &proto.AccessRestrictions{
|
||||
@@ -849,6 +878,8 @@ func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
|
||||
AllowedCountries: r.AllowedCountries,
|
||||
BlockedCountries: r.BlockedCountries,
|
||||
CrowdsecMode: r.CrowdSecMode,
|
||||
AllowMatch: r.AllowMatch,
|
||||
AppsecMode: r.AppSecMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,6 +905,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
|
||||
}
|
||||
@@ -1242,10 +1278,39 @@ func validateCrowdSecMode(mode string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func validateAllowMatch(mode string) error {
|
||||
switch mode {
|
||||
case "", "all", "any":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("allow_match %q is invalid", mode)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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())
|
||||
}
|
||||
@@ -1302,6 +1313,130 @@ func TestValidate_Private_AcceptsClusterTargetWithAccessGroups(t *testing.T) {
|
||||
require.NoError(t, rp.Validate())
|
||||
}
|
||||
|
||||
func TestRestrictions_AllowMatch_RoundTrip(t *testing.T) {
|
||||
anyMatch := api.AccessRestrictionsAllowMatchAny
|
||||
apiIn := &api.AccessRestrictions{
|
||||
AllowedCidrs: &[]string{"203.0.113.0/24"},
|
||||
AllowedCountries: &[]string{"US"},
|
||||
AllowMatch: &anyMatch,
|
||||
}
|
||||
|
||||
model, err := restrictionsFromAPI(apiIn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "any", model.AllowMatch)
|
||||
|
||||
apiOut := restrictionsToAPI(model)
|
||||
require.NotNil(t, apiOut.AllowMatch)
|
||||
assert.Equal(t, api.AccessRestrictionsAllowMatchAny, *apiOut.AllowMatch)
|
||||
|
||||
protoOut := restrictionsToProto(model)
|
||||
require.NotNil(t, protoOut)
|
||||
assert.Equal(t, "any", protoOut.AllowMatch)
|
||||
}
|
||||
|
||||
func TestRestrictions_AllowMatch_EmptyDefaultsToAll(t *testing.T) {
|
||||
// A stored record with no allow_match (existing services) stays empty and
|
||||
// must not surface a value on the API, preserving AND behavior downstream.
|
||||
model, err := restrictionsFromAPI(&api.AccessRestrictions{
|
||||
AllowedCidrs: &[]string{"203.0.113.0/24"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, model.AllowMatch, "unset allow_match stays empty")
|
||||
|
||||
apiOut := restrictionsToAPI(model)
|
||||
require.NotNil(t, apiOut)
|
||||
assert.Nil(t, apiOut.AllowMatch, "empty allow_match is omitted from the API response")
|
||||
}
|
||||
|
||||
func TestRestrictions_AllowMatchOnly_Preserved(t *testing.T) {
|
||||
// allow_match set without any list must not be dropped by the emptiness
|
||||
// guards, so it round-trips through both the API and proto conversions.
|
||||
model := AccessRestrictions{AllowMatch: "any"}
|
||||
|
||||
apiOut := restrictionsToAPI(model)
|
||||
require.NotNil(t, apiOut, "allow-match-only restriction must not be omitted from the API response")
|
||||
require.NotNil(t, apiOut.AllowMatch)
|
||||
assert.Equal(t, api.AccessRestrictionsAllowMatchAny, *apiOut.AllowMatch)
|
||||
|
||||
protoOut := restrictionsToProto(model)
|
||||
require.NotNil(t, protoOut, "allow-match-only restriction must not be omitted from the proto output")
|
||||
assert.Equal(t, "any", protoOut.AllowMatch)
|
||||
}
|
||||
|
||||
func TestValidate_RejectsInvalidAllowMatch(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Restrictions = AccessRestrictions{
|
||||
AllowedCIDRs: []string{"203.0.113.0/24"},
|
||||
AllowMatch: "sometimes",
|
||||
}
|
||||
assert.ErrorContains(t, rp.Validate(), "allow_match")
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Private = true
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
@@ -36,7 +37,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/management/server/users"
|
||||
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
||||
@@ -505,6 +505,7 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params
|
||||
SupportsCustomPorts: c.SupportsCustomPorts,
|
||||
RequireSubdomain: c.RequireSubdomain,
|
||||
SupportsCrowdsec: c.SupportsCrowdsec,
|
||||
SupportsAppsec: c.SupportsAppsec,
|
||||
Private: c.Private,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2259,7 +2259,7 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p
|
||||
}
|
||||
|
||||
func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) {
|
||||
const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth,
|
||||
const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth, restrictions,
|
||||
meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster,
|
||||
pass_host_header, rewrite_redirects, session_private_key, session_public_key,
|
||||
mode, listen_port, port_auto_assigned, source, source_peer, terminated,
|
||||
@@ -2278,6 +2278,7 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
|
||||
services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) {
|
||||
var s rpservice.Service
|
||||
var auth []byte
|
||||
var restrictions []byte
|
||||
var accessGroups []byte
|
||||
var createdAt, certIssuedAt sql.NullTime
|
||||
var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString
|
||||
@@ -2291,6 +2292,7 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
|
||||
&s.Domain,
|
||||
&s.Enabled,
|
||||
&auth,
|
||||
&restrictions,
|
||||
&createdAt,
|
||||
&certIssuedAt,
|
||||
&status,
|
||||
@@ -2318,6 +2320,12 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
|
||||
}
|
||||
}
|
||||
|
||||
if len(restrictions) > 0 {
|
||||
if err := json.Unmarshal(restrictions, &s.Restrictions); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal restrictions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(accessGroups) > 0 {
|
||||
if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal access_groups: %w", err)
|
||||
@@ -6357,6 +6365,7 @@ var validCapabilityColumns = map[string]struct{}{
|
||||
"supports_custom_ports": {},
|
||||
"require_subdomain": {},
|
||||
"supports_crowdsec": {},
|
||||
"supports_appsec": {},
|
||||
"private": {},
|
||||
}
|
||||
|
||||
@@ -6387,6 +6396,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 {
|
||||
|
||||
@@ -44,3 +44,46 @@ func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) {
|
||||
assert.Equal(t, []string{"grp-admins", "grp-ops"}, got.AccessGroups)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_GetAccount_ServiceRestrictionsRoundtrip(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()
|
||||
account := newAccountWithId(ctx, "account_svc_restrictions", "testuser", "")
|
||||
require.NoError(t, store.SaveAccount(ctx, account))
|
||||
|
||||
svc := &rpservice.Service{
|
||||
ID: "svc-restrictions",
|
||||
AccountID: account.Id,
|
||||
Name: "restricted-svc",
|
||||
Domain: "restricted.example",
|
||||
Enabled: true,
|
||||
Mode: rpservice.ModeHTTP,
|
||||
Restrictions: rpservice.AccessRestrictions{
|
||||
AllowedCIDRs: []string{"203.0.113.0/24"},
|
||||
AllowedCountries: []string{"US"},
|
||||
AllowMatch: "any",
|
||||
CrowdSecMode: "observe",
|
||||
AppSecMode: "enforce",
|
||||
},
|
||||
}
|
||||
require.NoError(t, store.CreateService(ctx, svc))
|
||||
|
||||
loaded, err := store.GetAccount(ctx, account.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, loaded.Services, 1)
|
||||
|
||||
// Restrictions are stored as a JSON blob; confirm the whole struct,
|
||||
// including allow_match and the crowdsec/appsec 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -321,6 +321,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)
|
||||
|
||||
@@ -1835,6 +1835,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()
|
||||
|
||||
@@ -79,6 +79,9 @@ var (
|
||||
geoDataDir string
|
||||
crowdsecAPIURL string
|
||||
crowdsecAPIKey string
|
||||
appsecURL string
|
||||
appsecTimeout time.Duration
|
||||
appsecMaxBodyBytes int64
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -125,6 +128,9 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)")
|
||||
rootCmd.Flags().StringVar(&crowdsecAPIURL, "crowdsec-api-url", envStringOrDefault("NB_PROXY_CROWDSEC_API_URL", ""), "CrowdSec LAPI URL for IP reputation checks")
|
||||
rootCmd.Flags().StringVar(&crowdsecAPIKey, "crowdsec-api-key", envStringOrDefault("NB_PROXY_CROWDSEC_API_KEY", ""), "CrowdSec bouncer API key")
|
||||
rootCmd.Flags().StringVar(&appsecURL, "crowdsec-appsec-url", envStringOrDefault("NB_PROXY_CROWDSEC_APPSEC_URL", ""), "CrowdSec AppSec (WAF) endpoint for HTTP request inspection, e.g. http://127.0.0.1:7422/ (reuses the bouncer API key)")
|
||||
rootCmd.Flags().DurationVar(&appsecTimeout, "crowdsec-appsec-timeout", envDurationOrDefault("NB_PROXY_CROWDSEC_APPSEC_TIMEOUT", 0), "Timeout for a single AppSec inspection call (0 = 200ms)")
|
||||
rootCmd.Flags().Int64Var(&appsecMaxBodyBytes, "crowdsec-appsec-max-body-bytes", envInt64OrDefault("NB_PROXY_CROWDSEC_APPSEC_MAX_BODY_BYTES", 0), "Cap on the request body mirrored to AppSec (0 = 64KiB, negative = headers and URI only)")
|
||||
}
|
||||
|
||||
// Execute runs the root command.
|
||||
@@ -254,6 +260,10 @@ func runServer(cmd *cobra.Command, args []string) error {
|
||||
GeoDataDir: geoDataDir,
|
||||
CrowdSecAPIURL: crowdsecAPIURL,
|
||||
CrowdSecAPIKey: crowdsecAPIKey,
|
||||
CrowdSecAppSecURL: appsecURL,
|
||||
CrowdSecAppSecTimeout: appsecTimeout,
|
||||
|
||||
CrowdSecAppSecMaxBodyBytes: appsecMaxBodyBytes,
|
||||
})
|
||||
|
||||
return srv.ListenAndServe(ctx, addr)
|
||||
@@ -293,6 +303,19 @@ func envUint16OrDefault(key string, def uint16) uint16 {
|
||||
return uint16(parsed)
|
||||
}
|
||||
|
||||
func envInt64OrDefault(key string, def int64) int64 {
|
||||
v, exists := os.LookupEnv(key)
|
||||
if !exists {
|
||||
return def
|
||||
}
|
||||
parsed, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
log.Warnf("parse %s=%q: %v, using default %d", key, v, err, def)
|
||||
return def
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envDurationOrDefault(key string, def time.Duration) time.Duration {
|
||||
v, exists := os.LookupEnv(key)
|
||||
if !exists {
|
||||
|
||||
83
proxy/internal/appsec/body.go
Normal file
83
proxy/internal/appsec/body.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package appsec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// bufferBody reads up to limit+1 bytes from r.Body and always restores r.Body so
|
||||
// the request stays forwardable. oversize reports that the body exceeded limit, in
|
||||
// which case the returned prefix must not be used for inspection: the bytes are
|
||||
// only read so they can be replayed to the backend.
|
||||
func bufferBody(r *http.Request, limit int64) (body []byte, oversize bool, err error) {
|
||||
original := r.Body
|
||||
buf, readErr := io.ReadAll(io.LimitReader(original, limit+1))
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
// Restore what was read so a downstream retry sees a consistent stream,
|
||||
// then surface the failure.
|
||||
r.Body = replay(buf, original)
|
||||
return nil, false, readErr
|
||||
}
|
||||
|
||||
if int64(len(buf)) > limit {
|
||||
r.Body = replay(buf, original)
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
// The whole body is buffered, so the original is drained and can be closed.
|
||||
// A close error on a drained read-only body does not invalidate the bytes.
|
||||
_ = original.Close()
|
||||
r.Body = io.NopCloser(bytes.NewReader(buf))
|
||||
if r.ContentLength <= 0 {
|
||||
// A chunked request now has a known length. Keep ContentLength and the
|
||||
// header in agreement so the upstream request is framed consistently.
|
||||
r.ContentLength = int64(len(buf))
|
||||
r.Header.Set("Content-Length", strconv.Itoa(len(buf)))
|
||||
r.Header.Del("Transfer-Encoding")
|
||||
r.TransferEncoding = nil
|
||||
}
|
||||
return buf, false, nil
|
||||
}
|
||||
|
||||
// replay returns a ReadCloser that yields the already-read prefix followed by
|
||||
// the remainder of the original stream, and closes the original.
|
||||
func replay(prefix []byte, rest io.ReadCloser) io.ReadCloser {
|
||||
return struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
Reader: io.MultiReader(bytes.NewReader(prefix), rest),
|
||||
Closer: rest,
|
||||
}
|
||||
}
|
||||
|
||||
// formHasField reports whether body is a URL-encoded form carrying any of the
|
||||
// named fields. Used to keep credential submissions out of the mirrored
|
||||
// request: the proxy's own password / PIN login form posts to the service path,
|
||||
// so without this the plaintext credential would reach the Security Engine.
|
||||
func formHasField(contentType string, body []byte, fields []string) bool {
|
||||
if len(fields) == 0 || len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
media, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || media != "application/x-www-form-urlencoded" {
|
||||
return false
|
||||
}
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
// An unparsable form cannot be cleared as credential-free.
|
||||
return true
|
||||
}
|
||||
for field := range values {
|
||||
if slices.Contains(fields, field) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
339
proxy/internal/appsec/client.go
Normal file
339
proxy/internal/appsec/client.go
Normal file
@@ -0,0 +1,339 @@
|
||||
// Package appsec implements the CrowdSec AppSec (WAF) side of the remediation
|
||||
// component protocol: each inspected HTTP request is mirrored to the Security
|
||||
// Engine's AppSec endpoint, which replies with an allow / ban / captcha verdict
|
||||
// for that request.
|
||||
//
|
||||
// This is a separate endpoint from the LAPI decision stream used by the
|
||||
// crowdsec package: LAPI answers "is this IP known bad", AppSec answers "is
|
||||
// this request an attack". The two are configured and enabled independently.
|
||||
package appsec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
||||
)
|
||||
|
||||
// Header names the AppSec component reads off the mirrored request. IP, URI and
|
||||
// Verb are mandatory: the engine answers 500 when any of them is missing.
|
||||
const (
|
||||
headerAPIKey = "X-Crowdsec-Appsec-Api-Key" //nolint:gosec // G101: a header name, not a credential
|
||||
headerIP = "X-Crowdsec-Appsec-Ip"
|
||||
headerURI = "X-Crowdsec-Appsec-Uri"
|
||||
headerVerb = "X-Crowdsec-Appsec-Verb"
|
||||
headerHost = "X-Crowdsec-Appsec-Host"
|
||||
headerUserAgent = "X-Crowdsec-Appsec-User-Agent"
|
||||
headerHTTPVersion = "X-Crowdsec-Appsec-Http-Version"
|
||||
headerTransactionID = "X-Crowdsec-Appsec-Transaction-Id"
|
||||
)
|
||||
|
||||
// headerPrefix covers every protocol header. Any client-supplied header in this
|
||||
// namespace is dropped before forwarding so a caller cannot influence the
|
||||
// engine's view of its own address, or replay an API key.
|
||||
const headerPrefix = "X-Crowdsec-Appsec-"
|
||||
|
||||
// Remediation actions the engine can return.
|
||||
const (
|
||||
actionAllow = "allow"
|
||||
actionBan = "ban"
|
||||
actionCaptcha = "captcha"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTimeout matches the 200ms budget CrowdSec's remediation component
|
||||
// spec sets for the blocking AppSec call.
|
||||
DefaultTimeout = 200 * time.Millisecond
|
||||
// DefaultMaxBodyBytes caps the request body mirrored to the engine.
|
||||
// Requests with a larger body are inspected on headers and URI only.
|
||||
DefaultMaxBodyBytes int64 = 64 << 10
|
||||
)
|
||||
|
||||
// ErrUnavailable reports that the engine could not produce a verdict: the call
|
||||
// failed, timed out, or the engine rejected it (401 bad key, 500 malformed).
|
||||
// Distinguished from a block verdict so the caller can apply the per-service
|
||||
// mode: enforce fails closed, observe allows.
|
||||
var ErrUnavailable = errors.New("appsec engine unavailable")
|
||||
|
||||
// Config configures a Client.
|
||||
type Config struct {
|
||||
// URL is the AppSec endpoint, e.g. http://127.0.0.1:7422/.
|
||||
URL string
|
||||
// APIKey is the CrowdSec bouncer API key. The AppSec component validates it
|
||||
// against LAPI, so the same key used for the decision stream works here.
|
||||
APIKey string
|
||||
// Timeout bounds a single inspection call. Zero means DefaultTimeout.
|
||||
Timeout time.Duration
|
||||
// MaxBodyBytes caps the mirrored request body. Zero means
|
||||
// DefaultMaxBodyBytes; negative disables body forwarding entirely.
|
||||
MaxBodyBytes int64
|
||||
Logger *log.Entry
|
||||
}
|
||||
|
||||
// Client mirrors HTTP requests to a CrowdSec AppSec endpoint. It holds no
|
||||
// per-service state and is safe for concurrent use.
|
||||
type Client struct {
|
||||
url string
|
||||
apiKey string
|
||||
maxBodyBytes int64
|
||||
http *http.Client
|
||||
logger *log.Entry
|
||||
}
|
||||
|
||||
// New validates the config and returns a Client. The endpoint is not contacted
|
||||
// here: the engine may come up after the proxy.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
if cfg.URL == "" {
|
||||
return nil, errors.New("appsec url is empty")
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, errors.New("appsec api key is empty")
|
||||
}
|
||||
parsed, err := url.Parse(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse appsec url: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("appsec url scheme %q is not http(s)", parsed.Scheme)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return nil, errors.New("appsec url has no host")
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
maxBody := cfg.MaxBodyBytes
|
||||
if maxBody == 0 {
|
||||
maxBody = DefaultMaxBodyBytes
|
||||
}
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = log.NewEntry(log.StandardLogger())
|
||||
}
|
||||
|
||||
return &Client{
|
||||
url: cfg.URL,
|
||||
apiKey: cfg.APIKey,
|
||||
maxBodyBytes: maxBody,
|
||||
logger: logger,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Request is one inspection request.
|
||||
type Request struct {
|
||||
// HTTP is the in-flight client request. Inspect buffers and restores its
|
||||
// body, so the request stays forwardable afterwards.
|
||||
HTTP *http.Request
|
||||
// ClientIP is the resolved client address (after trusted-proxy handling).
|
||||
ClientIP netip.Addr
|
||||
// TransactionID correlates the engine's alert with the proxy's access log
|
||||
// entry. Empty lets the engine generate its own UUID.
|
||||
TransactionID string
|
||||
// OmitBodyFields suppresses body forwarding when the body is a form
|
||||
// containing any of these field names. Used to keep credentials submitted
|
||||
// to the proxy's own login form out of the engine.
|
||||
OmitBodyFields []string
|
||||
}
|
||||
|
||||
// Inspect mirrors r to the AppSec engine and returns its verdict. A nil error
|
||||
// with restrict.Allow means the request passed. On failure it returns
|
||||
// DenyAppSecUnavailable wrapped with ErrUnavailable; the caller decides whether
|
||||
// that blocks, based on the per-service mode.
|
||||
func (c *Client) Inspect(ctx context.Context, req Request) (restrict.Verdict, error) {
|
||||
if c == nil {
|
||||
return restrict.DenyAppSecUnavailable, ErrUnavailable
|
||||
}
|
||||
if req.HTTP == nil {
|
||||
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: nil request", ErrUnavailable)
|
||||
}
|
||||
|
||||
body, err := c.readBody(req)
|
||||
if err != nil {
|
||||
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: read body: %w", ErrUnavailable, err)
|
||||
}
|
||||
|
||||
outbound, err := c.buildRequest(ctx, req, body)
|
||||
if err != nil {
|
||||
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: %w", ErrUnavailable, err)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(outbound)
|
||||
if err != nil {
|
||||
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: %w", ErrUnavailable, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
c.logger.Tracef("close appsec response body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return c.verdict(resp)
|
||||
}
|
||||
|
||||
// readBody buffers the body so it can be mirrored, always restoring it on the
|
||||
// original request. Returns nil when there is no body to forward: no body at
|
||||
// all, an upgrade request, a body over the cap, or a credential form.
|
||||
func (c *Client) readBody(req Request) ([]byte, error) {
|
||||
r := req.HTTP
|
||||
if c.maxBodyBytes < 0 || r.Body == nil || r.Body == http.NoBody {
|
||||
return nil, nil
|
||||
}
|
||||
// Upgrade requests (websockets) have no meaningful request body and their
|
||||
// stream must not be consumed here.
|
||||
if r.Header.Get("Upgrade") != "" || strings.EqualFold(r.Header.Get("Connection"), "upgrade") {
|
||||
return nil, nil
|
||||
}
|
||||
// A Content-Length over the cap is known to be too large before reading.
|
||||
if r.ContentLength > c.maxBodyBytes {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, oversize, err := bufferBody(r, c.maxBodyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// An oversize body was only partially read: a truncated prefix changes the
|
||||
// engine's verdict in both directions, so inspect headers and URI only.
|
||||
if oversize {
|
||||
return nil, nil
|
||||
}
|
||||
if formHasField(r.Header.Get("Content-Type"), body, req.OmitBodyFields) {
|
||||
return nil, nil
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// buildRequest assembles the mirrored request. Per the protocol it is a GET
|
||||
// when there is no body and a POST otherwise; bytes.Reader gives the outbound
|
||||
// request an accurate Content-Length, which the engine relies on to read the
|
||||
// body at all.
|
||||
func (c *Client) buildRequest(ctx context.Context, req Request, body []byte) (*http.Request, error) {
|
||||
method := http.MethodGet
|
||||
var payload io.Reader
|
||||
if len(body) > 0 {
|
||||
method = http.MethodPost
|
||||
payload = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
outbound, err := http.NewRequestWithContext(ctx, method, c.url, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build appsec request: %w", err)
|
||||
}
|
||||
|
||||
r := req.HTTP
|
||||
copyInspectableHeaders(outbound.Header, r.Header)
|
||||
|
||||
outbound.Header.Set(headerAPIKey, c.apiKey)
|
||||
outbound.Header.Set(headerIP, req.ClientIP.Unmap().String())
|
||||
outbound.Header.Set(headerURI, r.URL.RequestURI())
|
||||
outbound.Header.Set(headerVerb, r.Method)
|
||||
outbound.Header.Set(headerHost, r.Host)
|
||||
if ua := r.UserAgent(); ua != "" {
|
||||
outbound.Header.Set(headerUserAgent, ua)
|
||||
}
|
||||
outbound.Header.Set(headerHTTPVersion, httpVersion(r))
|
||||
if req.TransactionID != "" {
|
||||
outbound.Header.Set(headerTransactionID, req.TransactionID)
|
||||
}
|
||||
return outbound, nil
|
||||
}
|
||||
|
||||
// verdict maps the engine's response to a restrict.Verdict. 200 is a pass and
|
||||
// 401/500 are engine-side failures; every other status carries a remediation in
|
||||
// the body. The blocked status code is operator-configurable
|
||||
// (blocked_http_code), so the action field decides, not the status.
|
||||
func (c *Client) verdict(resp *http.Response) (restrict.Verdict, error) {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return restrict.Allow, nil
|
||||
case http.StatusUnauthorized:
|
||||
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: rejected api key", ErrUnavailable)
|
||||
case http.StatusInternalServerError:
|
||||
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: engine error", ErrUnavailable)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<10)).Decode(&decoded); err != nil {
|
||||
// A non-2xx status with an unreadable body is still a block: the engine
|
||||
// answered, we just cannot tell which remediation it chose.
|
||||
c.logger.Debugf("failed to decode appsec response (status %d): %v", resp.StatusCode, err)
|
||||
return restrict.DenyAppSecBan, nil
|
||||
}
|
||||
|
||||
switch decoded.Action {
|
||||
case actionAllow:
|
||||
return restrict.Allow, nil
|
||||
case actionCaptcha:
|
||||
return restrict.DenyAppSecCaptcha, nil
|
||||
case actionBan:
|
||||
return restrict.DenyAppSecBan, nil
|
||||
default:
|
||||
// Unknown remediation: the engine flagged the request, so deny.
|
||||
c.logger.Debugf("unknown appsec action %q (status %d), treating as ban", decoded.Action, resp.StatusCode)
|
||||
return restrict.DenyAppSecBan, nil
|
||||
}
|
||||
}
|
||||
|
||||
// copyInspectableHeaders copies the client's headers, which are what the WAF
|
||||
// rules actually match on, dropping hop-by-hop headers that describe the
|
||||
// proxy-to-engine connection rather than the client request, and any header in
|
||||
// the AppSec protocol namespace.
|
||||
func copyInspectableHeaders(dst, src http.Header) {
|
||||
for name, values := range src {
|
||||
if hopByHopHeaders[http.CanonicalHeaderKey(name)] {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(http.CanonicalHeaderKey(name), headerPrefix) {
|
||||
continue
|
||||
}
|
||||
dst[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
|
||||
}
|
||||
// Content-Length describes the mirrored payload, not the client's: net/http
|
||||
// sets it from the body we actually attach. Content-Type is kept either way
|
||||
// so rules matching on it still fire when the body was not forwarded.
|
||||
dst.Del("Content-Length")
|
||||
}
|
||||
|
||||
var hopByHopHeaders = map[string]bool{
|
||||
"Connection": true,
|
||||
"Keep-Alive": true,
|
||||
"Proxy-Authenticate": true,
|
||||
"Proxy-Authorization": true,
|
||||
"Proxy-Connection": true,
|
||||
"Te": true,
|
||||
"Trailer": true,
|
||||
"Transfer-Encoding": true,
|
||||
"Upgrade": true,
|
||||
}
|
||||
|
||||
// httpVersion renders the two-digit form the engine parses ("11", "20").
|
||||
func httpVersion(r *http.Request) string {
|
||||
major, minor := r.ProtoMajor, r.ProtoMinor
|
||||
if major < 0 || major > 9 || minor < 0 || minor > 9 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d%d", major, minor)
|
||||
}
|
||||
416
proxy/internal/appsec/client_test.go
Normal file
416
proxy/internal/appsec/client_test.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package appsec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
||||
)
|
||||
|
||||
// engine records what the AppSec component received and replies with a
|
||||
// canned status and body.
|
||||
type engine struct {
|
||||
status int
|
||||
body string
|
||||
|
||||
gotMethod string
|
||||
gotHeader http.Header
|
||||
gotBody []byte
|
||||
gotLength int64
|
||||
requests int
|
||||
}
|
||||
|
||||
func (e *engine) start(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
e.requests++
|
||||
e.gotMethod = r.Method
|
||||
e.gotHeader = r.Header.Clone()
|
||||
e.gotBody = body
|
||||
e.gotLength = r.ContentLength
|
||||
|
||||
status := e.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if e.body != "" {
|
||||
_, _ = w.Write([]byte(e.body))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newClient(t *testing.T, url string, cfg ...func(*Config)) *Client {
|
||||
t.Helper()
|
||||
c := Config{URL: url, APIKey: "test-key"}
|
||||
for _, fn := range cfg {
|
||||
fn(&c)
|
||||
}
|
||||
client, err := New(c)
|
||||
require.NoError(t, err)
|
||||
return client
|
||||
}
|
||||
|
||||
func inbound(method, target string, body string) *http.Request {
|
||||
var r *http.Request
|
||||
if body == "" {
|
||||
r = httptest.NewRequest(method, target, nil)
|
||||
} else {
|
||||
r = httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
}
|
||||
r.Host = "svc.example.com"
|
||||
r.Header.Set("User-Agent", "curl/8.0")
|
||||
return r
|
||||
}
|
||||
|
||||
func TestNew_RejectsBadConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
}{
|
||||
{"empty url", Config{APIKey: "k"}},
|
||||
{"empty key", Config{URL: "http://127.0.0.1:7422/"}},
|
||||
{"non http scheme", Config{URL: "tcp://127.0.0.1:7422", APIKey: "k"}},
|
||||
{"no host", Config{URL: "http:///path", APIKey: "k"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := New(tt.cfg)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspect_AllowSendsProtocolHeaders(t *testing.T) {
|
||||
eng := &engine{status: http.StatusOK, body: `{"action":"allow","http_status":200}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL)
|
||||
|
||||
r := inbound(http.MethodGet, "http://svc.example.com/admin?q=1", "")
|
||||
r.Header.Set("Cookie", "session=abc")
|
||||
|
||||
verdict, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
TransactionID: "req-42",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, restrict.Allow, verdict, "allow action must pass the request")
|
||||
|
||||
assert.Equal(t, http.MethodGet, eng.gotMethod, "a bodyless request is forwarded as GET")
|
||||
assert.Equal(t, "203.0.113.7", eng.gotHeader.Get(headerIP))
|
||||
assert.Equal(t, "/admin?q=1", eng.gotHeader.Get(headerURI), "URI header must carry path and query")
|
||||
assert.Equal(t, http.MethodGet, eng.gotHeader.Get(headerVerb))
|
||||
assert.Equal(t, "svc.example.com", eng.gotHeader.Get(headerHost))
|
||||
assert.Equal(t, "curl/8.0", eng.gotHeader.Get(headerUserAgent))
|
||||
assert.Equal(t, "11", eng.gotHeader.Get(headerHTTPVersion))
|
||||
assert.Equal(t, "req-42", eng.gotHeader.Get(headerTransactionID))
|
||||
assert.Equal(t, "test-key", eng.gotHeader.Get(headerAPIKey))
|
||||
// Client headers are what the WAF rules match on.
|
||||
assert.Equal(t, "session=abc", eng.gotHeader.Get("Cookie"))
|
||||
}
|
||||
|
||||
func TestInspect_MapsActionsToVerdicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
want restrict.Verdict
|
||||
wantErr bool
|
||||
}{
|
||||
{"allow", http.StatusOK, `{"action":"allow"}`, restrict.Allow, false},
|
||||
{"ban", http.StatusForbidden, `{"action":"ban","http_status":403}`, restrict.DenyAppSecBan, false},
|
||||
{"captcha", http.StatusForbidden, `{"action":"captcha","http_status":403}`, restrict.DenyAppSecCaptcha, false},
|
||||
// blocked_http_code is operator-configurable, so the action decides.
|
||||
{"custom block status", http.StatusTeapot, `{"action":"ban"}`, restrict.DenyAppSecBan, false},
|
||||
{"allow on custom status", http.StatusTeapot, `{"action":"allow"}`, restrict.Allow, false},
|
||||
{"unknown action denies", http.StatusForbidden, `{"action":"something-new"}`, restrict.DenyAppSecBan, false},
|
||||
{"unreadable body denies", http.StatusForbidden, `not json`, restrict.DenyAppSecBan, false},
|
||||
{"bad api key is unavailable", http.StatusUnauthorized, "", restrict.DenyAppSecUnavailable, true},
|
||||
{"engine error is unavailable", http.StatusInternalServerError, "", restrict.DenyAppSecUnavailable, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
eng := &engine{status: tt.status, body: tt.body}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL)
|
||||
|
||||
verdict, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrUnavailable), "engine-side failures must be ErrUnavailable so the caller can apply the mode")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.want, verdict)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspect_ForwardsBodyAndRestoresIt(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL)
|
||||
|
||||
const payload = `{"user":"' OR 1=1--"}`
|
||||
r := inbound(http.MethodPost, "http://svc.example.com/login", payload)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.MethodPost, eng.gotMethod, "a request with a body is forwarded as POST")
|
||||
assert.Equal(t, payload, string(eng.gotBody))
|
||||
assert.Equal(t, int64(len(payload)), eng.gotLength,
|
||||
"the engine reads exactly Content-Length bytes, so it must be accurate")
|
||||
|
||||
// The backend still needs the body.
|
||||
restored, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload, string(restored), "body must be restored for the upstream request")
|
||||
}
|
||||
|
||||
func TestInspect_OversizeBodyFallsBackToHeaders(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL, func(c *Config) { c.MaxBodyBytes = 16 })
|
||||
|
||||
payload := strings.Repeat("A", 64)
|
||||
r := inbound(http.MethodPost, "http://svc.example.com/upload", payload)
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.MethodGet, eng.gotMethod, "an oversize body is not forwarded")
|
||||
assert.Empty(t, eng.gotBody, "a truncated prefix must never be sent: it changes the verdict")
|
||||
|
||||
restored, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload, string(restored), "the full body must still reach the upstream")
|
||||
}
|
||||
|
||||
func TestInspect_ChunkedOversizeBodyIsReplayed(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL, func(c *Config) { c.MaxBodyBytes = 8 })
|
||||
|
||||
payload := strings.Repeat("B", 40)
|
||||
r := inbound(http.MethodPost, "http://svc.example.com/upload", payload)
|
||||
// Unknown length: the cap can only be detected while reading.
|
||||
r.ContentLength = -1
|
||||
r.Header.Del("Content-Length")
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, eng.gotBody)
|
||||
restored, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload, string(restored), "bytes read to detect the cap must be replayed")
|
||||
}
|
||||
|
||||
func TestInspect_SkipsCredentialForm(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL)
|
||||
|
||||
r := inbound(http.MethodPost, "http://svc.example.com/", "password=hunter2&next=%2Fhome")
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
OmitBodyFields: []string{"password", "pin"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, eng.gotBody, "a login form body must not reach the engine")
|
||||
assert.NotContains(t, string(eng.gotBody), "hunter2")
|
||||
// The request is still inspected on headers and URI.
|
||||
assert.Equal(t, 1, eng.requests)
|
||||
|
||||
restored, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "password=hunter2&next=%2Fhome", string(restored),
|
||||
"the login handler still needs to read the form")
|
||||
}
|
||||
|
||||
func TestInspect_ForwardsNonCredentialForm(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL)
|
||||
|
||||
r := inbound(http.MethodPost, "http://svc.example.com/search", "q=%3Cscript%3E")
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
OmitBodyFields: []string{"password", "pin"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "q=%3Cscript%3E", string(eng.gotBody), "ordinary form bodies must be inspected")
|
||||
}
|
||||
|
||||
func TestInspect_StripsSpoofedProtocolHeaders(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL)
|
||||
|
||||
r := inbound(http.MethodGet, "http://svc.example.com/", "")
|
||||
// A caller trying to make the engine see a different source address, and to
|
||||
// smuggle in its own key.
|
||||
r.Header.Set(headerIP, "10.0.0.1")
|
||||
r.Header.Set(headerAPIKey, "attacker-key")
|
||||
r.Header.Set(headerURI, "/harmless")
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "203.0.113.7", eng.gotHeader.Get(headerIP), "the proxy's resolved IP must win")
|
||||
assert.Equal(t, "test-key", eng.gotHeader.Get(headerAPIKey))
|
||||
assert.Equal(t, "/", eng.gotHeader.Get(headerURI))
|
||||
assert.Len(t, eng.gotHeader.Values(headerIP), 1, "no duplicate protocol headers")
|
||||
}
|
||||
|
||||
func TestInspect_DropsHopByHopHeaders(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL)
|
||||
|
||||
r := inbound(http.MethodGet, "http://svc.example.com/", "")
|
||||
r.Header.Set("Proxy-Authorization", "Basic zzz")
|
||||
r.Header.Set("Keep-Alive", "timeout=5")
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, eng.gotHeader.Get("Proxy-Authorization"))
|
||||
assert.Empty(t, eng.gotHeader.Get("Keep-Alive"))
|
||||
}
|
||||
|
||||
func TestInspect_UpgradeRequestKeepsStreamIntact(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL)
|
||||
|
||||
r := inbound(http.MethodGet, "http://svc.example.com/ws", "")
|
||||
r.Header.Set("Upgrade", "websocket")
|
||||
r.Header.Set("Connection", "upgrade")
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.MethodGet, eng.gotMethod)
|
||||
assert.Empty(t, eng.gotBody)
|
||||
assert.Equal(t, 1, eng.requests, "upgrade requests are still inspected on headers")
|
||||
}
|
||||
|
||||
func TestInspect_TimeoutIsUnavailable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client := newClient(t, srv.URL, func(c *Config) { c.Timeout = 10 * time.Millisecond })
|
||||
|
||||
verdict, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrUnavailable))
|
||||
assert.Equal(t, restrict.DenyAppSecUnavailable, verdict)
|
||||
}
|
||||
|
||||
func TestInspect_UnreachableEngineIsUnavailable(t *testing.T) {
|
||||
// Port 1 on loopback refuses connections.
|
||||
client := newClient(t, "http://127.0.0.1:1/")
|
||||
|
||||
verdict, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrUnavailable))
|
||||
assert.Equal(t, restrict.DenyAppSecUnavailable, verdict)
|
||||
}
|
||||
|
||||
func TestInspect_NilClientFailsClosed(t *testing.T) {
|
||||
var client *Client
|
||||
verdict, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, restrict.DenyAppSecUnavailable, verdict)
|
||||
}
|
||||
|
||||
func TestInspect_NegativeCapDisablesBodyForwarding(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL, func(c *Config) { c.MaxBodyBytes = -1 })
|
||||
|
||||
const payload = `{"a":1}`
|
||||
r := inbound(http.MethodPost, "http://svc.example.com/", payload)
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: r,
|
||||
ClientIP: netip.MustParseAddr("203.0.113.7"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, eng.gotBody)
|
||||
|
||||
restored, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload, string(restored), "an untouched body must still be forwardable")
|
||||
}
|
||||
|
||||
func TestInspect_MapsV4MappedClientIP(t *testing.T) {
|
||||
eng := &engine{body: `{"action":"allow"}`}
|
||||
srv := eng.start(t)
|
||||
client := newClient(t, srv.URL)
|
||||
|
||||
_, err := client.Inspect(context.Background(), Request{
|
||||
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
|
||||
ClientIP: netip.MustParseAddr("::ffff:203.0.113.7"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "203.0.113.7", eng.gotHeader.Get(headerIP),
|
||||
"v4-mapped addresses must be unmapped so engine allowlists and rules match")
|
||||
}
|
||||
179
proxy/internal/auth/appsec_test.go
Normal file
179
proxy/internal/auth/appsec_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/appsec"
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
)
|
||||
|
||||
// appsecEngine is a stub AppSec component returning a fixed remediation.
|
||||
func appsecEngine(t *testing.T, status int, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = strings.NewReader("").Read(nil)
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
w.WriteHeader(status)
|
||||
if body != "" {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// serveWithAppSec runs a request through the middleware for a domain configured
|
||||
// with the given AppSec mode, returning the response and the captured metadata.
|
||||
func serveWithAppSec(t *testing.T, mode restrict.AppSecMode, client *appsec.Client, r *http.Request) (*httptest.ResponseRecorder, map[string]string, bool) {
|
||||
t.Helper()
|
||||
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
mw.SetAppSec(client)
|
||||
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
|
||||
AccountID: types.AccountID("acct-1"),
|
||||
ServiceID: types.ServiceID("svc-1"),
|
||||
AppSecMode: mode,
|
||||
}))
|
||||
|
||||
reached := false
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
cd := proxy.NewCapturedData("req-1")
|
||||
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, r)
|
||||
|
||||
return rec, cd.GetMetadata(), reached
|
||||
}
|
||||
|
||||
func appsecRequest() *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://svc.example.com/?x=/etc/passwd", nil)
|
||||
r.Host = "svc.example.com"
|
||||
r.RemoteAddr = "203.0.113.7:44444"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestCheckAppSec_EnforceBlocksBannedRequest(t *testing.T) {
|
||||
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban","http_status":403}`)
|
||||
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.False(t, reached, "a banned request must not reach the backend")
|
||||
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
|
||||
assert.NotContains(t, meta, "appsec_mode", "enforce is the default, only observe is annotated")
|
||||
}
|
||||
|
||||
func TestCheckAppSec_ObserveAllowsAndRecordsVerdict(t *testing.T) {
|
||||
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban","http_status":403}`)
|
||||
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, meta, reached := serveWithAppSec(t, restrict.AppSecObserve, client, appsecRequest())
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.True(t, reached, "observe mode must not block")
|
||||
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
|
||||
assert.Equal(t, "observe", meta["appsec_mode"])
|
||||
}
|
||||
|
||||
func TestCheckAppSec_AllowedRequestPasses(t *testing.T) {
|
||||
srv := appsecEngine(t, http.StatusOK, `{"action":"allow","http_status":200}`)
|
||||
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.True(t, reached)
|
||||
assert.NotContains(t, meta, "appsec_verdict", "a clean request records no verdict")
|
||||
}
|
||||
|
||||
func TestCheckAppSec_OffSkipsInspection(t *testing.T) {
|
||||
// An engine that would ban everything; the mode must keep us away from it.
|
||||
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
|
||||
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, meta, reached := serveWithAppSec(t, restrict.AppSecOff, client, appsecRequest())
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.True(t, reached)
|
||||
assert.Empty(t, meta)
|
||||
}
|
||||
|
||||
func TestCheckAppSec_EnforceFailsClosedWithoutClient(t *testing.T) {
|
||||
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, nil, appsecRequest())
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code,
|
||||
"enforce with no configured endpoint must deny rather than pass traffic uninspected")
|
||||
assert.False(t, reached)
|
||||
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
|
||||
}
|
||||
|
||||
func TestCheckAppSec_ObserveAllowsWithoutClient(t *testing.T) {
|
||||
rec, meta, reached := serveWithAppSec(t, restrict.AppSecObserve, nil, appsecRequest())
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.True(t, reached)
|
||||
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
|
||||
assert.Equal(t, "observe", meta["appsec_mode"])
|
||||
}
|
||||
|
||||
func TestCheckAppSec_EnforceFailsClosedWhenEngineUnreachable(t *testing.T) {
|
||||
client, err := appsec.New(appsec.Config{URL: "http://127.0.0.1:1/", APIKey: "k"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.False(t, reached)
|
||||
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
|
||||
}
|
||||
|
||||
func TestCheckAppSec_InspectsOverlayTraffic(t *testing.T) {
|
||||
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
|
||||
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Requests arriving over the WireGuard overlay skip the geo and IP-reputation
|
||||
// checks, but request content is just as inspectable.
|
||||
r := appsecRequest()
|
||||
r = r.WithContext(types.WithOverlayOrigin(r.Context()))
|
||||
|
||||
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "overlay traffic must still be inspected")
|
||||
assert.False(t, reached)
|
||||
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
|
||||
}
|
||||
|
||||
func TestCheckAppSec_UnresolvableClientIPFailsClosed(t *testing.T) {
|
||||
srv := appsecEngine(t, http.StatusOK, `{"action":"allow"}`)
|
||||
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
|
||||
require.NoError(t, err)
|
||||
|
||||
r := appsecRequest()
|
||||
r.RemoteAddr = "not-an-address"
|
||||
|
||||
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code,
|
||||
"the engine requires a client address; a request we cannot attribute must not pass")
|
||||
assert.False(t, reached)
|
||||
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/auth"
|
||||
"github.com/netbirdio/netbird/proxy/internal/appsec"
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
@@ -59,6 +60,8 @@ type DomainConfig struct {
|
||||
IPRestrictions *restrict.Filter
|
||||
// Private routes the domain through ValidateTunnelPeer; failure → 403.
|
||||
Private bool
|
||||
// AppSecMode enables CrowdSec AppSec request inspection for this domain.
|
||||
AppSecMode restrict.AppSecMode
|
||||
}
|
||||
|
||||
type validationResult struct {
|
||||
@@ -82,6 +85,9 @@ type Middleware struct {
|
||||
sessionValidator SessionValidator
|
||||
geo restrict.GeoResolver
|
||||
tunnelCache *tunnelValidationCache
|
||||
// appsec is the shared CrowdSec AppSec client, nil when the proxy has no
|
||||
// AppSec endpoint configured. Set once during startup, before serving.
|
||||
appsec *appsec.Client
|
||||
}
|
||||
|
||||
// NewMiddleware creates a new authentication middleware. The sessionValidator is
|
||||
@@ -99,6 +105,12 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
|
||||
}
|
||||
}
|
||||
|
||||
// SetAppSec installs the shared CrowdSec AppSec client. Must be called during
|
||||
// startup, before the middleware serves any request.
|
||||
func (mw *Middleware) SetAppSec(client *appsec.Client) {
|
||||
mw.appsec = client
|
||||
}
|
||||
|
||||
// Protect wraps next with per-domain authentication and IP restriction checks.
|
||||
// Requests whose Host is not registered pass through unchanged.
|
||||
func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
@@ -123,6 +135,10 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if !mw.checkAppSec(w, r, config) {
|
||||
return
|
||||
}
|
||||
|
||||
// Private services bypass operator schemes and gate on tunnel peer.
|
||||
if config.Private {
|
||||
if mw.forwardWithTunnelPeer(w, r, host, config, next) {
|
||||
@@ -262,6 +278,93 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
|
||||
return false
|
||||
}
|
||||
|
||||
// checkAppSec mirrors the request to the CrowdSec AppSec engine when the domain
|
||||
// enables inspection. Returns false when the request was blocked and a response
|
||||
// has been written.
|
||||
//
|
||||
// Unlike the geo and IP-reputation checks, this runs for overlay traffic too:
|
||||
// AppSec inspects request content, which is just as meaningful when the caller
|
||||
// reached the proxy through the WireGuard tunnel.
|
||||
func (mw *Middleware) checkAppSec(w http.ResponseWriter, r *http.Request, config DomainConfig) bool {
|
||||
if !config.AppSecMode.Enabled() {
|
||||
return true
|
||||
}
|
||||
|
||||
verdict := mw.inspectAppSec(r, config)
|
||||
if verdict == restrict.Allow {
|
||||
return true
|
||||
}
|
||||
|
||||
observe := config.AppSecMode == restrict.AppSecObserve
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetMetadata("appsec_verdict", verdict.String())
|
||||
if observe {
|
||||
cd.SetMetadata("appsec_mode", "observe")
|
||||
}
|
||||
}
|
||||
|
||||
if observe {
|
||||
mw.logger.Debugf("AppSec observe: would block %s for %s (%s)", r.RemoteAddr, r.Host, verdict)
|
||||
return true
|
||||
}
|
||||
|
||||
mw.markDenied(r, verdict.String())
|
||||
mw.logger.Debugf("AppSec: %s for %s %s", verdict, r.Host, r.RemoteAddr)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
// inspectAppSec runs the AppSec call and returns its verdict. Failures come
|
||||
// back as DenyAppSecUnavailable regardless of mode so observe mode still
|
||||
// records that inspection did not happen; the caller decides what blocks.
|
||||
func (mw *Middleware) inspectAppSec(r *http.Request, config DomainConfig) restrict.Verdict {
|
||||
// Mode requested but the proxy has no AppSec endpoint configured. Management
|
||||
// gates this on the cluster capability; a stale mapping can still arrive.
|
||||
if mw.appsec == nil {
|
||||
mw.logger.Debugf("AppSec mode %q requested for %s but no AppSec endpoint is configured", config.AppSecMode, r.Host)
|
||||
return restrict.DenyAppSecUnavailable
|
||||
}
|
||||
|
||||
clientIP := mw.resolveClientIP(r)
|
||||
if !clientIP.IsValid() {
|
||||
// The engine requires a client address, and a request whose source we
|
||||
// cannot establish is exactly the kind we must not wave through.
|
||||
mw.logger.Debugf("AppSec: cannot resolve client address for %q", r.RemoteAddr)
|
||||
return restrict.DenyAppSecUnavailable
|
||||
}
|
||||
|
||||
req := appsec.Request{
|
||||
HTTP: r,
|
||||
ClientIP: clientIP,
|
||||
OmitBodyFields: credentialFormFields(config.Schemes),
|
||||
}
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
req.TransactionID = cd.GetRequestID()
|
||||
}
|
||||
|
||||
verdict, err := mw.appsec.Inspect(r.Context(), req)
|
||||
if err != nil {
|
||||
mw.logger.Debugf("AppSec inspection failed for %s: %v", r.Host, err)
|
||||
}
|
||||
return verdict
|
||||
}
|
||||
|
||||
// credentialFormFields lists the login form fields whose presence suppresses
|
||||
// body forwarding, so a password or PIN submitted to the proxy's own login form
|
||||
// never reaches the Security Engine.
|
||||
func credentialFormFields(schemes []Scheme) []string {
|
||||
var fields []string
|
||||
for _, s := range schemes {
|
||||
switch s.Type() {
|
||||
case auth.MethodPassword:
|
||||
fields = append(fields, passwordFormId)
|
||||
case auth.MethodPIN:
|
||||
fields = append(fields, pinFormId)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// resolveClientIP extracts the real client IP from CapturedData, falling back to r.RemoteAddr.
|
||||
func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
@@ -281,12 +384,18 @@ func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
|
||||
return addr.Unmap()
|
||||
}
|
||||
|
||||
// blockIPRestriction sets captured data fields for an IP-restriction block event.
|
||||
func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) {
|
||||
// markDenied records the deny reason on the captured data so the access log
|
||||
// attributes the response to the proxy rather than the backend.
|
||||
func (mw *Middleware) markDenied(r *http.Request, reason string) {
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetOrigin(proxy.OriginAuth)
|
||||
cd.SetAuthMethod(reason)
|
||||
}
|
||||
}
|
||||
|
||||
// blockIPRestriction sets captured data fields for an IP-restriction block event.
|
||||
func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) {
|
||||
mw.markDenied(r, reason)
|
||||
mw.logger.Debugf("IP restriction: %s for %s", reason, r.RemoteAddr)
|
||||
}
|
||||
|
||||
@@ -642,40 +751,49 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
|
||||
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
|
||||
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error {
|
||||
if len(schemes) == 0 {
|
||||
mw.domainsMux.Lock()
|
||||
defer mw.domainsMux.Unlock()
|
||||
mw.domains[domain] = DomainConfig{
|
||||
AccountID: accountID,
|
||||
ServiceID: serviceID,
|
||||
IPRestrictions: ipRestrictions,
|
||||
Private: private,
|
||||
}
|
||||
return nil
|
||||
// DomainSettings is the per-domain configuration AddDomain applies.
|
||||
type DomainSettings struct {
|
||||
Schemes []Scheme
|
||||
// SessionPublicKey is the base64-encoded ed25519 key used to verify session
|
||||
// cookies. Required when Schemes is non-empty.
|
||||
SessionPublicKey string
|
||||
SessionExpiration time.Duration
|
||||
AccountID types.AccountID
|
||||
ServiceID types.ServiceID
|
||||
IPRestrictions *restrict.Filter
|
||||
// Private forces ValidateTunnelPeer enforcement (403 on failure) regardless
|
||||
// of the schemes list.
|
||||
Private bool
|
||||
AppSecMode restrict.AppSecMode
|
||||
}
|
||||
|
||||
// AddDomain registers authentication schemes for the given domain. With schemes
|
||||
// a valid session public key is required.
|
||||
func (mw *Middleware) AddDomain(domain string, settings DomainSettings) error {
|
||||
config := DomainConfig{
|
||||
AccountID: settings.AccountID,
|
||||
ServiceID: settings.ServiceID,
|
||||
IPRestrictions: settings.IPRestrictions,
|
||||
Private: settings.Private,
|
||||
AppSecMode: settings.AppSecMode,
|
||||
}
|
||||
|
||||
pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode session public key for domain %s: %w", domain, err)
|
||||
}
|
||||
if len(pubKeyBytes) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
|
||||
if len(settings.Schemes) > 0 {
|
||||
pubKeyBytes, err := base64.StdEncoding.DecodeString(settings.SessionPublicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode session public key for domain %s: %w", domain, err)
|
||||
}
|
||||
if len(pubKeyBytes) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
|
||||
}
|
||||
config.Schemes = settings.Schemes
|
||||
config.SessionPublicKey = pubKeyBytes
|
||||
config.SessionExpiration = settings.SessionExpiration
|
||||
}
|
||||
|
||||
mw.domainsMux.Lock()
|
||||
defer mw.domainsMux.Unlock()
|
||||
mw.domains[domain] = DomainConfig{
|
||||
Schemes: schemes,
|
||||
SessionPublicKey: pubKeyBytes,
|
||||
SessionExpiration: expiration,
|
||||
AccountID: accountID,
|
||||
ServiceID: serviceID,
|
||||
IPRestrictions: ipRestrictions,
|
||||
Private: private,
|
||||
}
|
||||
mw.domains[domain] = config
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestAddDomain_ValidKey(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour})
|
||||
require.NoError(t, err)
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
@@ -81,7 +81,7 @@ func TestAddDomain_EmptyKey(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionExpiration: time.Hour})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid session public key size")
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: "not-valid-base64!!!", SessionExpiration: time.Hour})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decode session public key")
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
|
||||
|
||||
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: shortKey, SessionExpiration: time.Hour})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid session public key size")
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
|
||||
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{SessionExpiration: time.Hour})
|
||||
require.NoError(t, err, "domains with no auth schemes should not require a key")
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
@@ -139,8 +139,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp1.PublicKey, SessionExpiration: time.Hour}))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp2.PublicKey, SessionExpiration: 2 * time.Hour}))
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
config := mw.domains["example.com"]
|
||||
@@ -156,7 +156,7 @@ func TestRemoveDomain(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
mw.RemoveDomain("example.com")
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) {
|
||||
|
||||
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -218,7 +218,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -239,7 +239,7 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
@@ -272,7 +272,7 @@ func TestProtect_SessionCookieGroupsPropagate(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
groups := []string{"engineering", "sre"}
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
|
||||
@@ -337,7 +337,7 @@ func TestProtect_PrivateService_TunnelPeerGroupsPropagate(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
// Private service: no operator schemes — auth gates solely on the tunnel peer.
|
||||
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
|
||||
require.NoError(t, mw.AddDomain("agent.example.com", DomainSettings{SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
|
||||
|
||||
cd := proxy.NewCapturedData("")
|
||||
cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source
|
||||
@@ -377,7 +377,7 @@ func TestProtect_PrivateService_TunnelPeerDenied(t *testing.T) {
|
||||
}}
|
||||
mw := NewMiddleware(log.StandardLogger(), validator, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
|
||||
require.NoError(t, mw.AddDomain("agent.example.com", DomainSettings{SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
|
||||
|
||||
cd := proxy.NewCapturedData("")
|
||||
cd.SetClientIP(netip.MustParseAddr("100.90.1.14"))
|
||||
@@ -405,7 +405,7 @@ func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
// Sign a token that expired 1 second ago.
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
|
||||
@@ -431,7 +431,7 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
// Token signed for a different domain audience.
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
@@ -458,7 +458,7 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) {
|
||||
kp2 := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp1.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
// Token signed with a different private key.
|
||||
token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
@@ -495,7 +495,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -548,7 +548,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -584,7 +584,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
|
||||
return "", "password", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{pinScheme, passwordScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -614,7 +614,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
|
||||
return "invalid-jwt-token", "", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -638,7 +638,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(randomBytes)
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
|
||||
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false)
|
||||
err = mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: key, SessionExpiration: time.Hour})
|
||||
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
|
||||
}
|
||||
|
||||
@@ -647,10 +647,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
// Attempt to overwrite with an invalid key.
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: "bad", SessionExpiration: time.Hour})
|
||||
require.Error(t, err)
|
||||
|
||||
// The original valid config should still be intact.
|
||||
@@ -674,7 +674,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -701,7 +701,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
|
||||
return "", "password", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -728,7 +728,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -815,8 +815,7 @@ func TestWasCredentialSubmitted(t *testing.T) {
|
||||
func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})})
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -851,8 +850,7 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) {
|
||||
// trusted proxies), checkIPRestrictions should use that IP, not RemoteAddr.
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}})})
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -892,8 +890,7 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
|
||||
// Geo is nil, country restrictions are configured: must deny (fail-close).
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}})})
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -916,11 +913,10 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
|
||||
func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{
|
||||
AllowedCIDRs: []string{"100.64.0.0/10"},
|
||||
AllowedCountries: []string{"US"},
|
||||
}), false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{
|
||||
AllowedCIDRs: []string{"100.64.0.0/10"},
|
||||
AllowedCountries: []string{"US"},
|
||||
})})
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -953,8 +949,7 @@ func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
|
||||
func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false)
|
||||
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}})})
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -982,7 +977,7 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
|
||||
return "", oidcURL, nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1011,7 +1006,7 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{oidcScheme, pinScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1055,7 +1050,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
|
||||
|
||||
var backendCalled bool
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
@@ -1098,7 +1093,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
// Also add a PIN scheme so we can verify fallthrough behavior.
|
||||
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr, pinScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1118,7 +1113,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
|
||||
return &proto.AuthenticateResponse{Success: false}, nil
|
||||
}}
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -1141,7 +1136,7 @@ func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
|
||||
return nil, errors.New("gRPC unavailable")
|
||||
}}
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1158,7 +1153,7 @@ func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1218,7 +1213,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
|
||||
|
||||
// Single Header scheme (as if one entry existed), but the mock checks both values.
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
|
||||
|
||||
var backendCalled bool
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -1276,7 +1271,7 @@ func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) {
|
||||
return "", "https://idp.example.com/authorize", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1300,7 +1295,7 @@ func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) {
|
||||
return "", "https://idp.example.com/authorize", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1320,7 +1315,7 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1350,7 +1345,7 @@ func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1385,7 +1380,7 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.V
|
||||
func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
|
||||
t.Helper()
|
||||
mw := NewMiddleware(log.New(), validator, nil)
|
||||
require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false))
|
||||
require.NoError(t, mw.AddDomain("svc.example", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1"}))
|
||||
return mw
|
||||
}
|
||||
|
||||
@@ -235,8 +235,8 @@ func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
|
||||
}
|
||||
mw := NewMiddleware(log.New(), validator, nil)
|
||||
|
||||
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
|
||||
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
|
||||
require.NoError(t, mw.AddDomain("svc-a.example", DomainSettings{AccountID: "acct-a", ServiceID: "svc-a"}))
|
||||
require.NoError(t, mw.AddDomain("svc-b.example", DomainSettings{AccountID: "acct-b", ServiceID: "svc-b"}))
|
||||
|
||||
// The fast-path requires the inbound-listener marker on the context.
|
||||
// The peerstore lookup itself is account-agnostic at this level
|
||||
@@ -299,7 +299,7 @@ func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *te
|
||||
|
||||
func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
|
||||
mw := NewMiddleware(log.New(), nil, nil)
|
||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
|
||||
require.NoError(t, mw.AddDomain("private.svc", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
|
||||
|
||||
called := false
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -328,7 +328,7 @@ func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
|
||||
},
|
||||
}
|
||||
mw := NewMiddleware(log.New(), validator, nil)
|
||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
|
||||
require.NoError(t, mw.AddDomain("private.svc", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
|
||||
|
||||
called := false
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -50,6 +50,59 @@ const (
|
||||
CrowdSecObserve CrowdSecMode = "observe"
|
||||
)
|
||||
|
||||
// AppSecMode is the per-service CrowdSec AppSec (WAF) enforcement mode.
|
||||
type AppSecMode string
|
||||
|
||||
const (
|
||||
// AppSecOff disables request inspection.
|
||||
AppSecOff AppSecMode = ""
|
||||
// AppSecEnforce blocks requests the engine flags, and fails closed when the
|
||||
// engine is unreachable.
|
||||
AppSecEnforce AppSecMode = "enforce"
|
||||
// AppSecObserve records the verdict without blocking.
|
||||
AppSecObserve AppSecMode = "observe"
|
||||
)
|
||||
|
||||
// ParseAppSecMode maps a wire value to an AppSecMode. Unrecognized values map
|
||||
// to AppSecOff so a typo never turns inspection into an unintended block.
|
||||
func ParseAppSecMode(s string) AppSecMode {
|
||||
switch AppSecMode(s) {
|
||||
case AppSecEnforce:
|
||||
return AppSecEnforce
|
||||
case AppSecObserve:
|
||||
return AppSecObserve
|
||||
default:
|
||||
return AppSecOff
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether the mode asks for request inspection.
|
||||
func (m AppSecMode) Enabled() bool {
|
||||
return m == AppSecEnforce || m == AppSecObserve
|
||||
}
|
||||
|
||||
// AllowMatch controls how the configured allowlists (CIDR, country) combine.
|
||||
// Blocklists are always a separate hard-deny gate and are unaffected by it.
|
||||
type AllowMatch string
|
||||
|
||||
const (
|
||||
// AllowMatchAll requires the address to match every configured allowlist
|
||||
// (AND). This is the default and preserves the historical behavior.
|
||||
AllowMatchAll AllowMatch = "all"
|
||||
// AllowMatchAny requires the address to match at least one configured
|
||||
// allowlist (OR), e.g. "allowed country OR allowed CIDR".
|
||||
AllowMatchAny AllowMatch = "any"
|
||||
)
|
||||
|
||||
// normalizeAllowMatch maps unknown or empty values to the restrictive default
|
||||
// (AllowMatchAll) so an unrecognized mode never loosens access.
|
||||
func normalizeAllowMatch(m AllowMatch) AllowMatch {
|
||||
if m == AllowMatchAny {
|
||||
return AllowMatchAny
|
||||
}
|
||||
return AllowMatchAll
|
||||
}
|
||||
|
||||
// Filter evaluates IP restrictions. CIDR checks are performed first
|
||||
// (cheap), followed by country lookups (more expensive) only when needed.
|
||||
type Filter struct {
|
||||
@@ -59,6 +112,9 @@ type Filter struct {
|
||||
BlockedCountries []string
|
||||
CrowdSec CrowdSecChecker
|
||||
CrowdSecMode CrowdSecMode
|
||||
// AllowMatch controls how the allowlists combine (AND vs OR). Empty means
|
||||
// AllowMatchAll.
|
||||
AllowMatch AllowMatch
|
||||
}
|
||||
|
||||
// FilterConfig holds the raw configuration for building a Filter.
|
||||
@@ -69,6 +125,7 @@ type FilterConfig struct {
|
||||
BlockedCountries []string
|
||||
CrowdSec CrowdSecChecker
|
||||
CrowdSecMode CrowdSecMode
|
||||
AllowMatch AllowMatch
|
||||
Logger *log.Entry
|
||||
}
|
||||
|
||||
@@ -89,6 +146,7 @@ func ParseFilter(cfg FilterConfig) *Filter {
|
||||
f := &Filter{
|
||||
AllowedCountries: normalizeCountryCodes(cfg.AllowedCountries),
|
||||
BlockedCountries: normalizeCountryCodes(cfg.BlockedCountries),
|
||||
AllowMatch: normalizeAllowMatch(cfg.AllowMatch),
|
||||
}
|
||||
if hasCS {
|
||||
f.CrowdSec = cfg.CrowdSec
|
||||
@@ -146,6 +204,13 @@ const (
|
||||
// DenyCrowdSecUnavailable indicates enforce mode but the bouncer has not
|
||||
// completed its initial sync.
|
||||
DenyCrowdSecUnavailable
|
||||
// DenyAppSecBan indicates a CrowdSec AppSec "ban" remediation.
|
||||
DenyAppSecBan
|
||||
// DenyAppSecCaptcha indicates a CrowdSec AppSec "captcha" remediation.
|
||||
DenyAppSecCaptcha
|
||||
// DenyAppSecUnavailable indicates enforce mode but the AppSec engine could
|
||||
// not produce a verdict (unreachable, timed out, or it rejected the call).
|
||||
DenyAppSecUnavailable
|
||||
)
|
||||
|
||||
// String returns the deny reason string matching the HTTP auth mechanism names.
|
||||
@@ -167,11 +232,27 @@ func (v Verdict) String() string {
|
||||
return "crowdsec_throttle"
|
||||
case DenyCrowdSecUnavailable:
|
||||
return "crowdsec_unavailable"
|
||||
case DenyAppSecBan:
|
||||
return "appsec_ban"
|
||||
case DenyAppSecCaptcha:
|
||||
return "appsec_captcha"
|
||||
case DenyAppSecUnavailable:
|
||||
return "appsec_unavailable"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// IsAppSec returns true when the verdict originates from an AppSec inspection.
|
||||
func (v Verdict) IsAppSec() bool {
|
||||
switch v {
|
||||
case DenyAppSecBan, DenyAppSecCaptcha, DenyAppSecUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsCrowdSec returns true when the verdict originates from a CrowdSec check.
|
||||
func (v Verdict) IsCrowdSec() bool {
|
||||
switch v {
|
||||
@@ -216,6 +297,10 @@ func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
// IPv4 CIDR rules match regardless of how the address was received.
|
||||
addr = addr.Unmap()
|
||||
|
||||
if f.AllowMatch == AllowMatchAny {
|
||||
return f.checkAny(addr, geo)
|
||||
}
|
||||
|
||||
if v := f.checkCIDR(addr); v != Allow {
|
||||
return v
|
||||
}
|
||||
@@ -225,6 +310,77 @@ func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
return f.checkCrowdSec(addr)
|
||||
}
|
||||
|
||||
// checkAny evaluates the filter with OR semantics across allowlists: the
|
||||
// address is admitted if it matches any configured allowlist (CIDR or country).
|
||||
// Blocklists remain a hard-deny gate evaluated first and are independent of the
|
||||
// allow-combine mode, so a blocklist match (or unverifiable country block) still
|
||||
// denies. CrowdSec runs last, as in the default path.
|
||||
func (f *Filter) checkAny(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
if v := f.checkBlocked(addr, geo); v != Allow {
|
||||
return v
|
||||
}
|
||||
if v := f.checkAllowedAny(addr, geo); v != Allow {
|
||||
return v
|
||||
}
|
||||
return f.checkCrowdSec(addr)
|
||||
}
|
||||
|
||||
// checkBlocked is the hard-deny gate: it denies on any blocklist match,
|
||||
// regardless of the allow-combine mode. A configured country blocklist with an
|
||||
// unavailable geo lookup fails closed.
|
||||
func (f *Filter) checkBlocked(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
for _, prefix := range f.BlockedCIDRs {
|
||||
if prefix.Contains(addr) {
|
||||
return DenyCIDR
|
||||
}
|
||||
}
|
||||
|
||||
if len(f.BlockedCountries) == 0 {
|
||||
return Allow
|
||||
}
|
||||
if geo == nil || !geo.Available() {
|
||||
return DenyGeoUnavailable
|
||||
}
|
||||
if code := geo.LookupAddr(addr).CountryCode; code != "" && slices.Contains(f.BlockedCountries, code) {
|
||||
return DenyCountry
|
||||
}
|
||||
return Allow
|
||||
}
|
||||
|
||||
// checkAllowedAny admits the address if it matches any active allowlist. The
|
||||
// CIDR allowlist is evaluated first so a match admits without a geo lookup;
|
||||
// only when it does not match is the country allowlist consulted, where an
|
||||
// unavailable geo lookup fails closed.
|
||||
func (f *Filter) checkAllowedAny(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
cidrActive := len(f.AllowedCIDRs) > 0
|
||||
countryActive := len(f.AllowedCountries) > 0
|
||||
if !cidrActive && !countryActive {
|
||||
return Allow
|
||||
}
|
||||
|
||||
if cidrActive {
|
||||
for _, prefix := range f.AllowedCIDRs {
|
||||
if prefix.Contains(addr) {
|
||||
return Allow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if countryActive {
|
||||
if geo == nil || !geo.Available() {
|
||||
return DenyGeoUnavailable
|
||||
}
|
||||
if code := geo.LookupAddr(addr).CountryCode; code != "" && slices.Contains(f.AllowedCountries, code) {
|
||||
return Allow
|
||||
}
|
||||
}
|
||||
|
||||
if cidrActive {
|
||||
return DenyCIDR
|
||||
}
|
||||
return DenyCountry
|
||||
}
|
||||
|
||||
func (f *Filter) checkCIDR(addr netip.Addr) Verdict {
|
||||
if len(f.AllowedCIDRs) > 0 {
|
||||
allowed := false
|
||||
|
||||
@@ -150,6 +150,187 @@ func TestFilter_Check_CIDRAllowThenCountryBlock(t *testing.T) {
|
||||
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), geo), "CIDR denied before country check")
|
||||
}
|
||||
|
||||
// TestFilter_Check_CrossCategoryAllowlistsAreAND documents the current
|
||||
// behavior: when both a CIDR allowlist and a country allowlist are set, a
|
||||
// request must satisfy BOTH to be allowed (AND across categories). There is no
|
||||
// way today to express "allow if in allowed country OR in allowed CIDR", e.g.
|
||||
// "allow all US traffic plus our office IP abroad". This is the gap an
|
||||
// any/all allow-combine mode would close; the cases marked "GAP" are the ones
|
||||
// that would flip to Allow under an "any" mode.
|
||||
func TestFilter_Check_CrossCategoryAllowlistsAreAND(t *testing.T) {
|
||||
officeAbroad := "203.0.113.7" // in allowed CIDR, but country not in allowlist
|
||||
usOutsideOffice := "1.1.1.1" // allowed country, but not in allowed CIDR
|
||||
usOffice := "203.0.113.8" // both
|
||||
neither := "198.51.100.1" // neither
|
||||
|
||||
geo := newMockGeo(map[string]string{
|
||||
officeAbroad: "DE",
|
||||
usOutsideOffice: "US",
|
||||
usOffice: "US",
|
||||
neither: "CN",
|
||||
})
|
||||
f := ParseFilter(FilterConfig{
|
||||
AllowedCIDRs: []string{"203.0.113.0/24"},
|
||||
AllowedCountries: []string{"US"},
|
||||
})
|
||||
|
||||
assert.Equal(t, Allow, f.Check(netip.MustParseAddr(usOffice), geo), "in allowed CIDR and allowed country")
|
||||
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr(officeAbroad), geo), "GAP: in allowed CIDR but country not allowed; any-mode should Allow")
|
||||
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr(usOutsideOffice), geo), "GAP: allowed country but not in allowed CIDR; any-mode should Allow")
|
||||
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr(neither), geo), "neither: denied under both modes")
|
||||
}
|
||||
|
||||
// TestFilter_Check_CrossCategoryBlockAndAllow locks the current (all/AND)
|
||||
// cross-category semantics that the evaluator must preserve: a blocklist match
|
||||
// in any category denies regardless of allowlists, and blocklists across
|
||||
// categories are effectively OR (a match in either denies).
|
||||
func TestFilter_Check_CrossCategoryBlockAndAllow(t *testing.T) {
|
||||
geo := newMockGeo(map[string]string{
|
||||
"1.1.1.1": "US",
|
||||
"10.1.2.3": "US",
|
||||
"2.2.2.2": "CN",
|
||||
"3.3.3.3": "US",
|
||||
})
|
||||
|
||||
t.Run("country allowlist with CIDR blocklist", func(t *testing.T) {
|
||||
f := ParseFilter(FilterConfig{
|
||||
AllowedCountries: []string{"US"},
|
||||
BlockedCIDRs: []string{"10.1.0.0/16"},
|
||||
})
|
||||
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US and not in blocked CIDR")
|
||||
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "US but in blocked CIDR, block wins")
|
||||
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "not in allowed country")
|
||||
})
|
||||
|
||||
t.Run("blocklists across categories are OR", func(t *testing.T) {
|
||||
f := ParseFilter(FilterConfig{
|
||||
BlockedCIDRs: []string{"10.1.0.0/16"},
|
||||
BlockedCountries: []string{"CN"},
|
||||
})
|
||||
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "in blocked CIDR")
|
||||
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "in blocked country")
|
||||
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "in neither blocklist")
|
||||
})
|
||||
}
|
||||
|
||||
// TestFilter_Check_AllowCIDRPlusAllowCountryDeniesGeolessLAN documents a trap
|
||||
// with all/AND mode: pairing an allowed CIDR (a private LAN) with an allowed
|
||||
// country denies the LAN source, because a private IP has no country in the
|
||||
// geo DB and an active country allowlist denies unknown countries. Under an
|
||||
// "any" mode the CIDR match alone would admit it. This is the strongest reason
|
||||
// allow-CIDR + allow-country usually wants OR, not AND.
|
||||
func TestFilter_Check_AllowCIDRPlusAllowCountryDeniesGeolessLAN(t *testing.T) {
|
||||
geo := newMockGeo(map[string]string{}) // no entries: every lookup is unknown country
|
||||
f := ParseFilter(FilterConfig{
|
||||
AllowedCIDRs: []string{"192.168.50.0/24"},
|
||||
AllowedCountries: []string{"US"},
|
||||
})
|
||||
|
||||
got := f.Check(netip.MustParseAddr("192.168.50.5"), geo)
|
||||
assert.Equal(t, DenyCountry, got, "GAP: LAN source in allowed CIDR is denied by the country allowlist; any-mode should Allow")
|
||||
}
|
||||
|
||||
func TestFilter_Check_AllowMatchAny(t *testing.T) {
|
||||
bannedIP := "203.0.113.9"
|
||||
geo := newMockGeo(map[string]string{
|
||||
"1.1.1.1": "US", // allowed country, outside allowed CIDR
|
||||
"203.0.113.7": "DE", // allowed CIDR, non-allowed country
|
||||
"203.0.113.8": "US", // both
|
||||
bannedIP: "US", // allowed CIDR, but CrowdSec-banned
|
||||
"198.51.100.1": "CN", // neither
|
||||
"2.2.2.2": "CN", // blocked country, but in allowed CIDR
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config FilterConfig
|
||||
addr string
|
||||
geo GeoResolver
|
||||
want Verdict
|
||||
}{
|
||||
{
|
||||
name: "in allowed CIDR only",
|
||||
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
||||
addr: "203.0.113.7", geo: geo, want: Allow,
|
||||
},
|
||||
{
|
||||
name: "in allowed country only",
|
||||
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
||||
addr: "1.1.1.1", geo: geo, want: Allow,
|
||||
},
|
||||
{
|
||||
name: "in both",
|
||||
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
||||
addr: "203.0.113.8", geo: geo, want: Allow,
|
||||
},
|
||||
{
|
||||
name: "in neither",
|
||||
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
||||
addr: "198.51.100.1", geo: geo, want: DenyCIDR,
|
||||
},
|
||||
{
|
||||
name: "geoless LAN admitted via CIDR (the #597 trap, fixed)",
|
||||
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"192.168.50.0/24"}, AllowedCountries: []string{"US"}},
|
||||
addr: "192.168.50.5", geo: newMockGeo(map[string]string{}), want: Allow,
|
||||
},
|
||||
{
|
||||
name: "CIDR match short-circuits geo when geo unavailable",
|
||||
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
||||
addr: "203.0.113.7", geo: &unavailableGeo{}, want: Allow,
|
||||
},
|
||||
{
|
||||
name: "geo unavailable fails closed when CIDR does not match",
|
||||
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
||||
addr: "1.1.1.1", geo: &unavailableGeo{}, want: DenyGeoUnavailable,
|
||||
},
|
||||
{
|
||||
name: "block gate wins over allowed CIDR (blocked country)",
|
||||
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"0.0.0.0/0"}, BlockedCountries: []string{"CN"}},
|
||||
addr: "2.2.2.2", geo: geo, want: DenyCountry,
|
||||
},
|
||||
{
|
||||
name: "block gate wins over allowed country (blocked CIDR)",
|
||||
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCountries: []string{"US"}, BlockedCIDRs: []string{"203.0.113.0/24"}},
|
||||
addr: "203.0.113.8", geo: geo, want: DenyCIDR,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := ParseFilter(tc.config)
|
||||
assert.Equal(t, tc.want, f.Check(netip.MustParseAddr(tc.addr), tc.geo))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilter_Check_AllowMatchAny_CrowdSecStillRuns(t *testing.T) {
|
||||
bannedIP := "203.0.113.9"
|
||||
cs := &mockCrowdSec{decisions: map[string]*CrowdSecDecision{bannedIP: {Type: DecisionBan}}, ready: true}
|
||||
geo := newMockGeo(map[string]string{bannedIP: "US", "203.0.113.7": "US"})
|
||||
|
||||
f := ParseFilter(FilterConfig{
|
||||
AllowMatch: AllowMatchAny,
|
||||
AllowedCIDRs: []string{"203.0.113.0/24"},
|
||||
CrowdSec: cs,
|
||||
CrowdSecMode: CrowdSecEnforce,
|
||||
})
|
||||
assert.Equal(t, DenyCrowdSecBan, f.Check(netip.MustParseAddr(bannedIP), geo), "CrowdSec ban denies even when allowlist admits")
|
||||
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("203.0.113.7"), geo), "clean IP in allowed CIDR is allowed")
|
||||
}
|
||||
|
||||
func TestFilter_Check_UnknownAllowMatchDefaultsToAll(t *testing.T) {
|
||||
// An unrecognized allow-combine mode must fall back to the restrictive
|
||||
// AND default, never loosen access.
|
||||
geo := newMockGeo(map[string]string{"203.0.113.7": "DE"})
|
||||
f := ParseFilter(FilterConfig{
|
||||
AllowMatch: AllowMatch("bogus"),
|
||||
AllowedCIDRs: []string{"203.0.113.0/24"},
|
||||
AllowedCountries: []string{"US"},
|
||||
})
|
||||
assert.Equal(t, AllowMatchAll, f.AllowMatch, "unknown mode normalizes to all")
|
||||
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("203.0.113.7"), geo), "AND semantics: in CIDR but wrong country denied")
|
||||
}
|
||||
|
||||
func TestParseFilter_Empty(t *testing.T) {
|
||||
f := ParseFilter(FilterConfig{})
|
||||
assert.Nil(t, f)
|
||||
|
||||
@@ -126,6 +126,15 @@ type Config struct {
|
||||
// CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables
|
||||
// CrowdSec.
|
||||
CrowdSecAPIKey string
|
||||
// CrowdSecAppSecURL is the CrowdSec AppSec (WAF) endpoint. Empty disables
|
||||
// HTTP request inspection.
|
||||
CrowdSecAppSecURL string
|
||||
// CrowdSecAppSecTimeout bounds a single AppSec inspection call. Zero falls
|
||||
// back to the internal default.
|
||||
CrowdSecAppSecTimeout time.Duration
|
||||
// CrowdSecAppSecMaxBodyBytes caps the request body mirrored to AppSec.
|
||||
// Zero falls back to the internal default; negative forwards no body.
|
||||
CrowdSecAppSecMaxBodyBytes int64
|
||||
}
|
||||
|
||||
// New builds a Server from cfg without performing any I/O. No goroutines
|
||||
@@ -135,42 +144,45 @@ type Config struct {
|
||||
// directly) byte-for-byte equivalent.
|
||||
func New(ctx context.Context, cfg Config) *Server {
|
||||
return &Server{
|
||||
ctx: ctx,
|
||||
ListenAddr: cfg.ListenAddr,
|
||||
ID: cfg.ID,
|
||||
Logger: cfg.Logger,
|
||||
Version: cfg.Version,
|
||||
ProxyURL: cfg.ProxyURL,
|
||||
ManagementAddress: cfg.ManagementAddress,
|
||||
ProxyToken: cfg.ProxyToken,
|
||||
CertificateDirectory: cfg.CertificateDirectory,
|
||||
CertificateFile: cfg.CertificateFile,
|
||||
CertificateKeyFile: cfg.CertificateKeyFile,
|
||||
GenerateACMECertificates: cfg.GenerateACMECertificates,
|
||||
ACMEChallengeAddress: cfg.ACMEChallengeAddress,
|
||||
ACMEDirectory: cfg.ACMEDirectory,
|
||||
ACMEEABKID: cfg.ACMEEABKID,
|
||||
ACMEEABHMACKey: cfg.ACMEEABHMACKey,
|
||||
ACMEChallengeType: cfg.ACMEChallengeType,
|
||||
CertLockMethod: cfg.CertLockMethod,
|
||||
WildcardCertDir: cfg.WildcardCertDir,
|
||||
DebugEndpointEnabled: cfg.DebugEndpointEnabled,
|
||||
DebugEndpointAddress: cfg.DebugEndpointAddress,
|
||||
HealthAddress: cfg.HealthAddr,
|
||||
ForwardedProto: cfg.ForwardedProto,
|
||||
TrustedProxies: cfg.TrustedProxies,
|
||||
WireguardPort: cfg.WireguardPort,
|
||||
ProxyProtocol: cfg.ProxyProtocol,
|
||||
PreSharedKey: cfg.PreSharedKey,
|
||||
Performance: cfg.Performance,
|
||||
SupportsCustomPorts: cfg.SupportsCustomPorts,
|
||||
RequireSubdomain: cfg.RequireSubdomain,
|
||||
Private: cfg.Private,
|
||||
MaxDialTimeout: cfg.MaxDialTimeout,
|
||||
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
|
||||
MappingBatchWatchdog: cfg.MappingBatchWatchdog,
|
||||
GeoDataDir: cfg.GeoDataDir,
|
||||
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
|
||||
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
|
||||
ctx: ctx,
|
||||
ListenAddr: cfg.ListenAddr,
|
||||
ID: cfg.ID,
|
||||
Logger: cfg.Logger,
|
||||
Version: cfg.Version,
|
||||
ProxyURL: cfg.ProxyURL,
|
||||
ManagementAddress: cfg.ManagementAddress,
|
||||
ProxyToken: cfg.ProxyToken,
|
||||
CertificateDirectory: cfg.CertificateDirectory,
|
||||
CertificateFile: cfg.CertificateFile,
|
||||
CertificateKeyFile: cfg.CertificateKeyFile,
|
||||
GenerateACMECertificates: cfg.GenerateACMECertificates,
|
||||
ACMEChallengeAddress: cfg.ACMEChallengeAddress,
|
||||
ACMEDirectory: cfg.ACMEDirectory,
|
||||
ACMEEABKID: cfg.ACMEEABKID,
|
||||
ACMEEABHMACKey: cfg.ACMEEABHMACKey,
|
||||
ACMEChallengeType: cfg.ACMEChallengeType,
|
||||
CertLockMethod: cfg.CertLockMethod,
|
||||
WildcardCertDir: cfg.WildcardCertDir,
|
||||
DebugEndpointEnabled: cfg.DebugEndpointEnabled,
|
||||
DebugEndpointAddress: cfg.DebugEndpointAddress,
|
||||
HealthAddress: cfg.HealthAddr,
|
||||
ForwardedProto: cfg.ForwardedProto,
|
||||
TrustedProxies: cfg.TrustedProxies,
|
||||
WireguardPort: cfg.WireguardPort,
|
||||
ProxyProtocol: cfg.ProxyProtocol,
|
||||
PreSharedKey: cfg.PreSharedKey,
|
||||
Performance: cfg.Performance,
|
||||
SupportsCustomPorts: cfg.SupportsCustomPorts,
|
||||
RequireSubdomain: cfg.RequireSubdomain,
|
||||
Private: cfg.Private,
|
||||
MaxDialTimeout: cfg.MaxDialTimeout,
|
||||
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
|
||||
MappingBatchWatchdog: cfg.MappingBatchWatchdog,
|
||||
GeoDataDir: cfg.GeoDataDir,
|
||||
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
|
||||
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
|
||||
CrowdSecAppSecURL: cfg.CrowdSecAppSecURL,
|
||||
CrowdSecAppSecTimeout: cfg.CrowdSecAppSecTimeout,
|
||||
CrowdSecAppSecMaxBodyBytes: cfg.CrowdSecAppSecMaxBodyBytes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,10 @@ func (m *testProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testProxyManager) ClusterSupportsAppSec(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
@@ -562,16 +566,11 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T
|
||||
addMappingCalls.Add(1)
|
||||
|
||||
// Apply to real auth middleware (idempotent)
|
||||
err := authMw.AddDomain(
|
||||
mapping.GetDomain(),
|
||||
nil,
|
||||
"",
|
||||
0,
|
||||
proxytypes.AccountID(mapping.GetAccountId()),
|
||||
proxytypes.ServiceID(mapping.GetId()),
|
||||
nil,
|
||||
mapping.GetPrivate(),
|
||||
)
|
||||
err := authMw.AddDomain(mapping.GetDomain(), auth.DomainSettings{
|
||||
AccountID: proxytypes.AccountID(mapping.GetAccountId()),
|
||||
ServiceID: proxytypes.ServiceID(mapping.GetId()),
|
||||
Private: mapping.GetPrivate(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Apply to real proxy (idempotent)
|
||||
|
||||
@@ -45,6 +45,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/embed"
|
||||
"github.com/netbirdio/netbird/proxy/internal/accesslog"
|
||||
"github.com/netbirdio/netbird/proxy/internal/acme"
|
||||
"github.com/netbirdio/netbird/proxy/internal/appsec"
|
||||
"github.com/netbirdio/netbird/proxy/internal/auth"
|
||||
"github.com/netbirdio/netbird/proxy/internal/certwatch"
|
||||
"github.com/netbirdio/netbird/proxy/internal/conntrack"
|
||||
@@ -126,6 +127,10 @@ type Server struct {
|
||||
crowdsecMu sync.Mutex
|
||||
crowdsecServices map[types.ServiceID]bool
|
||||
|
||||
// appsecClient is the shared CrowdSec AppSec client, nil when no AppSec
|
||||
// endpoint is configured. Stateless, so it needs no per-service lifecycle.
|
||||
appsecClient *appsec.Client
|
||||
|
||||
// routerReady is closed once mainRouter is fully initialized.
|
||||
// The mapping worker waits on this before processing updates.
|
||||
routerReady chan struct{}
|
||||
@@ -238,6 +243,17 @@ type Server struct {
|
||||
CrowdSecAPIURL string
|
||||
// CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables CrowdSec.
|
||||
CrowdSecAPIKey string
|
||||
// CrowdSecAppSecURL is the CrowdSec AppSec (WAF) endpoint, e.g.
|
||||
// http://127.0.0.1:7422/. Empty disables request inspection. Requires
|
||||
// CrowdSecAPIKey, which the AppSec component validates against LAPI.
|
||||
CrowdSecAppSecURL string
|
||||
// CrowdSecAppSecTimeout bounds a single AppSec inspection call.
|
||||
// Zero means appsec.DefaultTimeout.
|
||||
CrowdSecAppSecTimeout time.Duration
|
||||
// CrowdSecAppSecMaxBodyBytes caps the request body mirrored to the AppSec
|
||||
// engine. Zero means appsec.DefaultMaxBodyBytes; negative disables body
|
||||
// forwarding, leaving header and URI inspection.
|
||||
CrowdSecAppSecMaxBodyBytes int64
|
||||
// MaxSessionIdleTimeout caps the per-service session idle timeout.
|
||||
// Zero means no cap (the proxy honors whatever management sends).
|
||||
// Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments.
|
||||
@@ -411,6 +427,10 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
}()
|
||||
|
||||
s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo)
|
||||
// Must follow the auth middleware: the AppSec client is installed on it.
|
||||
if err := s.initAppSec(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies)
|
||||
|
||||
s.startDebugEndpoint()
|
||||
@@ -1274,6 +1294,7 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
|
||||
|
||||
func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
|
||||
supportsCrowdSec := s.crowdsecRegistry.Available()
|
||||
supportsAppSec := s.appsecClient != nil
|
||||
privateCapability := s.Private
|
||||
// Always true: this build enforces ProxyMapping.private via the auth middleware.
|
||||
supportsPrivateService := true
|
||||
@@ -1281,6 +1302,7 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
|
||||
SupportsCustomPorts: &s.SupportsCustomPorts,
|
||||
RequireSubdomain: &s.RequireSubdomain,
|
||||
SupportsCrowdsec: &supportsCrowdSec,
|
||||
SupportsAppsec: &supportsAppSec,
|
||||
Private: &privateCapability,
|
||||
SupportsPrivateService: &supportsPrivateService,
|
||||
}
|
||||
@@ -1904,10 +1926,51 @@ func (s *Server) parseRestrictions(mapping *proto.ProxyMapping) *restrict.Filter
|
||||
BlockedCountries: r.GetBlockedCountries(),
|
||||
CrowdSec: checker,
|
||||
CrowdSecMode: csMode,
|
||||
AllowMatch: restrict.AllowMatch(r.GetAllowMatch()),
|
||||
Logger: log.NewEntry(s.Logger),
|
||||
})
|
||||
}
|
||||
|
||||
// initAppSec builds the shared AppSec client when an endpoint is configured.
|
||||
// A configured-but-invalid endpoint is a startup error rather than a silent
|
||||
// downgrade: services asking for enforce would otherwise fail closed on every
|
||||
// request with no indication why.
|
||||
func (s *Server) initAppSec() error {
|
||||
if s.CrowdSecAppSecURL == "" {
|
||||
return nil
|
||||
}
|
||||
if s.CrowdSecAPIKey == "" {
|
||||
return errors.New("crowdsec appsec url is set but the crowdsec api key is empty")
|
||||
}
|
||||
|
||||
client, err := appsec.New(appsec.Config{
|
||||
URL: s.CrowdSecAppSecURL,
|
||||
APIKey: s.CrowdSecAPIKey,
|
||||
Timeout: s.CrowdSecAppSecTimeout,
|
||||
MaxBodyBytes: s.CrowdSecAppSecMaxBodyBytes,
|
||||
Logger: log.NewEntry(s.Logger),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("init crowdsec appsec: %w", err)
|
||||
}
|
||||
|
||||
s.appsecClient = client
|
||||
s.auth.SetAppSec(client)
|
||||
s.Logger.Infof("CrowdSec AppSec inspection available at %s", s.CrowdSecAppSecURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// appSecMode resolves the per-service AppSec mode. A service asking for
|
||||
// inspection on a proxy with no AppSec endpoint keeps its mode so the auth
|
||||
// middleware fails closed for enforce, mirroring the CrowdSec behavior.
|
||||
func (s *Server) appSecMode(mapping *proto.ProxyMapping) restrict.AppSecMode {
|
||||
mode := restrict.ParseAppSecMode(mapping.GetAccessRestrictions().GetAppsecMode())
|
||||
if mode.Enabled() && s.appsecClient == nil {
|
||||
s.Logger.Warnf("service %s requests AppSec mode %q but proxy has no AppSec endpoint configured", mapping.GetId(), mode)
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// releaseCrowdSec releases the CrowdSec bouncer reference for the given
|
||||
// service if it had one.
|
||||
func (s *Server) releaseCrowdSec(svcID types.ServiceID) {
|
||||
@@ -2074,7 +2137,17 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
|
||||
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
|
||||
|
||||
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
|
||||
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil {
|
||||
settings := auth.DomainSettings{
|
||||
Schemes: schemes,
|
||||
SessionPublicKey: mapping.GetAuth().GetSessionKey(),
|
||||
SessionExpiration: maxSessionAge,
|
||||
AccountID: accountID,
|
||||
ServiceID: svcID,
|
||||
IPRestrictions: ipRestrictions,
|
||||
Private: mapping.GetPrivate(),
|
||||
AppSecMode: s.appSecMode(mapping),
|
||||
}
|
||||
if err := s.auth.AddDomain(mapping.GetDomain(), settings); err != nil {
|
||||
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
|
||||
}
|
||||
m := s.protoToMapping(ctx, mapping)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# FreeBSD Port Diff Generator for NetBird
|
||||
#
|
||||
# This script generates the diff file required for submitting a FreeBSD port update.
|
||||
# It works on macOS, Linux, and FreeBSD by fetching files from FreeBSD cgit and
|
||||
# computing checksums from the Go module proxy.
|
||||
# It works on macOS, Linux, and FreeBSD by fetching files from the FreeBSD ports
|
||||
# GitHub mirror and computing checksums from the Go module proxy.
|
||||
#
|
||||
# Usage: ./freebsd-port-diff.sh [new_version]
|
||||
# Example: ./freebsd-port-diff.sh 0.60.7
|
||||
@@ -14,7 +14,7 @@
|
||||
set -e
|
||||
|
||||
GITHUB_REPO="netbirdio/netbird"
|
||||
PORTS_CGIT_BASE="https://cgit.freebsd.org/ports/plain/security/netbird"
|
||||
PORTS_MIRROR_BASE="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird"
|
||||
GO_PROXY="https://proxy.golang.org/github.com/netbirdio/netbird/@v"
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-.}"
|
||||
AWK_FIRST_FIELD='{print $1}'
|
||||
@@ -30,10 +30,17 @@ fetch_all_tags() {
|
||||
|
||||
fetch_current_ports_version() {
|
||||
echo "Fetching current version from FreeBSD ports..." >&2
|
||||
curl -sL "${PORTS_CGIT_BASE}/Makefile" 2>/dev/null | \
|
||||
local makefile version
|
||||
makefile=$(fetch_ports_file "Makefile") || return 1
|
||||
version=$(echo "$makefile" | \
|
||||
grep -E "^DISTVERSION=" | \
|
||||
sed 's/DISTVERSION=[[:space:]]*//' | \
|
||||
tr -d '\t '
|
||||
tr -d '\t ')
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "Error: Could not extract DISTVERSION from ports Makefile" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$version"
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -45,7 +52,16 @@ fetch_latest_github_release() {
|
||||
|
||||
fetch_ports_file() {
|
||||
local filename="$1"
|
||||
curl -sL "${PORTS_CGIT_BASE}/${filename}" 2>/dev/null
|
||||
local content
|
||||
if ! content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "${PORTS_MIRROR_BASE}/${filename}" 2>/dev/null); then
|
||||
echo "Error: Could not fetch ${filename} from ${PORTS_MIRROR_BASE}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$content" == \<* ]]; then
|
||||
echo "Error: Received HTML instead of ${filename} from ${PORTS_MIRROR_BASE}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$content"
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -9,18 +9,22 @@
|
||||
# Example: ./freebsd-port-issue-body.sh 0.56.0 0.59.1
|
||||
#
|
||||
# If no versions are provided, the script will:
|
||||
# - Fetch OLD version from FreeBSD ports cgit (current version in ports tree)
|
||||
# - Fetch OLD version from the FreeBSD ports GitHub mirror (current version in ports tree)
|
||||
# - Fetch NEW version from latest NetBird GitHub release tag
|
||||
|
||||
set -e
|
||||
|
||||
GITHUB_REPO="netbirdio/netbird"
|
||||
PORTS_CGIT_URL="https://cgit.freebsd.org/ports/plain/security/netbird/Makefile"
|
||||
PORTS_MAKEFILE_URL="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird/Makefile"
|
||||
|
||||
fetch_current_ports_version() {
|
||||
echo "Fetching current version from FreeBSD ports..." >&2
|
||||
local makefile_content
|
||||
makefile_content=$(curl -sL "$PORTS_CGIT_URL" 2>/dev/null)
|
||||
makefile_content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "$PORTS_MAKEFILE_URL" 2>/dev/null) || makefile_content=""
|
||||
if [[ "$makefile_content" == \<* ]]; then
|
||||
echo "Error: Received HTML instead of Makefile from ${PORTS_MAKEFILE_URL}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$makefile_content" ]]; then
|
||||
echo "Error: Could not fetch Makefile from FreeBSD ports" >&2
|
||||
return 1
|
||||
|
||||
@@ -187,16 +187,16 @@ func (c *GrpcClient) ready() bool {
|
||||
// Sync wraps the real client's Sync endpoint call and takes care of retries and encryption/decryption of messages
|
||||
// Blocking request. The result will be sent via msgHandler callback function
|
||||
func (c *GrpcClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error {
|
||||
return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error {
|
||||
return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler)
|
||||
return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error {
|
||||
return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler, backOff)
|
||||
})
|
||||
}
|
||||
|
||||
// Job wraps the real client's Job endpoint call and takes care of retries and encryption/decryption of messages
|
||||
// Blocking request. The result will be sent via msgHandler callback function
|
||||
func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error {
|
||||
return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error {
|
||||
return c.handleJobStream(ctx, serverPubKey, msgHandler)
|
||||
return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error {
|
||||
return c.handleJobStream(ctx, serverPubKey, msgHandler, backOff)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequ
|
||||
// It takes care of retries, connection readiness, and fetching server public key.
|
||||
func (c *GrpcClient) withMgmtStream(
|
||||
ctx context.Context,
|
||||
handler func(ctx context.Context, serverPubKey wgtypes.Key) error,
|
||||
handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error,
|
||||
) error {
|
||||
backOff := defaultBackoff(ctx)
|
||||
operation := func() error {
|
||||
@@ -224,7 +224,7 @@ func (c *GrpcClient) withMgmtStream(
|
||||
return err
|
||||
}
|
||||
|
||||
return handler(ctx, *serverPubKey)
|
||||
return handler(ctx, *serverPubKey, backOff)
|
||||
}
|
||||
|
||||
err := backoff.Retry(operation, backOff)
|
||||
@@ -239,6 +239,7 @@ func (c *GrpcClient) handleJobStream(
|
||||
ctx context.Context,
|
||||
serverPubKey wgtypes.Key,
|
||||
msgHandler func(msg *proto.JobRequest) *proto.JobResponse,
|
||||
backOff backoff.BackOff,
|
||||
) error {
|
||||
ctx, cancelStream := context.WithCancel(ctx)
|
||||
defer cancelStream()
|
||||
@@ -256,6 +257,19 @@ func (c *GrpcClient) handleJobStream(
|
||||
|
||||
log.Debug("job stream handshake sent successfully")
|
||||
|
||||
// The stream is up, so reset the backoff. This matters for two reasons,
|
||||
// both caused by the backoff lib not resetting its state on a successful
|
||||
// connection:
|
||||
// 1. Without a reset, after a connect followed by an error the next retry
|
||||
// starts from the accumulated (large) interval instead of retrying
|
||||
// promptly, delaying reconnection.
|
||||
// 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the
|
||||
// next stream error makes NextBackOff() return Stop, so the retry loop
|
||||
// exits immediately. That error is then mislabeled unrecoverable and
|
||||
// bubbles up to trigger a full engine restart / data-plane teardown
|
||||
// instead of a silent reconnection.
|
||||
backOff.Reset()
|
||||
|
||||
// Main loop: receive, process, respond
|
||||
for {
|
||||
jobReq, err := c.receiveJobRequest(ctx, stream, serverPubKey)
|
||||
@@ -371,7 +385,7 @@ func (c *GrpcClient) sendJobResponse(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error {
|
||||
func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error, backOff backoff.BackOff) error {
|
||||
ctx, cancelStream := context.WithCancel(ctx)
|
||||
defer cancelStream()
|
||||
|
||||
@@ -390,6 +404,19 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
|
||||
c.notifyConnected()
|
||||
c.setSyncStreamConnected()
|
||||
|
||||
// The stream is up, so reset the backoff. This matters for two reasons,
|
||||
// both caused by the backoff lib not resetting its state on a successful
|
||||
// connection:
|
||||
// 1. Without a reset, after a connect followed by an error the next retry
|
||||
// starts from the accumulated (large) interval instead of retrying
|
||||
// promptly, delaying reconnection.
|
||||
// 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the
|
||||
// next stream error makes NextBackOff() return Stop, so the retry loop
|
||||
// exits immediately. That error is then mislabeled unrecoverable and
|
||||
// bubbles up to trigger a full engine restart / data-plane teardown
|
||||
// instead of a silent reconnection.
|
||||
backOff.Reset()
|
||||
|
||||
// blocking until error
|
||||
err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler)
|
||||
if err != nil {
|
||||
|
||||
@@ -3379,6 +3379,30 @@ components:
|
||||
- "observe"
|
||||
default: "off"
|
||||
description: CrowdSec IP reputation mode. Only available when the proxy cluster supports CrowdSec.
|
||||
allow_match:
|
||||
type: string
|
||||
enum:
|
||||
- "all"
|
||||
- "any"
|
||||
default: "all"
|
||||
description: >-
|
||||
How the allowlists (allowed_cidrs, allowed_countries) combine.
|
||||
"all" (default) requires a connection to match every configured
|
||||
allowlist (AND); "any" requires it to match at least one (OR), e.g.
|
||||
an allowed country OR an allowed CIDR. Blocklists always reject on
|
||||
match regardless of this setting.
|
||||
appsec_mode:
|
||||
type: string
|
||||
enum:
|
||||
- "off"
|
||||
- "enforce"
|
||||
- "observe"
|
||||
default: "off"
|
||||
description: >-
|
||||
CrowdSec AppSec (WAF) request inspection mode. Only available when
|
||||
the proxy cluster supports AppSec, and only applied to HTTP
|
||||
services. "enforce" blocks requests the WAF flags; "observe" records
|
||||
the verdict in the access log without blocking.
|
||||
PasswordAuthConfig:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3515,6 +3539,10 @@ components:
|
||||
type: boolean
|
||||
description: Whether all active proxies in the cluster have CrowdSec configured
|
||||
example: false
|
||||
supports_appsec:
|
||||
type: boolean
|
||||
description: Whether all active proxies in the cluster have a CrowdSec AppSec (WAF) endpoint configured
|
||||
example: false
|
||||
private:
|
||||
type: boolean
|
||||
description: True when at least one connected proxy in this cluster is running embedded in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. Lets the dashboard distinguish per-peer / private clusters from centralised ones.
|
||||
@@ -3574,6 +3602,10 @@ components:
|
||||
type: boolean
|
||||
description: Whether the proxy cluster has CrowdSec configured
|
||||
example: false
|
||||
supports_appsec:
|
||||
type: boolean
|
||||
description: Whether the proxy cluster has a CrowdSec AppSec (WAF) endpoint configured
|
||||
example: false
|
||||
supports_private:
|
||||
type: boolean
|
||||
description: Whether the proxy cluster supports private (NetBird-only) services. True when at least one connected proxy in the cluster runs embedded in a netbird client.
|
||||
|
||||
@@ -17,6 +17,45 @@ const (
|
||||
TokenAuthScopes tokenAuthContextKey = "TokenAuth.Scopes"
|
||||
)
|
||||
|
||||
// Defines values for AccessRestrictionsAllowMatch.
|
||||
const (
|
||||
AccessRestrictionsAllowMatchAll AccessRestrictionsAllowMatch = "all"
|
||||
AccessRestrictionsAllowMatchAny AccessRestrictionsAllowMatch = "any"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the AccessRestrictionsAllowMatch enum.
|
||||
func (e AccessRestrictionsAllowMatch) Valid() bool {
|
||||
switch e {
|
||||
case AccessRestrictionsAllowMatchAll:
|
||||
return true
|
||||
case AccessRestrictionsAllowMatchAny:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for AccessRestrictionsAppsecMode.
|
||||
const (
|
||||
AccessRestrictionsAppsecModeEnforce AccessRestrictionsAppsecMode = "enforce"
|
||||
AccessRestrictionsAppsecModeObserve AccessRestrictionsAppsecMode = "observe"
|
||||
AccessRestrictionsAppsecModeOff AccessRestrictionsAppsecMode = "off"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the AccessRestrictionsAppsecMode enum.
|
||||
func (e AccessRestrictionsAppsecMode) Valid() bool {
|
||||
switch e {
|
||||
case AccessRestrictionsAppsecModeEnforce:
|
||||
return true
|
||||
case AccessRestrictionsAppsecModeObserve:
|
||||
return true
|
||||
case AccessRestrictionsAppsecModeOff:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for AccessRestrictionsCrowdsecMode.
|
||||
const (
|
||||
AccessRestrictionsCrowdsecModeEnforce AccessRestrictionsCrowdsecMode = "enforce"
|
||||
@@ -1534,12 +1573,18 @@ func (e PutApiIntegrationsMspTenantsIdInviteJSONBodyValue) Valid() bool {
|
||||
|
||||
// AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services.
|
||||
type AccessRestrictions struct {
|
||||
// AllowMatch How the allowlists (allowed_cidrs, allowed_countries) combine. "all" (default) requires a connection to match every configured allowlist (AND); "any" requires it to match at least one (OR), e.g. an allowed country OR an allowed CIDR. Blocklists always reject on match regardless of this setting.
|
||||
AllowMatch *AccessRestrictionsAllowMatch `json:"allow_match,omitempty"`
|
||||
|
||||
// AllowedCidrs CIDR allowlist. If non-empty, only IPs matching these CIDRs are allowed.
|
||||
AllowedCidrs *[]string `json:"allowed_cidrs,omitempty"`
|
||||
|
||||
// AllowedCountries ISO 3166-1 alpha-2 country codes to allow. If non-empty, only these countries are permitted.
|
||||
AllowedCountries *[]string `json:"allowed_countries,omitempty"`
|
||||
|
||||
// AppsecMode CrowdSec AppSec (WAF) request inspection mode. Only available when the proxy cluster supports AppSec, and only applied to HTTP services. "enforce" blocks requests the WAF flags; "observe" records the verdict in the access log without blocking.
|
||||
AppsecMode *AccessRestrictionsAppsecMode `json:"appsec_mode,omitempty"`
|
||||
|
||||
// BlockedCidrs CIDR blocklist. Connections from these CIDRs are rejected. Evaluated after allowed_cidrs.
|
||||
BlockedCidrs *[]string `json:"blocked_cidrs,omitempty"`
|
||||
|
||||
@@ -1550,6 +1595,12 @@ type AccessRestrictions struct {
|
||||
CrowdsecMode *AccessRestrictionsCrowdsecMode `json:"crowdsec_mode,omitempty"`
|
||||
}
|
||||
|
||||
// AccessRestrictionsAllowMatch How the allowlists (allowed_cidrs, allowed_countries) combine. "all" (default) requires a connection to match every configured allowlist (AND); "any" requires it to match at least one (OR), e.g. an allowed country OR an allowed CIDR. Blocklists always reject on match regardless of this setting.
|
||||
type AccessRestrictionsAllowMatch string
|
||||
|
||||
// AccessRestrictionsAppsecMode CrowdSec AppSec (WAF) request inspection mode. Only available when the proxy cluster supports AppSec, and only applied to HTTP services. "enforce" blocks requests the WAF flags; "observe" records the verdict in the access log without blocking.
|
||||
type AccessRestrictionsAppsecMode string
|
||||
|
||||
// AccessRestrictionsCrowdsecMode CrowdSec IP reputation mode. Only available when the proxy cluster supports CrowdSec.
|
||||
type AccessRestrictionsCrowdsecMode string
|
||||
|
||||
@@ -4665,6 +4716,9 @@ type ProxyCluster struct {
|
||||
// RequireSubdomain Whether services on this cluster must include a subdomain label
|
||||
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
|
||||
|
||||
// SupportsAppsec Whether all active proxies in the cluster have a CrowdSec AppSec (WAF) endpoint configured
|
||||
SupportsAppsec *bool `json:"supports_appsec,omitempty"`
|
||||
|
||||
// SupportsCrowdsec Whether all active proxies in the cluster have CrowdSec configured
|
||||
SupportsCrowdsec *bool `json:"supports_crowdsec,omitempty"`
|
||||
|
||||
@@ -4733,6 +4787,9 @@ type ReverseProxyDomain struct {
|
||||
// RequireSubdomain Whether a subdomain label is required in front of this domain. When true, the domain cannot be used bare.
|
||||
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
|
||||
|
||||
// SupportsAppsec Whether the proxy cluster has a CrowdSec AppSec (WAF) endpoint configured
|
||||
SupportsAppsec *bool `json:"supports_appsec,omitempty"`
|
||||
|
||||
// SupportsCrowdsec Whether the proxy cluster has CrowdSec configured
|
||||
SupportsCrowdsec *bool `json:"supports_crowdsec,omitempty"`
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,10 @@ message ProxyCapabilities {
|
||||
optional bool private = 4;
|
||||
// Whether the proxy enforces ProxyMapping.private (fails closed on ValidateTunnelPeer failure). Management MUST NOT stream private mappings to proxies that don't claim this.
|
||||
optional bool supports_private_service = 5;
|
||||
// Whether the proxy has a CrowdSec AppSec (WAF) endpoint configured and can
|
||||
// inspect HTTP requests. Independent of supports_crowdsec: AppSec is a
|
||||
// separate endpoint on the Security Engine and applies to HTTP services only.
|
||||
optional bool supports_appsec = 6;
|
||||
}
|
||||
|
||||
// GetMappingUpdateRequest is sent to initialise a mapping stream.
|
||||
@@ -203,6 +207,13 @@ message AccessRestrictions {
|
||||
repeated string blocked_countries = 4;
|
||||
// CrowdSec IP reputation mode: "", "off", "enforce", or "observe".
|
||||
string crowdsec_mode = 5;
|
||||
// How the allowlists (CIDR, country) combine: "" or "all" require matching
|
||||
// every allowlist (AND); "any" requires matching at least one (OR).
|
||||
// Blocklists are always a hard-deny gate, independent of this mode.
|
||||
string allow_match = 6;
|
||||
// CrowdSec AppSec (WAF) request inspection mode: "", "off", "enforce", or
|
||||
// "observe". HTTP services only; ignored for TCP/UDP/TLS modes.
|
||||
string appsec_mode = 7;
|
||||
}
|
||||
|
||||
message ProxyMapping {
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
)
|
||||
|
||||
// ErrSharedSockStopped indicates that shared socket has been stopped
|
||||
var ErrSharedSockStopped = fmt.Errorf("shared socked stopped")
|
||||
var ErrSharedSockStopped = fmt.Errorf("shared socket stopped")
|
||||
|
||||
// SharedSocket is a net.PacketConn that initiates two raw sockets (ipv4 and ipv6) and listens to UDP packets filtered
|
||||
// by BPF instructions (e.g., IncomingSTUNFilter that checks and sends only STUN packets to the listeners (ReadFrom)).
|
||||
|
||||
Reference in New Issue
Block a user