Add transparent proxy inspection engine with envoy sidecar support

This commit is contained in:
Viktor Liu
2026-04-11 18:39:18 +02:00
parent 5259e5df51
commit afbddae472
65 changed files with 10428 additions and 763 deletions
+4
View File
@@ -113,6 +113,10 @@ type Manager interface {
SavePostureChecks(ctx context.Context, accountID, userID string, postureChecks *posture.Checks, create bool) (*posture.Checks, error)
DeletePostureChecks(ctx context.Context, accountID, postureChecksID, userID string) error
ListPostureChecks(ctx context.Context, accountID, userID string) ([]*posture.Checks, error)
GetInspectionPolicy(ctx context.Context, accountID, policyID, userID string) (*types.InspectionPolicy, error)
SaveInspectionPolicy(ctx context.Context, accountID, userID string, policy *types.InspectionPolicy, create bool) (*types.InspectionPolicy, error)
DeleteInspectionPolicy(ctx context.Context, accountID, policyID, userID string) error
ListInspectionPolicies(ctx context.Context, accountID, userID string) ([]*types.InspectionPolicy, error)
GetIdpManager() idp.Manager
UpdateIntegratedValidator(ctx context.Context, accountID, userID, validator string, groups []string) error
GroupValidation(ctx context.Context, accountId string, groups []string) (bool, error)
+2
View File
@@ -51,6 +51,7 @@ import (
"github.com/netbirdio/netbird/management/server/http/handlers/instance"
"github.com/netbirdio/netbird/management/server/http/handlers/networks"
"github.com/netbirdio/netbird/management/server/http/handlers/peers"
inspectionHandler "github.com/netbirdio/netbird/management/server/http/handlers/inspection"
"github.com/netbirdio/netbird/management/server/http/handlers/policies"
"github.com/netbirdio/netbird/management/server/http/handlers/routes"
"github.com/netbirdio/netbird/management/server/http/handlers/setup_keys"
@@ -162,6 +163,7 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
setup_keys.AddEndpoints(accountManager, router)
policies.AddEndpoints(accountManager, LocationManager, router)
policies.AddPostureCheckEndpoints(accountManager, LocationManager, router)
inspectionHandler.AddEndpoints(accountManager, router)
policies.AddLocationsEndpoints(accountManager, LocationManager, permissionsManager, router)
groups.AddEndpoints(accountManager, router)
routes.AddEndpoints(accountManager, router)
@@ -0,0 +1,288 @@
package inspection
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/netbirdio/netbird/management/server/account"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
)
// Handler manages inspection policy CRUD operations.
type Handler struct {
accountManager account.Manager
}
// AddEndpoints registers the inspection policy API endpoints.
func AddEndpoints(accountManager account.Manager, router *mux.Router) {
h := &Handler{accountManager: accountManager}
router.HandleFunc("/inspection-policies", h.list).Methods("GET", "OPTIONS")
router.HandleFunc("/inspection-policies", h.create).Methods("POST", "OPTIONS")
router.HandleFunc("/inspection-policies/{policyId}", h.get).Methods("GET", "OPTIONS")
router.HandleFunc("/inspection-policies/{policyId}", h.update).Methods("PUT", "OPTIONS")
router.HandleFunc("/inspection-policies/{policyId}", h.remove).Methods("DELETE", "OPTIONS")
}
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
policies, err := h.accountManager.ListInspectionPolicies(r.Context(), userAuth.AccountId, userAuth.UserId)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
result := make([]*api.InspectionPolicy, 0, len(policies))
for _, p := range policies {
result = append(result, toAPIResponse(p))
}
util.WriteJSONObject(r.Context(), w, result)
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
var req api.InspectionPolicyMinimum
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "decode request: %v", err), w)
return
}
policy := fromAPIRequest(&req)
saved, err := h.accountManager.SaveInspectionPolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, toAPIResponse(saved))
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
policyID := mux.Vars(r)["policyId"]
policy, err := h.accountManager.GetInspectionPolicy(r.Context(), userAuth.AccountId, policyID, userAuth.UserId)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, toAPIResponse(policy))
}
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
policyID := mux.Vars(r)["policyId"]
var req api.InspectionPolicyMinimum
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "decode request: %v", err), w)
return
}
policy := fromAPIRequest(&req)
policy.ID = policyID
saved, err := h.accountManager.SaveInspectionPolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, false)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, toAPIResponse(saved))
}
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
policyID := mux.Vars(r)["policyId"]
if err := h.accountManager.DeleteInspectionPolicy(r.Context(), userAuth.AccountId, policyID, userAuth.UserId); err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, struct{}{})
}
func toAPIResponse(p *types.InspectionPolicy) *api.InspectionPolicy {
id := p.ID
resp := &api.InspectionPolicy{
Id: &id,
Name: p.Name,
Enabled: p.Enabled,
}
if p.Description != "" {
resp.Description = &p.Description
}
if p.Mode != "" {
mode := api.InspectionPolicyMode(p.Mode)
resp.Mode = &mode
}
if p.ExternalURL != "" {
resp.ExternalUrl = &p.ExternalURL
}
if p.DefaultAction != "" {
da := api.InspectionPolicyDefaultAction(p.DefaultAction)
resp.DefaultAction = &da
}
if len(p.RedirectPorts) > 0 {
resp.RedirectPorts = &p.RedirectPorts
}
if p.CACertPEM != "" {
resp.CaCertPem = &p.CACertPEM
}
if p.CAKeyPEM != "" {
resp.CaKeyPem = &p.CAKeyPEM
}
if p.EnvoyBinaryPath != "" {
resp.EnvoyBinaryPath = &p.EnvoyBinaryPath
}
if p.EnvoyAdminPort != 0 {
port := int(p.EnvoyAdminPort)
resp.EnvoyAdminPort = &port
}
if p.ICAP != nil {
resp.Icap = &api.InspectionICAPConfig{}
if p.ICAP.ReqModURL != "" {
resp.Icap.ReqmodUrl = &p.ICAP.ReqModURL
}
if p.ICAP.RespModURL != "" {
resp.Icap.RespmodUrl = &p.ICAP.RespModURL
}
if p.ICAP.MaxConnections != 0 {
resp.Icap.MaxConnections = &p.ICAP.MaxConnections
}
}
rules := make([]api.InspectionPolicyRule, 0, len(p.Rules))
for _, r := range p.Rules {
rule := api.InspectionPolicyRule{
Action: api.InspectionPolicyRuleAction(r.Action),
Priority: r.Priority,
}
if len(r.Domains) > 0 {
rule.Domains = &r.Domains
}
if len(r.Networks) > 0 {
rule.Networks = &r.Networks
}
if len(r.Protocols) > 0 {
protos := make([]api.InspectionPolicyRuleProtocols, len(r.Protocols))
for i, proto := range r.Protocols {
protos[i] = api.InspectionPolicyRuleProtocols(proto)
}
rule.Protocols = &protos
}
if len(r.Paths) > 0 {
rule.Paths = &r.Paths
}
rules = append(rules, rule)
}
resp.Rules = rules
return resp
}
func fromAPIRequest(req *api.InspectionPolicyMinimum) *types.InspectionPolicy {
p := &types.InspectionPolicy{
Name: req.Name,
Enabled: req.Enabled,
}
if req.Description != nil {
p.Description = *req.Description
}
if req.Mode != nil {
p.Mode = string(*req.Mode)
}
if req.ExternalUrl != nil {
p.ExternalURL = *req.ExternalUrl
}
if req.DefaultAction != nil {
p.DefaultAction = string(*req.DefaultAction)
}
if req.RedirectPorts != nil {
p.RedirectPorts = *req.RedirectPorts
}
if req.CaCertPem != nil {
p.CACertPEM = *req.CaCertPem
}
if req.CaKeyPem != nil {
p.CAKeyPEM = *req.CaKeyPem
}
if req.EnvoyBinaryPath != nil {
p.EnvoyBinaryPath = *req.EnvoyBinaryPath
}
if req.EnvoyAdminPort != nil {
p.EnvoyAdminPort = *req.EnvoyAdminPort
}
if req.Icap != nil {
p.ICAP = &types.InspectionICAPConfig{}
if req.Icap.ReqmodUrl != nil {
p.ICAP.ReqModURL = *req.Icap.ReqmodUrl
}
if req.Icap.RespmodUrl != nil {
p.ICAP.RespModURL = *req.Icap.RespmodUrl
}
if req.Icap.MaxConnections != nil {
p.ICAP.MaxConnections = *req.Icap.MaxConnections
}
}
for _, r := range req.Rules {
rule := types.InspectionPolicyRule{
Action: string(r.Action),
Priority: r.Priority,
}
if r.Domains != nil {
rule.Domains = *r.Domains
}
if r.Networks != nil {
rule.Networks = *r.Networks
}
if r.Protocols != nil {
for _, proto := range *r.Protocols {
rule.Protocols = append(rule.Protocols, string(proto))
}
}
if r.Paths != nil {
rule.Paths = *r.Paths
}
p.Rules = append(p.Rules, rule)
}
return p
}
@@ -281,6 +281,10 @@ func (h *handler) savePolicy(w http.ResponseWriter, r *http.Request, accountID s
policy.SourcePostureChecks = *req.SourcePostureChecks
}
if req.InspectionPolicies != nil {
policy.InspectionPolicies = *req.InspectionPolicies
}
policy, err := h.accountManager.SavePolicy(r.Context(), accountID, userID, policy, create)
if err != nil {
util.WriteError(r.Context(), err, w)
@@ -377,6 +381,7 @@ func toPolicyResponse(groups []*types.Group, policy *types.Policy) *api.Policy {
Description: &policy.Description,
Enabled: policy.Enabled,
SourcePostureChecks: policy.SourcePostureChecks,
InspectionPolicies: &policy.InspectionPolicies,
}
for _, r := range policy.Rules {
rID := r.ID
+52
View File
@@ -0,0 +1,52 @@
package server
import (
"context"
"github.com/rs/xid"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
// GetInspectionPolicy returns an inspection policy by ID.
func (am *DefaultAccountManager) GetInspectionPolicy(ctx context.Context, accountID, policyID, userID string) (*types.InspectionPolicy, error) {
return am.Store.GetInspectionPolicyByID(ctx, store.LockingStrengthShare, accountID, policyID)
}
// SaveInspectionPolicy creates or updates an inspection policy.
func (am *DefaultAccountManager) SaveInspectionPolicy(ctx context.Context, accountID, userID string, policy *types.InspectionPolicy, create bool) (*types.InspectionPolicy, error) {
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if create {
policy.ID = xid.New().String()
}
policy.AccountID = accountID
return transaction.SaveInspectionPolicy(ctx, store.LockingStrengthUpdate, policy)
})
if err != nil {
return nil, err
}
am.UpdateAccountPeers(ctx, accountID)
return policy, nil
}
// DeleteInspectionPolicy removes an inspection policy.
func (am *DefaultAccountManager) DeleteInspectionPolicy(ctx context.Context, accountID, policyID, userID string) error {
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
return transaction.DeleteInspectionPolicy(ctx, store.LockingStrengthUpdate, accountID, policyID)
})
if err != nil {
return err
}
am.UpdateAccountPeers(ctx, accountID)
return nil
}
// ListInspectionPolicies returns all inspection policies for the account.
func (am *DefaultAccountManager) ListInspectionPolicies(ctx context.Context, accountID, userID string) ([]*types.InspectionPolicy, error) {
return am.Store.GetAccountInspectionPolicies(ctx, store.LockingStrengthShare, accountID)
}
@@ -909,6 +909,26 @@ func (am *MockAccountManager) ListPostureChecks(ctx context.Context, accountID,
return nil, status.Errorf(codes.Unimplemented, "method ListPostureChecks is not implemented")
}
// GetInspectionPolicy mocks GetInspectionPolicy of the AccountManager interface
func (am *MockAccountManager) GetInspectionPolicy(ctx context.Context, accountID, policyID, userID string) (*types.InspectionPolicy, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetInspectionPolicy is not implemented")
}
// SaveInspectionPolicy mocks SaveInspectionPolicy of the AccountManager interface
func (am *MockAccountManager) SaveInspectionPolicy(ctx context.Context, accountID, userID string, policy *types.InspectionPolicy, create bool) (*types.InspectionPolicy, error) {
return nil, status.Errorf(codes.Unimplemented, "method SaveInspectionPolicy is not implemented")
}
// DeleteInspectionPolicy mocks DeleteInspectionPolicy of the AccountManager interface
func (am *MockAccountManager) DeleteInspectionPolicy(ctx context.Context, accountID, policyID, userID string) error {
return status.Errorf(codes.Unimplemented, "method DeleteInspectionPolicy is not implemented")
}
// ListInspectionPolicies mocks ListInspectionPolicies of the AccountManager interface
func (am *MockAccountManager) ListInspectionPolicies(ctx context.Context, accountID, userID string) ([]*types.InspectionPolicy, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListInspectionPolicies is not implemented")
}
// GetIdpManager mocks GetIdpManager of the AccountManager interface
func (am *MockAccountManager) GetIdpManager() idp.Manager {
if am.GetIdpManagerFunc != nil {
@@ -18,6 +18,28 @@ type NetworkRouter struct {
Masquerade bool
Metric int
Enabled bool
Inspection *InspectionConfig `gorm:"serializer:json"`
}
// InspectionConfig holds traffic inspection settings for a routing peer.
// L7 inspection rules are stored separately as ProxyRule entities.
type InspectionConfig struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"` // "builtin" or "external"
ExternalURL string `json:"external_url"`
DefaultAction string `json:"default_action"` // "allow", "block", "inspect"
RedirectPorts []int `json:"redirect_ports"`
ICAP *InspectionICAP `json:"icap,omitempty"`
CACertPEM string `json:"ca_cert_pem,omitempty"`
CAKeyPEM string `json:"ca_key_pem,omitempty"`
ListenPort int `json:"listen_port"`
}
// InspectionICAP holds ICAP service configuration.
type InspectionICAP struct {
ReqModURL string `json:"reqmod_url"`
RespModURL string `json:"respmod_url"`
MaxConnections int `json:"max_connections"`
}
func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) {
@@ -38,7 +60,7 @@ func NewNetworkRouter(accountID string, networkID string, peer string, peerGroup
}
func (n *NetworkRouter) ToAPIResponse() *api.NetworkRouter {
return &api.NetworkRouter{
resp := &api.NetworkRouter{
Id: n.ID,
Peer: &n.Peer,
PeerGroups: &n.PeerGroups,
@@ -46,6 +68,12 @@ func (n *NetworkRouter) ToAPIResponse() *api.NetworkRouter {
Metric: n.Metric,
Enabled: n.Enabled,
}
if n.Inspection != nil {
resp.Inspection = inspectionToAPI(n.Inspection)
}
return resp
}
func (n *NetworkRouter) FromAPIRequest(req *api.NetworkRouterRequest) {
@@ -60,10 +88,11 @@ func (n *NetworkRouter) FromAPIRequest(req *api.NetworkRouterRequest) {
n.Masquerade = req.Masquerade
n.Metric = req.Metric
n.Enabled = req.Enabled
n.Inspection = inspectionFromAPI(req.Inspection)
}
func (n *NetworkRouter) Copy() *NetworkRouter {
return &NetworkRouter{
c := &NetworkRouter{
ID: n.ID,
NetworkID: n.NetworkID,
AccountID: n.AccountID,
@@ -73,6 +102,108 @@ func (n *NetworkRouter) Copy() *NetworkRouter {
Metric: n.Metric,
Enabled: n.Enabled,
}
if n.Inspection != nil {
insp := *n.Inspection
c.Inspection = &insp
}
return c
}
func inspectionToAPI(c *InspectionConfig) *api.RouterInspectionConfig {
if c == nil {
return nil
}
mode := api.RouterInspectionConfigMode(c.Mode)
defaultAction := api.RouterInspectionConfigDefaultAction(c.DefaultAction)
resp := &api.RouterInspectionConfig{
Enabled: c.Enabled,
Mode: &mode,
DefaultAction: &defaultAction,
}
if c.ExternalURL != "" {
resp.ExternalUrl = &c.ExternalURL
}
if len(c.RedirectPorts) > 0 {
resp.RedirectPorts = &c.RedirectPorts
}
if c.CACertPEM != "" {
resp.CaCertPem = &c.CACertPEM
}
if c.CAKeyPEM != "" {
resp.CaKeyPem = &c.CAKeyPEM
}
if c.ICAP != nil {
icap := api.InspectionICAPConfig{}
if c.ICAP.ReqModURL != "" {
icap.ReqmodUrl = &c.ICAP.ReqModURL
}
if c.ICAP.RespModURL != "" {
icap.RespmodUrl = &c.ICAP.RespModURL
}
if c.ICAP.MaxConnections > 0 {
icap.MaxConnections = &c.ICAP.MaxConnections
}
resp.Icap = &icap
}
return resp
}
func inspectionFromAPI(c *api.RouterInspectionConfig) *InspectionConfig {
if c == nil {
return nil
}
insp := &InspectionConfig{
Enabled: c.Enabled,
}
if c.Mode != nil {
insp.Mode = string(*c.Mode)
}
if c.DefaultAction != nil {
insp.DefaultAction = string(*c.DefaultAction)
}
if c.ExternalUrl != nil {
insp.ExternalURL = *c.ExternalUrl
}
if c.RedirectPorts != nil {
insp.RedirectPorts = *c.RedirectPorts
}
if c.CaCertPem != nil {
insp.CACertPEM = *c.CaCertPem
}
if c.CaKeyPem != nil {
insp.CAKeyPEM = *c.CaKeyPem
}
if c.Icap != nil {
insp.ICAP = &InspectionICAP{}
if c.Icap.ReqmodUrl != nil {
insp.ICAP.ReqModURL = *c.Icap.ReqmodUrl
}
if c.Icap.RespmodUrl != nil {
insp.ICAP.RespModURL = *c.Icap.RespmodUrl
}
if c.Icap.MaxConnections != nil {
insp.ICAP.MaxConnections = *c.Icap.MaxConnections
}
}
return insp
}
func derefInt(p *int) int {
if p == nil {
return 0
}
return *p
}
func (n *NetworkRouter) EventMeta(network *types.Network) map[string]any {
+7
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
_ "embed"
"slices"
"github.com/rs/xid"
@@ -150,6 +151,12 @@ func arePolicyChangesAffectPeers(ctx context.Context, transaction store.Store, a
return false, nil
}
// Inspection policy changes always affect peers since they control
// the transparent proxy config pushed in the network map.
if !slices.Equal(existingPolicy.InspectionPolicies, policy.InspectionPolicies) {
return true, nil
}
for _, rule := range existingPolicy.Rules {
if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" {
return true, nil
+59 -1
View File
@@ -134,7 +134,7 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met
&installation{}, &types.ExtraSettings{}, &posture.Checks{}, &nbpeer.NetworkAddress{},
&networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, &types.AccountOnboarding{},
&types.Job{}, &zones.Zone{}, &records.Record{}, &types.UserInviteRecord{}, &rpservice.Service{}, &rpservice.Target{}, &domain.Domain{},
&accesslogs.AccessLogEntry{}, &proxy.Proxy{},
&accesslogs.AccessLogEntry{}, &proxy.Proxy{}, &types.InspectionPolicy{},
)
if err != nil {
return nil, fmt.Errorf("auto migratePreAuto: %w", err)
@@ -1115,6 +1115,7 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types
Preload("RoutesG").
Preload("NameServerGroupsG").
Preload("PostureChecks").
Preload("InspectionPolicies").
Preload("Networks").
Preload("NetworkRouters").
Preload("NetworkResources").
@@ -3877,6 +3878,63 @@ func (s *SqlStore) DeletePostureChecks(ctx context.Context, accountID, postureCh
return nil
}
// GetAccountInspectionPolicies returns all inspection policies for the account.
// CA cert and key are decrypted after loading.
func (s *SqlStore) GetAccountInspectionPolicies(ctx context.Context, _ LockingStrength, accountID string) ([]*types.InspectionPolicy, error) {
var policies []*types.InspectionPolicy
result := s.db.Where("account_id = ?", accountID).Find(&policies)
if result.Error != nil {
return nil, status.Errorf(status.Internal, "failed to get inspection policies: %s", result.Error)
}
for _, p := range policies {
if err := p.DecryptSensitiveData(s.fieldEncrypt); err != nil {
return nil, fmt.Errorf("decrypt inspection policy %s: %w", p.ID, err)
}
}
return policies, nil
}
// GetInspectionPolicyByID returns an inspection policy by ID.
// CA cert and key are decrypted after loading.
func (s *SqlStore) GetInspectionPolicyByID(ctx context.Context, _ LockingStrength, accountID, policyID string) (*types.InspectionPolicy, error) {
var policy types.InspectionPolicy
result := s.db.Where(accountAndIDQueryCondition, accountID, policyID).First(&policy)
if result.Error != nil {
return nil, status.Errorf(status.Internal, "failed to get inspection policy: %s", result.Error)
}
if err := policy.DecryptSensitiveData(s.fieldEncrypt); err != nil {
return nil, fmt.Errorf("decrypt inspection policy %s: %w", policyID, err)
}
return &policy, nil
}
// SaveInspectionPolicy saves an inspection policy to the database.
// CA cert and key are encrypted before storage.
func (s *SqlStore) SaveInspectionPolicy(ctx context.Context, _ LockingStrength, policy *types.InspectionPolicy) error {
if err := policy.EncryptSensitiveData(s.fieldEncrypt); err != nil {
return fmt.Errorf("encrypt inspection policy: %w", err)
}
result := s.db.Save(policy)
if result.Error != nil {
log.WithContext(ctx).Errorf("failed to save inspection policy: %s", result.Error)
return status.Errorf(status.Internal, "failed to save inspection policy")
}
return nil
}
// DeleteInspectionPolicy deletes an inspection policy from the database.
func (s *SqlStore) DeleteInspectionPolicy(ctx context.Context, _ LockingStrength, accountID, policyID string) error {
result := s.db.Delete(&types.InspectionPolicy{}, accountAndIDQueryCondition, accountID, policyID)
if result.Error != nil {
log.WithContext(ctx).Errorf("failed to delete inspection policy: %s", result.Error)
return status.Errorf(status.Internal, "failed to delete inspection policy")
}
if result.RowsAffected == 0 {
return status.Errorf(status.NotFound, "inspection policy %s not found", policyID)
}
return nil
}
// GetAccountRoutes retrieves network routes for an account.
func (s *SqlStore) GetAccountRoutes(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*route.Route, error) {
tx := s.db
@@ -0,0 +1,151 @@
package store
import (
"context"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/types"
)
func TestSqlStore_InspectionPolicyCRUD(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
accountID := "test-account-inspection"
// Create account first
account := newAccountWithId(ctx, accountID, "test-user", "example.com")
err := store.SaveAccount(ctx, account)
require.NoError(t, err)
// Create
policy := &types.InspectionPolicy{
ID: "ip-1",
AccountID: accountID,
Name: "Block gambling",
Description: "Block all gambling sites",
Enabled: true,
Rules: []types.InspectionPolicyRule{
{
Domains: []string{"*.gambling.com", "*.betting.com"},
Action: "block",
Priority: 1,
Protocols: []string{"https"},
},
{
Domains: []string{"*.malware.org"},
Networks: []string{"10.0.0.0/8"},
Action: "block",
Priority: 2,
},
},
}
err = store.SaveInspectionPolicy(ctx, LockingStrengthUpdate, policy)
require.NoError(t, err)
// Read
got, err := store.GetInspectionPolicyByID(ctx, LockingStrengthShare, accountID, "ip-1")
require.NoError(t, err)
assert.Equal(t, "Block gambling", got.Name)
assert.Equal(t, "Block all gambling sites", got.Description)
assert.True(t, got.Enabled)
require.Len(t, got.Rules, 2)
assert.Equal(t, []string{"*.gambling.com", "*.betting.com"}, got.Rules[0].Domains)
assert.Equal(t, "block", got.Rules[0].Action)
assert.Equal(t, []string{"https"}, got.Rules[0].Protocols)
assert.Equal(t, []string{"10.0.0.0/8"}, got.Rules[1].Networks)
// List
policies, err := store.GetAccountInspectionPolicies(ctx, LockingStrengthShare, accountID)
require.NoError(t, err)
require.Len(t, policies, 1)
assert.Equal(t, "ip-1", policies[0].ID)
// Update
policy.Name = "Block gambling updated"
policy.Rules = append(policy.Rules, types.InspectionPolicyRule{
Domains: []string{"*.phishing.net"},
Action: "inspect",
Priority: 3,
})
err = store.SaveInspectionPolicy(ctx, LockingStrengthUpdate, policy)
require.NoError(t, err)
got, err = store.GetInspectionPolicyByID(ctx, LockingStrengthShare, accountID, "ip-1")
require.NoError(t, err)
assert.Equal(t, "Block gambling updated", got.Name)
require.Len(t, got.Rules, 3)
assert.Equal(t, "inspect", got.Rules[2].Action)
// Delete
err = store.DeleteInspectionPolicy(ctx, LockingStrengthUpdate, accountID, "ip-1")
require.NoError(t, err)
_, err = store.GetInspectionPolicyByID(ctx, LockingStrengthShare, accountID, "ip-1")
assert.Error(t, err)
policies, err = store.GetAccountInspectionPolicies(ctx, LockingStrengthShare, accountID)
require.NoError(t, err)
assert.Empty(t, policies)
})
}
func TestSqlStore_InspectionPolicyNotFound(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
accountID := "test-account-no-ip"
account := newAccountWithId(ctx, accountID, "test-user", "example.com")
err := store.SaveAccount(ctx, account)
require.NoError(t, err)
_, err = store.GetInspectionPolicyByID(ctx, LockingStrengthShare, accountID, "nonexistent")
assert.Error(t, err)
})
}
func TestSqlStore_InspectionPolicyIsolation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
// Create two accounts
acc1 := newAccountWithId(ctx, "acc-1", "user-1", "one.com")
acc2 := newAccountWithId(ctx, "acc-2", "user-2", "two.com")
require.NoError(t, store.SaveAccount(ctx, acc1))
require.NoError(t, store.SaveAccount(ctx, acc2))
// Save policy for acc-1
policy := &types.InspectionPolicy{
ID: "ip-acc1",
AccountID: "acc-1",
Name: "Account 1 policy",
Enabled: true,
Rules: []types.InspectionPolicyRule{{Action: "block", Priority: 1}},
}
require.NoError(t, store.SaveInspectionPolicy(ctx, LockingStrengthUpdate, policy))
// acc-2 should not see acc-1's policy
policies, err := store.GetAccountInspectionPolicies(ctx, LockingStrengthShare, "acc-2")
require.NoError(t, err)
assert.Empty(t, policies)
// acc-2 should not be able to get acc-1's policy by ID
_, err = store.GetInspectionPolicyByID(ctx, LockingStrengthShare, "acc-2", "ip-acc1")
assert.Error(t, err)
})
}
+5
View File
@@ -143,6 +143,11 @@ type Store interface {
SavePostureChecks(ctx context.Context, postureCheck *posture.Checks) error
DeletePostureChecks(ctx context.Context, accountID, postureChecksID string) error
GetAccountInspectionPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.InspectionPolicy, error)
GetInspectionPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types.InspectionPolicy, error)
SaveInspectionPolicy(ctx context.Context, lockStrength LockingStrength, policy *types.InspectionPolicy) error
DeleteInspectionPolicy(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) error
GetPeerLabelsInAccount(ctx context.Context, lockStrength LockingStrength, accountId string, hostname string) ([]string, error)
AddPeerToAllGroup(ctx context.Context, accountID string, peerID string) error
AddPeerToGroup(ctx context.Context, accountID, peerId string, groupID string) error
File diff suppressed because it is too large Load Diff
+1
View File
@@ -101,6 +101,7 @@ type Account struct {
NameServerGroupsG []nbdns.NameServerGroup `json:"-" gorm:"foreignKey:AccountID;references:id"`
DNSSettings DNSSettings `gorm:"embedded;embeddedPrefix:dns_settings_"`
PostureChecks []*posture.Checks `gorm:"foreignKey:AccountID;references:id"`
InspectionPolicies []*InspectionPolicy `gorm:"foreignKey:AccountID;references:id"`
Services []*service.Service `gorm:"foreignKey:AccountID;references:id"`
Domains []*proxydomain.Domain `gorm:"foreignKey:AccountID;references:id"`
// Settings is a dictionary of Account settings
@@ -81,6 +81,12 @@ func (a *Account) GetPeerNetworkMapComponents(
return nil
}
// Build inspection policies map for the network map builder
inspectionPoliciesMap := make(map[string]*InspectionPolicy, len(a.InspectionPolicies))
for _, ip := range a.InspectionPolicies {
inspectionPoliciesMap[ip.ID] = ip
}
components := &NetworkMapComponents{
PeerID: peerID,
Network: a.Network.Copy(),
@@ -91,6 +97,7 @@ func (a *Account) GetPeerNetworkMapComponents(
NetworkResources: make([]*resourceTypes.NetworkResource, 0),
PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)),
RouterPeers: make(map[string]*nbpeer.Peer),
InspectionPolicies: inspectionPoliciesMap,
}
components.AccountSettings = &AccountSettingsInfo{
@@ -0,0 +1,158 @@
package types
import (
"fmt"
"github.com/rs/xid"
"github.com/netbirdio/netbird/util/crypt"
)
// InspectionPolicy is a reusable set of L7 inspection rules with proxy configuration.
// Referenced by policies via InspectionPolicies field, similar to posture checks.
// Contains both what to inspect (rules) and how to inspect (CA, ICAP, mode).
type InspectionPolicy struct {
ID string `gorm:"primaryKey"`
AccountID string `gorm:"index"`
Name string
Description string
Enabled bool
Rules []InspectionPolicyRule `gorm:"serializer:json"`
// Mode is the proxy operation mode: "builtin", "envoy", or "external".
Mode string `json:"mode"`
// ExternalURL is the upstream proxy URL (HTTP CONNECT or SOCKS5) for external mode.
ExternalURL string `json:"external_url"`
// DefaultAction applies when no rule matches: "allow", "block", or "inspect".
DefaultAction string `json:"default_action"`
// Redirect ports: which destination ports to intercept at L4.
// Empty means all ports.
RedirectPorts []int `gorm:"serializer:json" json:"redirect_ports"`
// MITM CA certificate and key (PEM-encoded)
CACertPEM string `json:"ca_cert_pem"`
CAKeyPEM string `json:"ca_key_pem"`
// ICAP configuration for external content scanning
ICAP *InspectionICAPConfig `gorm:"serializer:json" json:"icap"`
// Envoy sidecar configuration (mode "envoy" only)
EnvoyBinaryPath string `json:"envoy_binary_path"`
EnvoyAdminPort int `json:"envoy_admin_port"`
EnvoySnippets *InspectionEnvoySnippets `gorm:"serializer:json" json:"envoy_snippets"`
}
// InspectionEnvoySnippets holds user-provided YAML fragments for envoy config customization.
// Only safe snippet types are exposed: filters and clusters. Listeners and bootstrap
// overrides are not allowed since the envoy instance is fully managed.
type InspectionEnvoySnippets struct {
HTTPFilters string `json:"http_filters"`
NetworkFilters string `json:"network_filters"`
Clusters string `json:"clusters"`
}
// InspectionICAPConfig holds ICAP protocol settings.
type InspectionICAPConfig struct {
ReqModURL string `json:"reqmod_url"`
RespModURL string `json:"respmod_url"`
MaxConnections int `json:"max_connections"`
}
// InspectionPolicyRule is an L7 rule within an inspection policy.
// No source or network references: sources come from the referencing policy,
// the destination network/routing peer is derived from the policy's destination.
type InspectionPolicyRule struct {
Domains []string `json:"domains"`
// Networks restricts this rule to specific destination CIDRs.
Networks []string `json:"networks"`
// Protocols this rule applies to: "http", "https", "h2", "h3", "websocket", "other".
Protocols []string `json:"protocols"`
// Paths are URL path patterns: "/api/", "/login", "/admin/*".
Paths []string `json:"paths"`
Action string `json:"action"`
Priority int `json:"priority"`
}
// NewInspectionPolicy creates a new InspectionPolicy with a generated ID.
func NewInspectionPolicy(accountID, name, description string, enabled bool) *InspectionPolicy {
return &InspectionPolicy{
ID: xid.New().String(),
AccountID: accountID,
Name: name,
Description: description,
Enabled: enabled,
}
}
// Copy returns a deep copy.
func (p *InspectionPolicy) Copy() *InspectionPolicy {
c := *p
c.Rules = make([]InspectionPolicyRule, len(p.Rules))
for i, r := range p.Rules {
c.Rules[i] = r
c.Rules[i].Domains = append([]string{}, r.Domains...)
c.Rules[i].Networks = append([]string{}, r.Networks...)
c.Rules[i].Protocols = append([]string{}, r.Protocols...)
}
c.RedirectPorts = append([]int{}, p.RedirectPorts...)
if p.ICAP != nil {
icap := *p.ICAP
c.ICAP = &icap
}
return &c
}
// EncryptSensitiveData encrypts CA cert and key in place.
func (p *InspectionPolicy) EncryptSensitiveData(enc *crypt.FieldEncrypt) error {
if enc == nil {
return nil
}
var err error
if p.CACertPEM != "" {
p.CACertPEM, err = enc.Encrypt(p.CACertPEM)
if err != nil {
return fmt.Errorf("encrypt ca_cert_pem: %w", err)
}
}
if p.CAKeyPEM != "" {
p.CAKeyPEM, err = enc.Encrypt(p.CAKeyPEM)
if err != nil {
return fmt.Errorf("encrypt ca_key_pem: %w", err)
}
}
return nil
}
// DecryptSensitiveData decrypts CA cert and key in place.
func (p *InspectionPolicy) DecryptSensitiveData(enc *crypt.FieldEncrypt) error {
if enc == nil {
return nil
}
var err error
if p.CACertPEM != "" {
p.CACertPEM, err = enc.Decrypt(p.CACertPEM)
if err != nil {
return fmt.Errorf("decrypt ca_cert_pem: %w", err)
}
}
if p.CAKeyPEM != "" {
p.CAKeyPEM, err = enc.Decrypt(p.CAKeyPEM)
if err != nil {
return fmt.Errorf("decrypt ca_key_pem: %w", err)
}
}
return nil
}
// HasDomainOnly returns true if this rule matches by domain and has no CIDR destinations.
func (r *InspectionPolicyRule) HasDomainOnly() bool {
return len(r.Domains) > 0 && len(r.Networks) == 0
}
// HasCIDRDestination returns true if this rule specifies destination CIDRs.
func (r *InspectionPolicyRule) HasCIDRDestination() bool {
return len(r.Networks) > 0
}
+4 -3
View File
@@ -37,9 +37,10 @@ type NetworkMap struct {
OfflinePeers []*nbpeer.Peer
FirewallRules []*FirewallRule
RoutesFirewallRules []*RouteFirewallRule
ForwardingRules []*ForwardingRule
AuthorizedUsers map[string]map[string]struct{}
EnableSSH bool
ForwardingRules []*ForwardingRule
TransparentProxyConfig *TransparentProxyConfig
AuthorizedUsers map[string]map[string]struct{}
EnableSSH bool
}
func (nm *NetworkMap) Merge(other *NetworkMap) {
+216 -10
View File
@@ -45,6 +45,13 @@ type NetworkMapComponents struct {
PostureFailedPeers map[string]map[string]struct{}
RouterPeers map[string]*nbpeer.Peer
// TransparentProxyConfig is the account-level transparent proxy configuration.
// Nil if no proxy is configured at account level.
TransparentProxyConfig *TransparentProxyConfig
// InspectionPolicies are reusable inspection rule sets referenced by policies.
InspectionPolicies map[string]*InspectionPolicy
}
type AccountSettingsInfo struct {
@@ -155,16 +162,21 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
dnsUpdate.NameServerGroups = c.getPeerNSGroupsFromGroups(targetPeerID, peerGroups)
}
// Build transparent proxy config if this peer is a routing peer with inspection enabled.
// Falls back to the account-level config if set.
tpConfig := c.getTransparentProxyConfig(targetPeerID, isRouter)
return &NetworkMap{
Peers: peersToConnectIncludingRouters,
Network: c.Network.Copy(),
Routes: append(networkResourcesRoutes, routesUpdate...),
DNSConfig: dnsUpdate,
OfflinePeers: expiredPeers,
FirewallRules: firewallRules,
RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...),
AuthorizedUsers: authorizedUsers,
EnableSSH: sshEnabled,
Peers: peersToConnectIncludingRouters,
Network: c.Network.Copy(),
Routes: append(networkResourcesRoutes, routesUpdate...),
DNSConfig: dnsUpdate,
OfflinePeers: expiredPeers,
FirewallRules: firewallRules,
RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...),
TransparentProxyConfig: tpConfig,
AuthorizedUsers: authorizedUsers,
EnableSSH: sshEnabled,
}
}
@@ -526,7 +538,6 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
return enabledRoutes, disabledRoutes
}
func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
var filteredRoutes []*route.Route
for _, r := range routes {
@@ -899,3 +910,198 @@ func (c *NetworkMapComponents) addNetworksRoutingPeers(
return peersToConnect
}
// getTransparentProxyConfig builds a TransparentProxyConfig for a routing peer
// by checking if any ACL policy targeting its networks has inspection policies attached.
func (c *NetworkMapComponents) getTransparentProxyConfig(peerID string, isRouter bool) *TransparentProxyConfig {
if c.TransparentProxyConfig != nil {
return c.TransparentProxyConfig
}
if !isRouter {
return nil
}
var networkIDs []string
for networkID, routers := range c.RoutersMap {
if _, ok := routers[peerID]; ok {
networkIDs = append(networkIDs, networkID)
}
}
if len(networkIDs) == 0 {
return nil
}
return c.buildTransparentProxyFromPolicies(networkIDs, peerID)
}
// buildTransparentProxyFromPolicies builds a TransparentProxyConfig from inspection
// policies attached to ACL policies targeting the given networks.
// Proxy infra config (CA, ICAP, mode) comes from the first inspection policy found.
// Rules are aggregated from all inspection policies.
func (c *NetworkMapComponents) buildTransparentProxyFromPolicies(networkIDs []string, peerID string) *TransparentProxyConfig {
var config *TransparentProxyConfig
// Accumulate redirect sources across all networks.
allSources := make(map[string]struct{})
for _, networkID := range networkIDs {
networkPolicies := c.getPoliciesForNetwork(networkID)
for _, policy := range networkPolicies {
for _, ipID := range policy.InspectionPolicies {
ip, ok := c.InspectionPolicies[ipID]
if !ok || !ip.Enabled {
continue
}
// First inspection policy sets the infra config
if config == nil {
config = &TransparentProxyConfig{
Enabled: true,
ExternalURL: ip.ExternalURL,
DefaultAction: toTransparentProxyAction(ip.DefaultAction),
CACertPEM: []byte(ip.CACertPEM),
CAKeyPEM: []byte(ip.CAKeyPEM),
}
switch ip.Mode {
case "envoy":
config.Mode = TransparentProxyModeEnvoy
case "external":
config.Mode = TransparentProxyModeExternal
default:
config.Mode = TransparentProxyModeBuiltin
}
for _, p := range ip.RedirectPorts {
config.RedirectPorts = append(config.RedirectPorts, uint16(p))
}
if ip.ICAP != nil {
config.ICAP = &TransparentProxyICAPConfig{
ReqModURL: ip.ICAP.ReqModURL,
RespModURL: ip.ICAP.RespModURL,
MaxConnections: ip.ICAP.MaxConnections,
}
}
if ip.Mode == "envoy" {
config.EnvoyBinaryPath = ip.EnvoyBinaryPath
config.EnvoyAdminPort = uint16(ip.EnvoyAdminPort)
if ip.EnvoySnippets != nil {
config.EnvoySnippets = &TransparentProxyEnvoySnippets{
HTTPFilters: ip.EnvoySnippets.HTTPFilters,
NetworkFilters: ip.EnvoySnippets.NetworkFilters,
Clusters: ip.EnvoySnippets.Clusters,
}
}
}
}
// Aggregate rules from all inspection policies
for _, pr := range ip.Rules {
rule := TransparentProxyRule{
ID: ip.ID,
Domains: pr.Domains,
Networks: pr.Networks,
Protocols: pr.Protocols,
Paths: pr.Paths,
Action: toTransparentProxyAction(pr.Action),
Priority: pr.Priority,
}
config.Rules = append(config.Rules, rule)
}
}
}
// Collect sources for this network
for _, src := range c.deriveRedirectSourcesFromPolicies(networkID, peerID) {
allSources[src] = struct{}{}
}
}
if config != nil {
config.RedirectSources = make([]string, 0, len(allSources))
for src := range allSources {
config.RedirectSources = append(config.RedirectSources, src)
}
}
return config
}
// deriveRedirectSourcesFromPolicies collects source peer IPs from policies
// that target the given network and have inspection policies attached.
func (c *NetworkMapComponents) deriveRedirectSourcesFromPolicies(networkID, routingPeerID string) []string {
sourceSet := make(map[string]struct{})
for _, policy := range c.getPoliciesForNetwork(networkID) {
if len(policy.InspectionPolicies) == 0 {
continue
}
peerIDs := c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
for _, peerID := range peerIDs {
if peerID == routingPeerID {
continue
}
peer := c.GetPeerInfo(peerID)
if peer != nil && peer.IP != nil {
sourceSet[peer.IP.String()+"/32"] = struct{}{}
}
}
}
sources := make([]string, 0, len(sourceSet))
for s := range sourceSet {
sources = append(sources, s)
}
return sources
}
// getPoliciesForNetwork returns all unique policies that have inspection policies attached
// and target resources belonging to the given network.
func (c *NetworkMapComponents) getPoliciesForNetwork(networkID string) []*Policy {
seen := make(map[string]bool)
var result []*Policy
add := func(policy *Policy) {
if len(policy.InspectionPolicies) == 0 || seen[policy.ID] {
return
}
seen[policy.ID] = true
result = append(result, policy)
}
// Only include policies that target resources in the given network.
networkResourceIDs := make(map[string]struct{})
for _, resource := range c.NetworkResources {
if resource.NetworkID == networkID {
networkResourceIDs[resource.ID] = struct{}{}
}
}
for resourceID, policies := range c.ResourcePoliciesMap {
if _, ok := networkResourceIDs[resourceID]; !ok {
continue
}
for _, policy := range policies {
add(policy)
}
}
// Also check classic policies whose destination groups contain peers in this network.
for _, policy := range c.Policies {
add(policy)
}
return result
}
func toTransparentProxyAction(s string) TransparentProxyAction {
switch s {
case "allow":
return TransparentProxyActionAllow
case "inspect":
return TransparentProxyActionInspect
default:
return TransparentProxyActionBlock
}
}
+6
View File
@@ -73,6 +73,10 @@ type Policy struct {
// SourcePostureChecks are ID references to Posture checks for policy source groups
SourcePostureChecks []string `gorm:"serializer:json"`
// InspectionPolicies are ID references to inspection policies applied to traffic matching this policy.
// When set, traffic is routed through a transparent proxy on the destination network's routing peers.
InspectionPolicies []string `gorm:"serializer:json"`
}
// Copy returns a copy of the policy.
@@ -85,11 +89,13 @@ func (p *Policy) Copy() *Policy {
Enabled: p.Enabled,
Rules: make([]*PolicyRule, len(p.Rules)),
SourcePostureChecks: make([]string, len(p.SourcePostureChecks)),
InspectionPolicies: make([]string, len(p.InspectionPolicies)),
}
for i, r := range p.Rules {
c.Rules[i] = r.Copy()
}
copy(c.SourcePostureChecks, p.SourcePostureChecks)
copy(c.InspectionPolicies, p.InspectionPolicies)
return c
}
+91
View File
@@ -0,0 +1,91 @@
package types
import (
"net/netip"
"slices"
)
// ProxyRouteSet collects and deduplicates the routes that need to be pushed to
// source peers for transparent proxy rules. CIDR rules create specific routes;
// domain-only rules require a catch-all (0.0.0.0/0).
type ProxyRouteSet struct {
// routes is the deduplicated set of destination prefixes to route through the proxy.
routes map[netip.Prefix]struct{}
// needsCatchAll is true if any rule has domains without CIDRs.
needsCatchAll bool
}
// NewProxyRouteSet creates a new route set.
func NewProxyRouteSet() *ProxyRouteSet {
return &ProxyRouteSet{
routes: make(map[netip.Prefix]struct{}),
}
}
// AddFromRule adds route entries derived from a proxy rule's destinations.
// - CIDR destinations create specific routes
// - Domain-only rules (no CIDRs) trigger a catch-all route
// - Rules with neither domains nor CIDRs also trigger catch-all (match all traffic)
func (s *ProxyRouteSet) AddFromRule(rule *InspectionPolicyRule) {
if rule.HasCIDRDestination() {
for _, cidr := range rule.Networks {
prefix, err := netip.ParsePrefix(cidr)
if err != nil {
continue
}
s.routes[prefix] = struct{}{}
}
return
}
// Domain-only or no destination: need catch-all
s.needsCatchAll = true
}
// Routes returns the deduplicated list of prefixes to route through the proxy.
// If any rule requires catch-all, returns only ["0.0.0.0/0"] since it subsumes
// all specific CIDRs.
func (s *ProxyRouteSet) Routes() []netip.Prefix {
if s.needsCatchAll {
return []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}
}
result := make([]netip.Prefix, 0, len(s.routes))
for prefix := range s.routes {
result = append(result, prefix)
}
// Sort for deterministic output
slices.SortFunc(result, func(a, b netip.Prefix) int {
if c := a.Addr().Compare(b.Addr()); c != 0 {
return c
}
return a.Bits() - b.Bits()
})
// Remove CIDRs that are subsets of larger CIDRs
return deduplicatePrefixes(result)
}
// deduplicatePrefixes removes prefixes that are contained within other prefixes.
// Input must be sorted.
func deduplicatePrefixes(prefixes []netip.Prefix) []netip.Prefix {
if len(prefixes) <= 1 {
return prefixes
}
var result []netip.Prefix
for _, p := range prefixes {
subsumed := false
for _, existing := range result {
if existing.Contains(p.Addr()) && existing.Bits() <= p.Bits() {
subsumed = true
break
}
}
if !subsumed {
result = append(result, p)
}
}
return result
}
@@ -0,0 +1,81 @@
package types
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProxyRouteSet_CIDROnly(t *testing.T) {
s := NewProxyRouteSet()
s.AddFromRule(&InspectionPolicyRule{
Networks: []string{"10.0.0.0/8", "172.16.0.0/12"},
})
s.AddFromRule(&InspectionPolicyRule{
Networks: []string{"192.168.0.0/16"},
})
routes := s.Routes()
require.Len(t, routes, 3)
assert.Equal(t, netip.MustParsePrefix("10.0.0.0/8"), routes[0])
assert.Equal(t, netip.MustParsePrefix("172.16.0.0/12"), routes[1])
assert.Equal(t, netip.MustParsePrefix("192.168.0.0/16"), routes[2])
}
func TestProxyRouteSet_DomainOnlyForceCatchAll(t *testing.T) {
s := NewProxyRouteSet()
s.AddFromRule(&InspectionPolicyRule{
Domains: []string{"*.gambling.com"},
})
s.AddFromRule(&InspectionPolicyRule{
Networks: []string{"10.0.0.0/8"},
})
routes := s.Routes()
require.Len(t, routes, 1)
assert.Equal(t, netip.MustParsePrefix("0.0.0.0/0"), routes[0])
}
func TestProxyRouteSet_EmptyDestinationForceCatchAll(t *testing.T) {
s := NewProxyRouteSet()
s.AddFromRule(&InspectionPolicyRule{
Action: "block",
// No domains, no networks: match all traffic
})
routes := s.Routes()
require.Len(t, routes, 1)
assert.Equal(t, netip.MustParsePrefix("0.0.0.0/0"), routes[0])
}
func TestProxyRouteSet_DeduplicateSubsets(t *testing.T) {
s := NewProxyRouteSet()
s.AddFromRule(&InspectionPolicyRule{
Networks: []string{"10.0.0.0/8"},
})
s.AddFromRule(&InspectionPolicyRule{
Networks: []string{"10.1.0.0/16"}, // subset of 10.0.0.0/8
})
s.AddFromRule(&InspectionPolicyRule{
Networks: []string{"10.1.2.0/24"}, // subset of both
})
routes := s.Routes()
require.Len(t, routes, 1)
assert.Equal(t, netip.MustParsePrefix("10.0.0.0/8"), routes[0])
}
func TestProxyRouteSet_DuplicateCIDRs(t *testing.T) {
s := NewProxyRouteSet()
s.AddFromRule(&InspectionPolicyRule{
Networks: []string{"10.0.0.0/8"},
})
s.AddFromRule(&InspectionPolicyRule{
Networks: []string{"10.0.0.0/8"}, // duplicate
})
routes := s.Routes()
require.Len(t, routes, 1)
}
@@ -0,0 +1,158 @@
package types
import (
proto "github.com/netbirdio/netbird/shared/management/proto"
)
// TransparentProxyAction determines the proxy behavior for matched connections.
type TransparentProxyAction int
const (
TransparentProxyActionAllow TransparentProxyAction = 0
TransparentProxyActionBlock TransparentProxyAction = 1
TransparentProxyActionInspect TransparentProxyAction = 2
)
// TransparentProxyMode selects built-in or external proxy operation.
type TransparentProxyMode int
const (
TransparentProxyModeBuiltin TransparentProxyMode = 0
TransparentProxyModeExternal TransparentProxyMode = 1
TransparentProxyModeEnvoy TransparentProxyMode = 2
)
// TransparentProxyConfig holds the transparent proxy configuration for a routing peer.
type TransparentProxyConfig struct {
Enabled bool
Mode TransparentProxyMode
ExternalURL string
DefaultAction TransparentProxyAction
// RedirectSources is the set of source CIDRs to intercept.
RedirectSources []string
RedirectPorts []uint16
Rules []TransparentProxyRule
ICAP *TransparentProxyICAPConfig
CACertPEM []byte
CAKeyPEM []byte
ListenPort uint16
// Envoy sidecar fields (ModeEnvoy only)
EnvoyBinaryPath string
EnvoyAdminPort uint16
EnvoySnippets *TransparentProxyEnvoySnippets
}
// TransparentProxyEnvoySnippets holds user-provided YAML fragments for envoy config.
type TransparentProxyEnvoySnippets struct {
HTTPFilters string
NetworkFilters string
Clusters string
}
// TransparentProxyRule is an L7 inspection rule evaluated by the proxy engine.
type TransparentProxyRule struct {
ID string
// Domains are domain patterns, e.g. "*.example.com".
Domains []string
// Networks restricts this rule to specific destination CIDRs.
Networks []string
// Protocols this rule applies to: "http", "https", "h2", "h3", "websocket", "other".
Protocols []string
// Paths are URL path patterns: "/api/", "/login", "/admin/*".
Paths []string
Action TransparentProxyAction
Priority int
}
// TransparentProxyICAPConfig holds ICAP service configuration.
type TransparentProxyICAPConfig struct {
ReqModURL string
RespModURL string
MaxConnections int
}
// ToProto converts the internal config to the proto representation.
func (c *TransparentProxyConfig) ToProto() *proto.TransparentProxyConfig {
if c == nil {
return nil
}
pc := &proto.TransparentProxyConfig{
Enabled: c.Enabled,
Mode: proto.TransparentProxyMode(c.Mode),
DefaultAction: proto.TransparentProxyAction(c.DefaultAction),
CaCertPem: c.CACertPEM,
CaKeyPem: c.CAKeyPEM,
ListenPort: uint32(c.ListenPort),
}
if c.ExternalURL != "" {
pc.ExternalProxyUrl = c.ExternalURL
}
if c.Mode == TransparentProxyModeEnvoy {
pc.EnvoyBinaryPath = c.EnvoyBinaryPath
pc.EnvoyAdminPort = uint32(c.EnvoyAdminPort)
if c.EnvoySnippets != nil {
pc.EnvoySnippets = &proto.TransparentProxyEnvoySnippets{
HttpFilters: c.EnvoySnippets.HTTPFilters,
NetworkFilters: c.EnvoySnippets.NetworkFilters,
Clusters: c.EnvoySnippets.Clusters,
}
}
}
pc.RedirectSources = c.RedirectSources
redirectPorts := make([]uint32, len(c.RedirectPorts))
for i, p := range c.RedirectPorts {
redirectPorts[i] = uint32(p)
}
pc.RedirectPorts = redirectPorts
for _, r := range c.Rules {
pr := &proto.TransparentProxyRule{
Id: r.ID,
Domains: r.Domains,
Networks: r.Networks,
Paths: r.Paths,
Action: proto.TransparentProxyAction(r.Action),
Priority: int32(r.Priority),
}
for _, p := range r.Protocols {
pr.Protocols = append(pr.Protocols, stringToProtoProtocol(p))
}
pc.Rules = append(pc.Rules, pr)
}
if c.ICAP != nil {
pc.Icap = &proto.TransparentProxyICAPConfig{
ReqmodUrl: c.ICAP.ReqModURL,
RespmodUrl: c.ICAP.RespModURL,
MaxConnections: int32(c.ICAP.MaxConnections),
}
}
return pc
}
// stringToProtoProtocol maps a protocol string to its proto enum value.
func stringToProtoProtocol(s string) proto.TransparentProxyProtocol {
switch s {
case "http":
return proto.TransparentProxyProtocol_TP_PROTO_HTTP
case "https":
return proto.TransparentProxyProtocol_TP_PROTO_HTTPS
case "h2":
return proto.TransparentProxyProtocol_TP_PROTO_H2
case "h3":
return proto.TransparentProxyProtocol_TP_PROTO_H3
case "websocket":
return proto.TransparentProxyProtocol_TP_PROTO_WEBSOCKET
case "other":
return proto.TransparentProxyProtocol_TP_PROTO_OTHER
default:
return proto.TransparentProxyProtocol_TP_PROTO_ALL
}
}