mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 17:09:07 +02:00
[management] Configure per-target HTTP access actions (#8116)
Let HTTP targets inherit service authentication, bypass it, or block access. Validate and persist the action, expose it through the API, and deliver it to the proxy. Private services reject bypass. Validation: service/manager, store, and management-to-proxy integration race tests passed; scoped lint passed.
This commit is contained in:
@@ -339,6 +339,9 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *
|
||||
}
|
||||
|
||||
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
if err := validateTargetAccessControl(svc); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -708,6 +711,12 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St
|
||||
}
|
||||
|
||||
m.preserveExistingAuthSecrets(service, existingService)
|
||||
if err := preserveTargetAccessActions(service, existingService); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTargetAccessControl(service); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateHeaderAuthValues(service.Auth.HeaderAuths); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -745,6 +754,68 @@ func (m *Manager) validateServiceDomain(ctx context.Context, tx store.Store, acc
|
||||
return m.clusterDeriver.ValidateServiceDomain(ctx, tx, accountID, svc.Domain, cluster)
|
||||
}
|
||||
|
||||
func validateTargetAccessControl(svc *service.Service) error {
|
||||
if !svc.HasTargetAccessControl() {
|
||||
return nil
|
||||
}
|
||||
// Legacy requests may acquire an existing action during the update merge.
|
||||
if err := svc.Validate(); err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "%s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preserveTargetAccessActions(updated, existing *service.Service) error {
|
||||
if !existing.HasTargetAccessControl() {
|
||||
return nil
|
||||
}
|
||||
if slices.ContainsFunc(updated.Targets, func(target *service.Target) bool {
|
||||
return target == nil
|
||||
}) {
|
||||
return status.Errorf(status.InvalidArgument, "target must not be nil")
|
||||
}
|
||||
if len(updated.Targets) > 0 && !slices.ContainsFunc(updated.Targets, func(target *service.Target) bool {
|
||||
return !target.AccessActionProvided
|
||||
}) {
|
||||
return nil
|
||||
}
|
||||
byPath := make(map[string]*service.Target, len(existing.Targets))
|
||||
for _, target := range existing.Targets {
|
||||
location := effectiveTargetPath(target)
|
||||
if _, duplicate := byPath[location]; duplicate {
|
||||
return status.Errorf(status.InvalidArgument,
|
||||
"cannot preserve target access control for duplicate location %q", location)
|
||||
}
|
||||
byPath[location] = target
|
||||
}
|
||||
actionsProvided := false
|
||||
for _, target := range updated.Targets {
|
||||
actionsProvided = actionsProvided || target.AccessActionProvided
|
||||
location := effectiveTargetPath(target)
|
||||
if previous := byPath[location]; previous != nil && !target.AccessActionProvided {
|
||||
target.AccessAction = previous.AccessAction
|
||||
}
|
||||
delete(byPath, location)
|
||||
}
|
||||
if actionsProvided {
|
||||
return nil
|
||||
}
|
||||
for location, target := range byPath {
|
||||
if target.AccessAction != "" && target.AccessAction != service.TargetAccessActionInherit {
|
||||
return status.Errorf(status.InvalidArgument,
|
||||
"include access_action when removing or changing controlled target location %q", location)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func effectiveTargetPath(target *service.Target) string {
|
||||
if target.Path == nil || *target.Path == "" {
|
||||
return "/"
|
||||
}
|
||||
return *target.Path
|
||||
}
|
||||
|
||||
// validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters.
|
||||
// It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode.
|
||||
// Returns an error if validation fails, otherwise returns nil.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
func TestCreateServiceTargetAccessWithoutActiveProxies(t *testing.T) {
|
||||
for _, action := range []rpservice.TargetAccessAction{
|
||||
rpservice.TargetAccessActionBlock,
|
||||
rpservice.TargetAccessActionBypass,
|
||||
} {
|
||||
t.Run(string(action), func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
svc := &rpservice.Service{
|
||||
Name: "target access", Domain: "target.test.netbird.io", Mode: rpservice.ModeHTTP,
|
||||
Enabled: true,
|
||||
Targets: []*rpservice.Target{{
|
||||
AccountID: testAccountID, TargetId: testPeerID,
|
||||
TargetType: rpservice.TargetTypePeer, Protocol: "http", Port: 8080,
|
||||
Enabled: true, AccessAction: action,
|
||||
}},
|
||||
}
|
||||
created, err := mgr.CreateService(ctx, testAccountID, testUserID, svc)
|
||||
require.NoError(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored.Targets, 1)
|
||||
assert.Equal(t, action, stored.Targets[0].AccessAction,
|
||||
"accepted target actions must survive persistence")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateServicePreservesOmittedTargetAccessAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0)
|
||||
existing.Targets[0].AccessAction = rpservice.TargetAccessActionBlock
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Name = "renamed by an older client"
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
rootPath := "/"
|
||||
updated.Targets[0].Path = &rootPath
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, updated.Name, stored.Name, "legacy updates must still apply unrelated edits")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction,
|
||||
"an omitted action must preserve the stored block across equivalent root locations")
|
||||
|
||||
emptyPath := ""
|
||||
updated.Targets[0].Path = &emptyPath
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction,
|
||||
"an older client's empty root location must preserve the stored block")
|
||||
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = true
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rpservice.TargetAccessActionInherit, stored.Targets[0].AccessAction,
|
||||
"explicit inherit must clear the stored target block")
|
||||
}
|
||||
|
||||
func TestUpdateServiceActivatesPreservedTargetAccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0)
|
||||
existing.Enabled = false
|
||||
existing.Targets[0].AccessAction = rpservice.TargetAccessActionBlock
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Enabled = true
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, stored.Enabled, "activating target access must not require an active proxy")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction,
|
||||
"activation must retain an omitted stored access action")
|
||||
}
|
||||
|
||||
func TestUpdateServiceValidatesPreservedTargetAccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "public", "http", "target.test.netbird.io", testCluster, 0)
|
||||
existing.Targets[0].AccessAction = rpservice.TargetAccessActionBypass
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Private = true
|
||||
updated.AccessGroups = []string{testGroupID}
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.ErrorContains(t, err, "bypass access_action is not supported for private services")
|
||||
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, stored.Private, "an invalid preserved bypass must prevent the private-service update")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBypass, stored.Targets[0].AccessAction,
|
||||
"a rejected update must retain the existing target action")
|
||||
}
|
||||
|
||||
func TestUpdateServiceLegacyClientCannotRemoveControlledPath(t *testing.T) {
|
||||
for _, operation := range []string{"rename", "delete"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0)
|
||||
blockedPath := "/blocked"
|
||||
existing.Targets = append(existing.Targets, &rpservice.Target{
|
||||
AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer,
|
||||
Protocol: "http", Port: 8080, Enabled: true, Path: &blockedPath,
|
||||
AccessAction: rpservice.TargetAccessActionBlock,
|
||||
})
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Targets[1].AccessAction = rpservice.TargetAccessActionInherit
|
||||
if operation == "rename" {
|
||||
renamedPath := "/renamed"
|
||||
updated.Targets[1].Path = &renamedPath
|
||||
} else {
|
||||
updated.Targets = updated.Targets[:1]
|
||||
}
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.Error(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored.Targets, 2, "a legacy structural edit must retain the controlled target")
|
||||
var blocked *rpservice.Target
|
||||
for _, target := range stored.Targets {
|
||||
if effectiveTargetPath(target) == blockedPath {
|
||||
blocked = target
|
||||
}
|
||||
}
|
||||
require.NotNil(t, blocked, "the original controlled location must still exist")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, blocked.AccessAction,
|
||||
"a rejected legacy edit must not erase the block action")
|
||||
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = true
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, stored.HasTargetAccessControl(),
|
||||
"a feature-aware client must be able to intentionally remove or rename the controlled path")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveTargetAccessActionsExplicitUpdate(t *testing.T) {
|
||||
existing := &rpservice.Service{Targets: []*rpservice.Target{
|
||||
{Enabled: true, AccessAction: rpservice.TargetAccessActionBlock},
|
||||
{Enabled: false, AccessAction: rpservice.TargetAccessActionInherit},
|
||||
}}
|
||||
updated := &rpservice.Service{Targets: []*rpservice.Target{
|
||||
{Enabled: true, AccessAction: rpservice.TargetAccessActionBypass, AccessActionProvided: true},
|
||||
{Enabled: false, AccessAction: rpservice.TargetAccessActionInherit, AccessActionProvided: true},
|
||||
}}
|
||||
require.NoError(t, preserveTargetAccessActions(updated, existing))
|
||||
assert.Equal(t, rpservice.TargetAccessActionBypass, updated.Targets[0].AccessAction,
|
||||
"explicit actions must not need an ambiguous location-based legacy merge")
|
||||
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
assert.Error(t, preserveTargetAccessActions(updated, existing),
|
||||
"an omitted action must be rejected when its stored location is ambiguous")
|
||||
|
||||
existing.Targets = existing.Targets[:1]
|
||||
updated.Targets = nil
|
||||
assert.Error(t, preserveTargetAccessActions(updated, existing),
|
||||
"an empty target list must not count as an explicit removal of the stored block")
|
||||
}
|
||||
@@ -116,18 +116,20 @@ type MiddlewareConfig struct {
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
|
||||
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port uint16 `gorm:"index:idx_target_port" json:"port"`
|
||||
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
|
||||
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
|
||||
TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"`
|
||||
Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"`
|
||||
Options TargetOptions `gorm:"embedded" json:"options"`
|
||||
ProxyProtocol bool `json:"proxy_protocol"`
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
|
||||
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port uint16 `gorm:"index:idx_target_port" json:"port"`
|
||||
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
|
||||
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
|
||||
TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"`
|
||||
Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"`
|
||||
AccessAction TargetAccessAction `gorm:"type:varchar(16);not null;default:'inherit'" json:"access_action"`
|
||||
AccessActionProvided bool `gorm:"-" json:"-"`
|
||||
Options TargetOptions `gorm:"embedded" json:"options"`
|
||||
ProxyProtocol bool `json:"proxy_protocol"`
|
||||
}
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
@@ -309,14 +311,16 @@ func (s *Service) ToAPIResponse() *api.Service {
|
||||
// Convert internal targets to API targets
|
||||
apiTargets := make([]api.ServiceTarget, 0, len(s.Targets))
|
||||
for _, target := range s.Targets {
|
||||
accessAction := api.ServiceTargetAccessAction(target.effectiveAccessAction())
|
||||
st := api.ServiceTarget{
|
||||
Path: target.Path,
|
||||
Host: &target.Host,
|
||||
Port: int(target.Port),
|
||||
Protocol: api.ServiceTargetProtocol(target.Protocol),
|
||||
TargetId: target.TargetId,
|
||||
TargetType: api.ServiceTargetTargetType(target.TargetType),
|
||||
Enabled: target.Enabled && !s.Terminated,
|
||||
Path: target.Path,
|
||||
Host: &target.Host,
|
||||
Port: int(target.Port),
|
||||
Protocol: api.ServiceTargetProtocol(target.Protocol),
|
||||
TargetId: target.TargetId,
|
||||
TargetType: api.ServiceTargetTargetType(target.TargetType),
|
||||
Enabled: target.Enabled && !s.Terminated,
|
||||
AccessAction: &accessAction,
|
||||
}
|
||||
opts := targetOptionsToAPI(target.Options)
|
||||
if opts == nil {
|
||||
@@ -463,8 +467,9 @@ func (s *Service) buildPathMappings() []*proto.PathMapping {
|
||||
}
|
||||
|
||||
pm := &proto.PathMapping{
|
||||
Path: path,
|
||||
Target: targetURL.String(),
|
||||
Path: path,
|
||||
Target: targetURL.String(),
|
||||
AccessAction: targetAccessActionToProto(target.effectiveAccessAction()),
|
||||
}
|
||||
pm.Options = targetOptionsToProto(target.Options)
|
||||
pathMappings = append(pathMappings, pm)
|
||||
@@ -727,13 +732,18 @@ func targetsFromAPI(accountID string, apiTargetsPtr *[]api.ServiceTarget) ([]*Ta
|
||||
targets := make([]*Target, 0, len(apiTargets))
|
||||
for i, apiTarget := range apiTargets {
|
||||
target := &Target{
|
||||
AccountID: accountID,
|
||||
Path: apiTarget.Path,
|
||||
Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer
|
||||
Protocol: string(apiTarget.Protocol),
|
||||
TargetId: apiTarget.TargetId,
|
||||
TargetType: TargetType(apiTarget.TargetType),
|
||||
Enabled: apiTarget.Enabled,
|
||||
AccountID: accountID,
|
||||
Path: apiTarget.Path,
|
||||
Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer
|
||||
Protocol: string(apiTarget.Protocol),
|
||||
TargetId: apiTarget.TargetId,
|
||||
TargetType: TargetType(apiTarget.TargetType),
|
||||
Enabled: apiTarget.Enabled,
|
||||
AccessAction: TargetAccessActionInherit,
|
||||
}
|
||||
if apiTarget.AccessAction != nil {
|
||||
target.AccessAction = TargetAccessAction(*apiTarget.AccessAction)
|
||||
target.AccessActionProvided = true
|
||||
}
|
||||
if apiTarget.Host != nil {
|
||||
target.Host = *apiTarget.Host
|
||||
@@ -953,41 +963,70 @@ func (s *Service) validateTLSMode() error {
|
||||
}
|
||||
|
||||
func (s *Service) validateHTTPTargets() error {
|
||||
strictPaths := false
|
||||
for i, target := range s.Targets {
|
||||
switch target.TargetType {
|
||||
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
|
||||
// Host is normally overwritten by replaceHostByLookup with the
|
||||
// resolved peer IP / resource address; operator-supplied values
|
||||
// are honored only when DirectUpstream is set. Validate the
|
||||
// override here so misconfigured hosts fail fast at API time.
|
||||
if err := validateDirectUpstreamHost(i, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case TargetTypeSubnet:
|
||||
if err := validateSubnetTarget(i, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case TargetTypeCluster:
|
||||
if err := validateClusterTarget(i, target); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("target %d has invalid target_type %q", i, target.TargetType)
|
||||
}
|
||||
if target.TargetId == "" {
|
||||
return fmt.Errorf("target %d has empty target_id", i)
|
||||
}
|
||||
if target.ProxyProtocol {
|
||||
return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", i)
|
||||
}
|
||||
if err := validateTargetOptions(i, &target.Options); err != nil {
|
||||
if err := s.validateHTTPTarget(i, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Enabled && target.effectiveAccessAction() != TargetAccessActionInherit {
|
||||
strictPaths = true
|
||||
}
|
||||
}
|
||||
if !strictPaths {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths := make(map[string]int, len(s.Targets))
|
||||
for i, target := range s.Targets {
|
||||
if !target.Enabled {
|
||||
continue
|
||||
}
|
||||
normalizedPath := normalizedTargetPath(target.Path)
|
||||
if previous, ok := paths[normalizedPath]; ok {
|
||||
return fmt.Errorf("targets %d and %d have duplicate path %q", previous, i, normalizedPath)
|
||||
}
|
||||
paths[normalizedPath] = i
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) validateHTTPTarget(idx int, target *Target) error {
|
||||
if target == nil {
|
||||
return fmt.Errorf("target %d is nil", idx)
|
||||
}
|
||||
if err := validateTargetAccessAction(idx, target, s.Private); err != nil {
|
||||
return err
|
||||
}
|
||||
switch target.TargetType {
|
||||
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
|
||||
// Host is normally overwritten by replaceHostByLookup with the
|
||||
// resolved peer IP / resource address; operator-supplied values
|
||||
// are honored only when DirectUpstream is set. Validate the
|
||||
// override here so misconfigured hosts fail fast at API time.
|
||||
if err := validateDirectUpstreamHost(idx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case TargetTypeSubnet:
|
||||
if err := validateSubnetTarget(idx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case TargetTypeCluster:
|
||||
if err := validateClusterTarget(idx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("target %d has invalid target_type %q", idx, target.TargetType)
|
||||
}
|
||||
if target.TargetId == "" {
|
||||
return fmt.Errorf("target %d has empty target_id", idx)
|
||||
}
|
||||
if target.ProxyProtocol {
|
||||
return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", idx)
|
||||
}
|
||||
return validateTargetOptions(idx, &target.Options)
|
||||
}
|
||||
|
||||
func validateSubnetTarget(idx int, target *Target) error {
|
||||
host := strings.TrimSpace(target.Host)
|
||||
if host == "" {
|
||||
@@ -1063,10 +1102,20 @@ func validateDirectUpstreamHost(idx int, target *Target) error {
|
||||
}
|
||||
|
||||
func (s *Service) validateL4Target(target *Target) error {
|
||||
if target == nil {
|
||||
return errors.New("target 0 is nil")
|
||||
}
|
||||
// L4 services have a single target; per-target disable is meaningless
|
||||
// (use the service-level Enabled flag instead). Force it on so that
|
||||
// buildPathMappings always includes the target in the proto.
|
||||
target.Enabled = true
|
||||
action, err := validatedTargetAccessAction(0, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action != TargetAccessActionInherit {
|
||||
return errors.New("access_action is only supported for HTTP services")
|
||||
}
|
||||
|
||||
if target.TargetId == "" {
|
||||
return errors.New("target_id is required for L4 services")
|
||||
|
||||
@@ -62,9 +62,11 @@ func TestValidate_InvalidTargetType(t *testing.T) {
|
||||
|
||||
func TestValidate_ResourceTarget(t *testing.T) {
|
||||
rp := validProxy()
|
||||
resourcePath := "/resource"
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "resource-1",
|
||||
TargetType: TargetTypeHost,
|
||||
Path: &resourcePath,
|
||||
Host: "example.org",
|
||||
Port: 443,
|
||||
Protocol: "https",
|
||||
@@ -73,6 +75,232 @@ func TestValidate_ResourceTarget(t *testing.T) {
|
||||
require.NoError(t, rp.Validate())
|
||||
}
|
||||
|
||||
func TestValidateTargetAccessAction(t *testing.T) {
|
||||
t.Run("valid actions", func(t *testing.T) {
|
||||
for _, action := range []TargetAccessAction{
|
||||
TargetAccessActionInherit,
|
||||
TargetAccessActionBypass,
|
||||
TargetAccessActionBlock,
|
||||
} {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = action
|
||||
require.NoError(t, rp.Validate(), "action %q should be valid", action)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown action", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = "allow"
|
||||
assert.ErrorContains(t, rp.Validate(), `unknown access_action "allow"`)
|
||||
})
|
||||
|
||||
t.Run("explicit empty action", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessActionProvided = true
|
||||
assert.ErrorContains(t, rp.Validate(), `unknown access_action ""`)
|
||||
})
|
||||
|
||||
t.Run("private bypass", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Private = true
|
||||
rp.AccessGroups = []string{"group-1"}
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
assert.ErrorContains(t, rp.Validate(), "not supported for private services")
|
||||
})
|
||||
|
||||
t.Run("Agent Network bypass", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
rp.Targets[0].Options.AgentNetwork = true
|
||||
assert.ErrorContains(t, rp.Validate(), "not supported for Agent Network targets")
|
||||
})
|
||||
|
||||
t.Run("block remains available to private services", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Private = true
|
||||
rp.AccessGroups = []string{"group-1"}
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("non HTTP service", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Mode = ModeTCP
|
||||
rp.ListenPort = 8080
|
||||
rp.Targets[0].Protocol = TargetProtoTCP
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
assert.ErrorContains(t, rp.Validate(), "only supported for HTTP services")
|
||||
})
|
||||
|
||||
t.Run("nil non HTTP target", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Mode = ModeTCP
|
||||
rp.ListenPort = 8080
|
||||
rp.Targets[0] = nil
|
||||
assert.ErrorContains(t, rp.Validate(), "target 0 is nil")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateTargetAccessActionPath(t *testing.T) {
|
||||
for _, value := range []string{"api", "/api/../admin", "/api/./admin", "/api//admin", "/api%2Fadmin", `/api\admin`, "/api?x=1", "/api#fragment", "/api;admin", "/api/\x00"} {
|
||||
t.Run(fmt.Sprintf("path_%q", value), func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].Path = &value
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
assert.ErrorContains(t, rp.Validate(), "access_action path")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("nil path is canonical root", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("empty path is legacy root", func(t *testing.T) {
|
||||
value := ""
|
||||
rp := validProxy()
|
||||
rp.Targets[0].Path = &value
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("trailing slash is a distinct valid prefix", func(t *testing.T) {
|
||||
value := "/api/"
|
||||
rp := validProxy()
|
||||
rp.Targets[0].Path = &value
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateDuplicateEnabledTargetPaths(t *testing.T) {
|
||||
emptyPath := ""
|
||||
rootPath := "/"
|
||||
rootAliases := []struct {
|
||||
name string
|
||||
path *string
|
||||
}{
|
||||
{name: "nil", path: nil},
|
||||
{name: "empty", path: &emptyPath},
|
||||
{name: "slash", path: &rootPath},
|
||||
}
|
||||
|
||||
for _, alias := range rootAliases {
|
||||
t.Run("legacy all-inherit "+alias.name, func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: alias.path,
|
||||
Host: "10.0.0.2",
|
||||
Port: 443,
|
||||
Protocol: TargetProtoHTTPS,
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, rp.Validate(), "legacy all-inherit path and protocol twins must remain valid")
|
||||
})
|
||||
|
||||
t.Run("access-controlled "+alias.name, func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: alias.path,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
AccessAction: TargetAccessActionBlock,
|
||||
})
|
||||
assert.ErrorContains(t, rp.Validate(), `duplicate path "/"`)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("access action elsewhere enables strict paths", func(t *testing.T) {
|
||||
publicPath := "/public"
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets,
|
||||
&Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
},
|
||||
&Target{
|
||||
TargetId: "peer-3",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: &publicPath,
|
||||
Host: "10.0.0.3",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
AccessAction: TargetAccessActionBypass,
|
||||
},
|
||||
)
|
||||
assert.ErrorContains(t, rp.Validate(), `duplicate path "/"`)
|
||||
})
|
||||
|
||||
t.Run("disabled action preserves legacy validation", func(t *testing.T) {
|
||||
publicPath := "/public"
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets,
|
||||
&Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
},
|
||||
&Target{
|
||||
TargetId: "peer-3",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: &publicPath,
|
||||
Host: "10.0.0.3",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: false,
|
||||
AccessAction: TargetAccessActionBlock,
|
||||
},
|
||||
)
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("disabled target does not collide", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: false,
|
||||
})
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("trailing slash remains distinct", func(t *testing.T) {
|
||||
apiPath := "/api"
|
||||
apiSlashPath := "/api/"
|
||||
rp := validProxy()
|
||||
rp.Targets[0].Path = &apiPath
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: &apiSlashPath,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
AccessAction: TargetAccessActionBlock,
|
||||
})
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidate_MultipleTargetsOneInvalid(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
@@ -308,6 +536,90 @@ func TestToProtoMapping_TargetOptions(t *testing.T) {
|
||||
assert.Equal(t, int64(30), opts.RequestTimeout.Seconds)
|
||||
}
|
||||
|
||||
func TestTargetAccessActionConversions(t *testing.T) {
|
||||
t.Run("API presence", func(t *testing.T) {
|
||||
bypass := api.ServiceTargetAccessActionBypass
|
||||
empty := api.ServiceTargetAccessAction("")
|
||||
apiTargets := []api.ServiceTarget{
|
||||
{
|
||||
TargetId: "peer-1",
|
||||
TargetType: api.ServiceTargetTargetTypePeer,
|
||||
Protocol: api.ServiceTargetProtocolHttp,
|
||||
Port: 80,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
TargetId: "peer-2",
|
||||
TargetType: api.ServiceTargetTargetTypePeer,
|
||||
Protocol: api.ServiceTargetProtocolHttp,
|
||||
Port: 80,
|
||||
Enabled: true,
|
||||
AccessAction: &bypass,
|
||||
},
|
||||
{
|
||||
TargetId: "peer-3",
|
||||
TargetType: api.ServiceTargetTargetTypePeer,
|
||||
Protocol: api.ServiceTargetProtocolHttp,
|
||||
Port: 80,
|
||||
Enabled: true,
|
||||
AccessAction: &empty,
|
||||
},
|
||||
}
|
||||
|
||||
targets, err := targetsFromAPI("account-1", &apiTargets)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, targets, 3)
|
||||
assert.Equal(t, TargetAccessActionInherit, targets[0].AccessAction)
|
||||
assert.False(t, targets[0].AccessActionProvided)
|
||||
assert.Equal(t, TargetAccessActionBypass, targets[1].AccessAction)
|
||||
assert.True(t, targets[1].AccessActionProvided)
|
||||
assert.Empty(t, targets[2].AccessAction)
|
||||
assert.True(t, targets[2].AccessActionProvided)
|
||||
|
||||
rp := &Service{Name: "test", Domain: "example.com", Targets: []*Target{targets[2]}}
|
||||
assert.ErrorContains(t, rp.Validate(), `unknown access_action ""`)
|
||||
})
|
||||
|
||||
t.Run("API response", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
response := rp.ToAPIResponse()
|
||||
require.Len(t, response.Targets, 1)
|
||||
require.NotNil(t, response.Targets[0].AccessAction)
|
||||
assert.Equal(t, api.ServiceTargetAccessActionBlock, *response.Targets[0].AccessAction)
|
||||
})
|
||||
|
||||
t.Run("proto mapping", func(t *testing.T) {
|
||||
root := "/"
|
||||
bypassPath := "/bypass"
|
||||
blockPath := "/block"
|
||||
rp := &Service{
|
||||
ID: "svc-1",
|
||||
AccountID: "acc-1",
|
||||
Domain: "example.com",
|
||||
Targets: []*Target{
|
||||
{Path: &root, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true},
|
||||
{Path: &bypassPath, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true, AccessAction: TargetAccessActionBypass},
|
||||
{Path: &blockPath, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true, AccessAction: TargetAccessActionBlock},
|
||||
},
|
||||
}
|
||||
|
||||
mapping := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
||||
require.Len(t, mapping.Path, 3)
|
||||
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT, mapping.Path[0].GetAccessAction())
|
||||
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS, mapping.Path[1].GetAccessAction())
|
||||
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, mapping.Path[2].GetAccessAction())
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasTargetAccessControl(t *testing.T) {
|
||||
rp := validProxy()
|
||||
assert.False(t, rp.HasTargetAccessControl())
|
||||
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
assert.True(t, rp.HasTargetAccessControl())
|
||||
}
|
||||
|
||||
// TestToProtoMapping_AllowedGroupIds covers the list the proxy gates session
|
||||
// cookies on: without it the proxy can only check a cookie's signature, which
|
||||
// makes a token minted for a user outside the groups a bearer credential.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// TargetAccessAction controls authentication for a selected HTTP target.
|
||||
type TargetAccessAction string
|
||||
|
||||
const (
|
||||
TargetAccessActionInherit TargetAccessAction = "inherit"
|
||||
TargetAccessActionBypass TargetAccessAction = "bypass"
|
||||
TargetAccessActionBlock TargetAccessAction = "block"
|
||||
)
|
||||
|
||||
func (t *Target) effectiveAccessAction() TargetAccessAction {
|
||||
if t.AccessAction == "" {
|
||||
return TargetAccessActionInherit
|
||||
}
|
||||
return t.AccessAction
|
||||
}
|
||||
|
||||
func targetAccessActionToProto(action TargetAccessAction) proto.TargetAccessAction {
|
||||
switch action {
|
||||
case TargetAccessActionInherit:
|
||||
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT
|
||||
case TargetAccessActionBypass:
|
||||
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS
|
||||
case TargetAccessActionBlock:
|
||||
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK
|
||||
default:
|
||||
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK
|
||||
}
|
||||
}
|
||||
|
||||
// HasTargetAccessControl reports whether any target overrides service authentication.
|
||||
func (s *Service) HasTargetAccessControl() bool {
|
||||
for _, target := range s.Targets {
|
||||
if target != nil && target.effectiveAccessAction() != TargetAccessActionInherit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateTargetAccessAction(idx int, target *Target, private bool) error {
|
||||
action, err := validatedTargetAccessAction(idx, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action == TargetAccessActionBypass {
|
||||
if private {
|
||||
return fmt.Errorf("target %d: bypass access_action is not supported for private services", idx)
|
||||
}
|
||||
if target.Options.AgentNetwork {
|
||||
return fmt.Errorf("target %d: bypass access_action is not supported for Agent Network targets", idx)
|
||||
}
|
||||
}
|
||||
|
||||
if action == TargetAccessActionInherit {
|
||||
return nil
|
||||
}
|
||||
return validateAccessActionPath(idx, target.Path)
|
||||
}
|
||||
|
||||
func validatedTargetAccessAction(idx int, target *Target) (TargetAccessAction, error) {
|
||||
if target.AccessActionProvided && target.AccessAction == "" {
|
||||
return "", fmt.Errorf("target %d: unknown access_action %q", idx, target.AccessAction)
|
||||
}
|
||||
action := target.effectiveAccessAction()
|
||||
switch action {
|
||||
case TargetAccessActionInherit, TargetAccessActionBypass, TargetAccessActionBlock:
|
||||
return action, nil
|
||||
default:
|
||||
return "", fmt.Errorf("target %d: unknown access_action %q", idx, target.AccessAction)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedTargetPath(configured *string) string {
|
||||
if configured == nil || *configured == "" {
|
||||
return "/"
|
||||
}
|
||||
return *configured
|
||||
}
|
||||
|
||||
func validateAccessActionPath(idx int, configured *string) error {
|
||||
if configured == nil {
|
||||
return nil
|
||||
}
|
||||
value := *configured
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(value, "/") {
|
||||
return fmt.Errorf("target %d: access_action path %q must start with /", idx, value)
|
||||
}
|
||||
if !utf8.ValidString(value) || strings.ContainsAny(value, "%\\?#;") || strings.IndexFunc(value, unicode.IsControl) >= 0 {
|
||||
return fmt.Errorf("target %d: access_action path %q contains invalid characters", idx, value)
|
||||
}
|
||||
segments := strings.Split(strings.TrimPrefix(value, "/"), "/")
|
||||
for i, segment := range segments {
|
||||
if segment == "." || segment == ".." {
|
||||
return fmt.Errorf("target %d: access_action path %q is not canonical", idx, value)
|
||||
}
|
||||
if segment == "" && value != "/" && i != len(segments)-1 {
|
||||
return fmt.Errorf("target %d: access_action path %q is not canonical", idx, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user