mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-14 01:19:09 +02:00
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.
1726 lines
52 KiB
Go
1726 lines
52 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
|
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
func validProxy() *Service {
|
|
return &Service{
|
|
Name: "test",
|
|
Domain: "example.com",
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestValidate_Valid(t *testing.T) {
|
|
require.NoError(t, validProxy().Validate())
|
|
}
|
|
|
|
func TestValidate_EmptyName(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Name = ""
|
|
assert.ErrorContains(t, rp.Validate(), "name is required")
|
|
}
|
|
|
|
func TestValidate_EmptyDomain(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Domain = ""
|
|
assert.ErrorContains(t, rp.Validate(), "domain is required")
|
|
}
|
|
|
|
func TestValidate_NoTargets(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets = nil
|
|
assert.ErrorContains(t, rp.Validate(), "at least one target is required")
|
|
}
|
|
|
|
func TestValidate_EmptyTargetId(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].TargetId = ""
|
|
assert.ErrorContains(t, rp.Validate(), "empty target_id")
|
|
}
|
|
|
|
func TestValidate_InvalidTargetType(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].TargetType = "invalid"
|
|
assert.ErrorContains(t, rp.Validate(), "invalid target_type")
|
|
}
|
|
|
|
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",
|
|
Enabled: true,
|
|
})
|
|
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{
|
|
TargetId: "",
|
|
TargetType: TargetTypePeer,
|
|
Host: "10.0.0.2",
|
|
Port: 80,
|
|
Protocol: "http",
|
|
Enabled: true,
|
|
})
|
|
err := rp.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "target 1")
|
|
assert.Contains(t, err.Error(), "empty target_id")
|
|
}
|
|
|
|
func TestValidateTargetOptions_PathRewrite(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mode PathRewriteMode
|
|
wantErr string
|
|
}{
|
|
{"empty is default", "", ""},
|
|
{"preserve is valid", PathRewritePreserve, ""},
|
|
{"unknown rejected", "regex", "unknown path_rewrite mode"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.PathRewrite = tt.mode
|
|
err := rp.Validate()
|
|
if tt.wantErr == "" {
|
|
assert.NoError(t, err)
|
|
} else {
|
|
assert.ErrorContains(t, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateTargetOptions_RequestTimeout(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
timeout time.Duration
|
|
wantErr string
|
|
}{
|
|
{"valid 30s", 30 * time.Second, ""},
|
|
{"valid 2m", 2 * time.Minute, ""},
|
|
{"valid 10m", 10 * time.Minute, ""},
|
|
{"zero is fine", 0, ""},
|
|
{"negative", -1 * time.Second, "must be positive"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.RequestTimeout = tt.timeout
|
|
err := rp.Validate()
|
|
if tt.wantErr == "" {
|
|
assert.NoError(t, err)
|
|
} else {
|
|
assert.ErrorContains(t, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateTargetOptions_CustomHeaders(t *testing.T) {
|
|
t.Run("valid headers", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.CustomHeaders = map[string]string{
|
|
"X-Custom": "value",
|
|
"X-Trace": "abc123",
|
|
}
|
|
assert.NoError(t, rp.Validate())
|
|
})
|
|
|
|
t.Run("CRLF in key", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Bad\r\nKey": "value"}
|
|
assert.ErrorContains(t, rp.Validate(), "not a valid HTTP header name")
|
|
})
|
|
|
|
t.Run("CRLF in value", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Good": "bad\nvalue"}
|
|
assert.ErrorContains(t, rp.Validate(), "invalid characters")
|
|
})
|
|
|
|
t.Run("hop-by-hop header rejected", func(t *testing.T) {
|
|
for _, h := range []string{"Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection"} {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.CustomHeaders = map[string]string{h: "value"}
|
|
assert.ErrorContains(t, rp.Validate(), "hop-by-hop", "header %q should be rejected", h)
|
|
}
|
|
})
|
|
|
|
t.Run("reserved header rejected", func(t *testing.T) {
|
|
for _, h := range []string{"X-Forwarded-For", "X-Real-IP", "X-Forwarded-Proto", "X-Forwarded-Host", "X-Forwarded-Port", "Cookie", "Forwarded", "Content-Length", "Content-Type"} {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.CustomHeaders = map[string]string{h: "value"}
|
|
assert.ErrorContains(t, rp.Validate(), "managed by the proxy", "header %q should be rejected", h)
|
|
}
|
|
})
|
|
|
|
t.Run("Host header rejected", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.CustomHeaders = map[string]string{"Host": "evil.com"}
|
|
assert.ErrorContains(t, rp.Validate(), "pass_host_header")
|
|
})
|
|
|
|
t.Run("too many headers", func(t *testing.T) {
|
|
rp := validProxy()
|
|
headers := make(map[string]string, 17)
|
|
for i := range 17 {
|
|
headers[fmt.Sprintf("X-H%d", i)] = "v"
|
|
}
|
|
rp.Targets[0].Options.CustomHeaders = headers
|
|
assert.ErrorContains(t, rp.Validate(), "exceeds maximum of 16")
|
|
})
|
|
|
|
t.Run("key too long", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.CustomHeaders = map[string]string{strings.Repeat("X", 129): "v"}
|
|
assert.ErrorContains(t, rp.Validate(), "key")
|
|
assert.ErrorContains(t, rp.Validate(), "exceeds maximum length")
|
|
})
|
|
|
|
t.Run("value too long", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Ok": strings.Repeat("v", 4097)}
|
|
assert.ErrorContains(t, rp.Validate(), "value exceeds maximum length")
|
|
})
|
|
|
|
t.Run("duplicate canonical keys rejected", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].Options.CustomHeaders = map[string]string{
|
|
"x-custom": "a",
|
|
"X-Custom": "b",
|
|
}
|
|
assert.ErrorContains(t, rp.Validate(), "collide")
|
|
})
|
|
}
|
|
|
|
func TestValidate_DirectUpstreamHost(t *testing.T) {
|
|
target := Target{TargetId: "id-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true, Options: TargetOptions{DirectUpstream: true}}
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "127.0.0.2")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.NotNil(t, validateDirectUpstreamHost(0, targetWithHost(&target, "127.0.0.2:80")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "::1")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "::1%lo0")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1]")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.NotNil(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1]:80")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1%lo0]")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.NotNil(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1%lo0]:80")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "169.254.100.100")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "fe80::1")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[fe80::1]")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "224.100.100.100")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "ff00::ffff")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[ff00::ffff]")), ErrUnsupportedIPAddressUpstreamHost)
|
|
|
|
// empty host
|
|
assert.Nil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: " "}))
|
|
// host with a space
|
|
assert.NotNil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with space"}))
|
|
// host with a tab
|
|
assert.NotNil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with\ttab"}))
|
|
// host with a slash
|
|
assert.NotNil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with/slash"}))
|
|
}
|
|
|
|
func TestValidate_ValidateSubnetTarget(t *testing.T) {
|
|
target := Target{TargetId: "id-1", TargetType: TargetTypeSubnet, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true, Options: TargetOptions{DirectUpstream: true}}
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "127.0.0.2")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.NotNil(t, validateSubnetTarget(0, targetWithHost(&target, "127.0.0.2:80")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "::1")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.NotNil(t, validateSubnetTarget(0, targetWithHost(&target, "[::1]:80")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "::1%lo0")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "[::1%lo0]")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.NotNil(t, validateSubnetTarget(0, targetWithHost(&target, "[::1%lo0]:80")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "169.254.100.100")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "fe80::1")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "[fe80::1]")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "224.100.100.100")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "ff00::ffff")), ErrUnsupportedIPAddressUpstreamHost)
|
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "[ff00::ffff]")), ErrUnsupportedIPAddressUpstreamHost)
|
|
|
|
// empty host
|
|
assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: " "}))
|
|
// host with a space
|
|
assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with space"}))
|
|
// host with a tab
|
|
assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with\ttab"}))
|
|
// host with a slash
|
|
assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with/slash"}))
|
|
}
|
|
|
|
func targetWithHost(t *Target, host string) *Target {
|
|
t.Host = host
|
|
return t
|
|
}
|
|
|
|
func TestToProtoMapping_TargetOptions(t *testing.T) {
|
|
rp := &Service{
|
|
ID: "svc-1",
|
|
AccountID: "acc-1",
|
|
Domain: "example.com",
|
|
Targets: []*Target{
|
|
{
|
|
TargetId: "peer-1",
|
|
TargetType: TargetTypePeer,
|
|
Host: "10.0.0.1",
|
|
Port: 8080,
|
|
Protocol: "http",
|
|
Enabled: true,
|
|
Options: TargetOptions{
|
|
SkipTLSVerify: true,
|
|
RequestTimeout: 30 * time.Second,
|
|
PathRewrite: PathRewritePreserve,
|
|
CustomHeaders: map[string]string{"X-Custom": "val"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
|
require.Len(t, pm.Path, 1)
|
|
|
|
opts := pm.Path[0].Options
|
|
require.NotNil(t, opts, "options should be populated")
|
|
assert.True(t, opts.SkipTlsVerify)
|
|
assert.Equal(t, proto.PathRewriteMode_PATH_REWRITE_PRESERVE, opts.PathRewrite)
|
|
assert.Equal(t, map[string]string{"X-Custom": "val"}, opts.CustomHeaders)
|
|
require.NotNil(t, opts.RequestTimeout)
|
|
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.
|
|
func TestToProtoMapping_AllowedGroupIds(t *testing.T) {
|
|
t.Run("distribution groups reach the proxy", func(t *testing.T) {
|
|
rp := &Service{
|
|
ID: "svc-1",
|
|
AccountID: "acc-1",
|
|
Domain: "example.com",
|
|
Auth: AuthConfig{
|
|
BearerAuth: &BearerAuthConfig{
|
|
Enabled: true,
|
|
DistributionGroups: []string{"grp-1", "grp-2"},
|
|
},
|
|
},
|
|
}
|
|
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
|
|
|
assert.True(t, pm.GetAuth().GetOidc())
|
|
assert.Equal(t, []string{"grp-1", "grp-2"}, pm.GetAuth().GetAllowedGroupIds())
|
|
})
|
|
|
|
t.Run("a service open to the account carries no groups", func(t *testing.T) {
|
|
rp := &Service{
|
|
ID: "svc-1",
|
|
AccountID: "acc-1",
|
|
Domain: "example.com",
|
|
Auth: AuthConfig{
|
|
BearerAuth: &BearerAuthConfig{Enabled: true},
|
|
},
|
|
}
|
|
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
|
|
|
assert.True(t, pm.GetAuth().GetOidc())
|
|
assert.Empty(t, pm.GetAuth().GetAllowedGroupIds(), "an empty list must not restrict access")
|
|
})
|
|
}
|
|
|
|
func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) {
|
|
rp := &Service{
|
|
ID: "svc-1",
|
|
AccountID: "acc-1",
|
|
Domain: "example.com",
|
|
Targets: []*Target{
|
|
{
|
|
TargetId: "peer-1",
|
|
TargetType: TargetTypePeer,
|
|
Host: "10.0.0.1",
|
|
Port: 8080,
|
|
Protocol: "http",
|
|
Enabled: true,
|
|
},
|
|
},
|
|
}
|
|
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
|
require.Len(t, pm.Path, 1)
|
|
assert.Nil(t, pm.Path[0].Options, "options should be nil when all defaults")
|
|
}
|
|
|
|
func TestIsDefaultPort(t *testing.T) {
|
|
tests := []struct {
|
|
scheme string
|
|
port uint16
|
|
want bool
|
|
}{
|
|
{"http", 80, true},
|
|
{"https", 443, true},
|
|
{"http", 443, false},
|
|
{"https", 80, false},
|
|
{"http", 8080, false},
|
|
{"https", 8443, false},
|
|
{"http", 0, false},
|
|
{"https", 0, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(fmt.Sprintf("%s/%d", tt.scheme, tt.port), func(t *testing.T) {
|
|
assert.Equal(t, tt.want, isDefaultPort(tt.scheme, tt.port))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToProtoMapping_PortInTargetURL(t *testing.T) {
|
|
oidcConfig := proxy.OIDCValidationConfig{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
protocol string
|
|
host string
|
|
port uint16
|
|
wantTarget string
|
|
}{
|
|
{
|
|
name: "http with default port 80 omits port",
|
|
protocol: "http",
|
|
host: "10.0.0.1",
|
|
port: 80,
|
|
wantTarget: "http://10.0.0.1/",
|
|
},
|
|
{
|
|
name: "https with default port 443 omits port",
|
|
protocol: "https",
|
|
host: "10.0.0.1",
|
|
port: 443,
|
|
wantTarget: "https://10.0.0.1/",
|
|
},
|
|
{
|
|
name: "port 0 omits port",
|
|
protocol: "http",
|
|
host: "10.0.0.1",
|
|
port: 0,
|
|
wantTarget: "http://10.0.0.1/",
|
|
},
|
|
{
|
|
name: "non-default port is included",
|
|
protocol: "http",
|
|
host: "10.0.0.1",
|
|
port: 8080,
|
|
wantTarget: "http://10.0.0.1:8080/",
|
|
},
|
|
{
|
|
name: "https with non-default port is included",
|
|
protocol: "https",
|
|
host: "10.0.0.1",
|
|
port: 8443,
|
|
wantTarget: "https://10.0.0.1:8443/",
|
|
},
|
|
{
|
|
name: "http port 443 is included",
|
|
protocol: "http",
|
|
host: "10.0.0.1",
|
|
port: 443,
|
|
wantTarget: "http://10.0.0.1:443/",
|
|
},
|
|
{
|
|
name: "https port 80 is included",
|
|
protocol: "https",
|
|
host: "10.0.0.1",
|
|
port: 80,
|
|
wantTarget: "https://10.0.0.1:80/",
|
|
},
|
|
{
|
|
name: "domain host without port is unchanged",
|
|
protocol: "http",
|
|
host: "example.com",
|
|
port: 0,
|
|
wantTarget: "http://example.com/",
|
|
},
|
|
{
|
|
name: "domain host with non-default port is unchanged",
|
|
protocol: "http",
|
|
host: "example.com",
|
|
port: 8080,
|
|
wantTarget: "http://example.com:8080/",
|
|
},
|
|
{
|
|
name: "ipv6 host without port is bracketed",
|
|
protocol: "http",
|
|
host: "fb00:cafe:1::3",
|
|
port: 0,
|
|
wantTarget: "http://[fb00:cafe:1::3]/",
|
|
},
|
|
{
|
|
name: "ipv6 host with default port omits port and brackets host",
|
|
protocol: "http",
|
|
host: "fb00:cafe:1::3",
|
|
port: 80,
|
|
wantTarget: "http://[fb00:cafe:1::3]/",
|
|
},
|
|
{
|
|
name: "ipv6 host with non-default port is bracketed",
|
|
protocol: "http",
|
|
host: "fb00:cafe:1::3",
|
|
port: 8080,
|
|
wantTarget: "http://[fb00:cafe:1::3]:8080/",
|
|
},
|
|
{
|
|
name: "ipv6 loopback without port is bracketed",
|
|
protocol: "http",
|
|
host: "::1",
|
|
port: 0,
|
|
wantTarget: "http://[::1]/",
|
|
},
|
|
{
|
|
name: "ipv6 host with 5-digit port is bracketed",
|
|
protocol: "http",
|
|
host: "fb00:cafe::1",
|
|
port: 18080,
|
|
wantTarget: "http://[fb00:cafe::1]:18080/",
|
|
},
|
|
{
|
|
name: "pre-bracketed ipv6 without port stays single-bracketed",
|
|
protocol: "http",
|
|
host: "[fb00:cafe::1]",
|
|
port: 0,
|
|
wantTarget: "http://[fb00:cafe::1]/",
|
|
},
|
|
{
|
|
name: "pre-bracketed ipv6 with port is not double-bracketed",
|
|
protocol: "http",
|
|
host: "[fb00:cafe::1]",
|
|
port: 8080,
|
|
wantTarget: "http://[fb00:cafe::1]:8080/",
|
|
},
|
|
{
|
|
name: "v4-mapped ipv6 host without port is bracketed",
|
|
protocol: "http",
|
|
host: "::ffff:10.0.0.1",
|
|
port: 0,
|
|
wantTarget: "http://[::ffff:10.0.0.1]/",
|
|
},
|
|
{
|
|
name: "full-form 8-group ipv6 without port is bracketed",
|
|
protocol: "http",
|
|
host: "fb00:cafe:1:0:0:0:0:3",
|
|
port: 0,
|
|
wantTarget: "http://[fb00:cafe:1:0:0:0:0:3]/",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rp := &Service{
|
|
ID: "test-id",
|
|
AccountID: "acc-1",
|
|
Domain: "example.com",
|
|
Targets: []*Target{
|
|
{
|
|
TargetId: "peer-1",
|
|
TargetType: TargetTypePeer,
|
|
Host: tt.host,
|
|
Port: tt.port,
|
|
Protocol: tt.protocol,
|
|
Enabled: true,
|
|
},
|
|
},
|
|
}
|
|
pm := rp.ToProtoMapping(Create, "token", oidcConfig)
|
|
require.Len(t, pm.Path, 1, "should have one path mapping")
|
|
assert.Equal(t, tt.wantTarget, pm.Path[0].Target)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToProtoMapping_DisabledTargetSkipped(t *testing.T) {
|
|
rp := &Service{
|
|
ID: "test-id",
|
|
AccountID: "acc-1",
|
|
Domain: "example.com",
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 8080, Protocol: "http", Enabled: false},
|
|
{TargetId: "peer-2", TargetType: TargetTypePeer, Host: "10.0.0.2", Port: 9090, Protocol: "http", Enabled: true},
|
|
},
|
|
}
|
|
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
|
require.Len(t, pm.Path, 1)
|
|
assert.Equal(t, "http://10.0.0.2:9090/", pm.Path[0].Target)
|
|
}
|
|
|
|
func TestToProtoMapping_OperationTypes(t *testing.T) {
|
|
rp := validProxy()
|
|
tests := []struct {
|
|
op Operation
|
|
want proto.ProxyMappingUpdateType
|
|
}{
|
|
{Create, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED},
|
|
{Update, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED},
|
|
{Delete, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.op), func(t *testing.T) {
|
|
pm := rp.ToProtoMapping(tt.op, "", proxy.OIDCValidationConfig{})
|
|
assert.Equal(t, tt.want, pm.Type)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthConfig_HashSecrets(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config *AuthConfig
|
|
wantErr bool
|
|
validate func(*testing.T, *AuthConfig)
|
|
}{
|
|
{
|
|
name: "hash password successfully",
|
|
config: &AuthConfig{
|
|
PasswordAuth: &PasswordAuthConfig{
|
|
Enabled: true,
|
|
Password: "testPassword123",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
validate: func(t *testing.T, config *AuthConfig) {
|
|
if !strings.HasPrefix(config.PasswordAuth.Password, "$argon2id$") {
|
|
t.Errorf("Password not hashed with argon2id, got: %s", config.PasswordAuth.Password)
|
|
}
|
|
// Verify the hash can be verified
|
|
if err := argon2id.Verify("testPassword123", config.PasswordAuth.Password); err != nil {
|
|
t.Errorf("Hash verification failed: %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "hash PIN successfully",
|
|
config: &AuthConfig{
|
|
PinAuth: &PINAuthConfig{
|
|
Enabled: true,
|
|
Pin: "123456",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
validate: func(t *testing.T, config *AuthConfig) {
|
|
if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") {
|
|
t.Errorf("PIN not hashed with argon2id, got: %s", config.PinAuth.Pin)
|
|
}
|
|
// Verify the hash can be verified
|
|
if err := argon2id.Verify("123456", config.PinAuth.Pin); err != nil {
|
|
t.Errorf("Hash verification failed: %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "hash both password and PIN",
|
|
config: &AuthConfig{
|
|
PasswordAuth: &PasswordAuthConfig{
|
|
Enabled: true,
|
|
Password: "password",
|
|
},
|
|
PinAuth: &PINAuthConfig{
|
|
Enabled: true,
|
|
Pin: "9999",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
validate: func(t *testing.T, config *AuthConfig) {
|
|
if !strings.HasPrefix(config.PasswordAuth.Password, "$argon2id$") {
|
|
t.Errorf("Password not hashed with argon2id")
|
|
}
|
|
if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") {
|
|
t.Errorf("PIN not hashed with argon2id")
|
|
}
|
|
if err := argon2id.Verify("password", config.PasswordAuth.Password); err != nil {
|
|
t.Errorf("Password hash verification failed: %v", err)
|
|
}
|
|
if err := argon2id.Verify("9999", config.PinAuth.Pin); err != nil {
|
|
t.Errorf("PIN hash verification failed: %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "skip disabled password auth",
|
|
config: &AuthConfig{
|
|
PasswordAuth: &PasswordAuthConfig{
|
|
Enabled: false,
|
|
Password: "password",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
validate: func(t *testing.T, config *AuthConfig) {
|
|
if config.PasswordAuth.Password != "password" {
|
|
t.Errorf("Disabled password auth should not be hashed")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "skip empty password",
|
|
config: &AuthConfig{
|
|
PasswordAuth: &PasswordAuthConfig{
|
|
Enabled: true,
|
|
Password: "",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
validate: func(t *testing.T, config *AuthConfig) {
|
|
if config.PasswordAuth.Password != "" {
|
|
t.Errorf("Empty password should remain empty")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "skip nil password auth",
|
|
config: &AuthConfig{
|
|
PasswordAuth: nil,
|
|
PinAuth: &PINAuthConfig{
|
|
Enabled: true,
|
|
Pin: "1234",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
validate: func(t *testing.T, config *AuthConfig) {
|
|
if config.PasswordAuth != nil {
|
|
t.Errorf("PasswordAuth should remain nil")
|
|
}
|
|
if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") {
|
|
t.Errorf("PIN should still be hashed")
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.config.HashSecrets()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("HashSecrets() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if tt.validate != nil {
|
|
tt.validate(t, tt.config)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthConfig_HashSecrets_VerifyIncorrectSecret(t *testing.T) {
|
|
config := &AuthConfig{
|
|
PasswordAuth: &PasswordAuthConfig{
|
|
Enabled: true,
|
|
Password: "correctPassword",
|
|
},
|
|
}
|
|
|
|
if err := config.HashSecrets(); err != nil {
|
|
t.Fatalf("HashSecrets() error = %v", err)
|
|
}
|
|
|
|
// Verify with wrong password should fail
|
|
err := argon2id.Verify("wrongPassword", config.PasswordAuth.Password)
|
|
if !errors.Is(err, argon2id.ErrMismatchedHashAndPassword) {
|
|
t.Errorf("Expected ErrMismatchedHashAndPassword, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthConfig_ClearSecrets(t *testing.T) {
|
|
config := &AuthConfig{
|
|
PasswordAuth: &PasswordAuthConfig{
|
|
Enabled: true,
|
|
Password: "hashedPassword",
|
|
},
|
|
PinAuth: &PINAuthConfig{
|
|
Enabled: true,
|
|
Pin: "hashedPin",
|
|
},
|
|
}
|
|
|
|
config.ClearSecrets()
|
|
|
|
if config.PasswordAuth.Password != "" {
|
|
t.Errorf("Password not cleared, got: %s", config.PasswordAuth.Password)
|
|
}
|
|
if config.PinAuth.Pin != "" {
|
|
t.Errorf("PIN not cleared, got: %s", config.PinAuth.Pin)
|
|
}
|
|
}
|
|
|
|
func TestGenerateExposeName(t *testing.T) {
|
|
t.Run("no prefix generates 12-char name", func(t *testing.T) {
|
|
name, err := GenerateExposeName("")
|
|
require.NoError(t, err)
|
|
assert.Len(t, name, 12)
|
|
assert.Regexp(t, `^[a-z0-9]+$`, name)
|
|
})
|
|
|
|
t.Run("with prefix generates prefix-XXXX", func(t *testing.T) {
|
|
name, err := GenerateExposeName("myapp")
|
|
require.NoError(t, err)
|
|
assert.True(t, strings.HasPrefix(name, "myapp-"), "name should start with prefix")
|
|
suffix := strings.TrimPrefix(name, "myapp-")
|
|
assert.Len(t, suffix, 4, "suffix should be 4 chars")
|
|
assert.Regexp(t, `^[a-z0-9]+$`, suffix)
|
|
})
|
|
|
|
t.Run("unique names", func(t *testing.T) {
|
|
names := make(map[string]bool)
|
|
for i := 0; i < 50; i++ {
|
|
name, err := GenerateExposeName("")
|
|
require.NoError(t, err)
|
|
names[name] = true
|
|
}
|
|
assert.Greater(t, len(names), 45, "should generate mostly unique names")
|
|
})
|
|
|
|
t.Run("valid prefixes", func(t *testing.T) {
|
|
validPrefixes := []string{"a", "ab", "a1", "my-app", "web-server-01", "a-b"}
|
|
for _, prefix := range validPrefixes {
|
|
name, err := GenerateExposeName(prefix)
|
|
assert.NoError(t, err, "prefix %q should be valid", prefix)
|
|
assert.True(t, strings.HasPrefix(name, prefix+"-"), "name should start with %q-", prefix)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid prefixes", func(t *testing.T) {
|
|
invalidPrefixes := []string{
|
|
"-starts-with-dash",
|
|
"ends-with-dash-",
|
|
"has.dots",
|
|
"HAS-UPPER",
|
|
"has spaces",
|
|
"has/slash",
|
|
"a--",
|
|
}
|
|
for _, prefix := range invalidPrefixes {
|
|
_, err := GenerateExposeName(prefix)
|
|
assert.Error(t, err, "prefix %q should be invalid", prefix)
|
|
assert.Contains(t, err.Error(), "invalid name prefix")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExposeServiceRequest_ToService(t *testing.T) {
|
|
t.Run("basic HTTP service", func(t *testing.T) {
|
|
req := &ExposeServiceRequest{
|
|
Port: 8080,
|
|
Mode: "http",
|
|
}
|
|
|
|
service := req.ToService("account-1", "peer-1", "mysvc")
|
|
|
|
assert.Equal(t, "account-1", service.AccountID)
|
|
assert.Equal(t, "mysvc", service.Name)
|
|
assert.True(t, service.Enabled)
|
|
assert.Empty(t, service.Domain, "domain should be empty when not specified")
|
|
require.Len(t, service.Targets, 1)
|
|
|
|
target := service.Targets[0]
|
|
assert.Equal(t, uint16(8080), target.Port)
|
|
assert.Equal(t, "http", target.Protocol)
|
|
assert.Equal(t, "peer-1", target.TargetId)
|
|
assert.Equal(t, TargetTypePeer, target.TargetType)
|
|
assert.True(t, target.Enabled)
|
|
assert.Equal(t, "account-1", target.AccountID)
|
|
})
|
|
|
|
t.Run("with custom domain", func(t *testing.T) {
|
|
req := &ExposeServiceRequest{
|
|
Port: 3000,
|
|
Domain: "example.com",
|
|
}
|
|
|
|
service := req.ToService("acc", "peer", "web")
|
|
assert.Equal(t, "web.example.com", service.Domain)
|
|
})
|
|
|
|
t.Run("with PIN auth", func(t *testing.T) {
|
|
req := &ExposeServiceRequest{
|
|
Port: 80,
|
|
Pin: "1234",
|
|
}
|
|
|
|
service := req.ToService("acc", "peer", "svc")
|
|
require.NotNil(t, service.Auth.PinAuth)
|
|
assert.True(t, service.Auth.PinAuth.Enabled)
|
|
assert.Equal(t, "1234", service.Auth.PinAuth.Pin)
|
|
assert.Nil(t, service.Auth.PasswordAuth)
|
|
assert.Nil(t, service.Auth.BearerAuth)
|
|
})
|
|
|
|
t.Run("with password auth", func(t *testing.T) {
|
|
req := &ExposeServiceRequest{
|
|
Port: 80,
|
|
Password: "secret",
|
|
}
|
|
|
|
service := req.ToService("acc", "peer", "svc")
|
|
require.NotNil(t, service.Auth.PasswordAuth)
|
|
assert.True(t, service.Auth.PasswordAuth.Enabled)
|
|
assert.Equal(t, "secret", service.Auth.PasswordAuth.Password)
|
|
})
|
|
|
|
t.Run("with user groups (bearer auth)", func(t *testing.T) {
|
|
req := &ExposeServiceRequest{
|
|
Port: 80,
|
|
UserGroups: []string{"admins", "devs"},
|
|
}
|
|
|
|
service := req.ToService("acc", "peer", "svc")
|
|
require.NotNil(t, service.Auth.BearerAuth)
|
|
assert.True(t, service.Auth.BearerAuth.Enabled)
|
|
assert.Equal(t, []string{"admins", "devs"}, service.Auth.BearerAuth.DistributionGroups)
|
|
})
|
|
|
|
t.Run("with all auth types", func(t *testing.T) {
|
|
req := &ExposeServiceRequest{
|
|
Port: 443,
|
|
Domain: "myco.com",
|
|
Pin: "9999",
|
|
Password: "pass",
|
|
UserGroups: []string{"ops"},
|
|
}
|
|
|
|
service := req.ToService("acc", "peer", "full")
|
|
assert.Equal(t, "full.myco.com", service.Domain)
|
|
require.NotNil(t, service.Auth.PinAuth)
|
|
require.NotNil(t, service.Auth.PasswordAuth)
|
|
require.NotNil(t, service.Auth.BearerAuth)
|
|
})
|
|
}
|
|
|
|
func TestValidate_TLSOnly(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tls-svc",
|
|
Mode: "tls",
|
|
Domain: "example.com",
|
|
ListenPort: 8443,
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
}
|
|
|
|
func TestValidate_TLSMissingListenPort(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tls-svc",
|
|
Mode: "tls",
|
|
Domain: "example.com",
|
|
ListenPort: 0,
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true},
|
|
},
|
|
}
|
|
assert.ErrorContains(t, rp.Validate(), "listen_port is required")
|
|
}
|
|
|
|
func TestValidate_TLSMissingDomain(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tls-svc",
|
|
Mode: "tls",
|
|
ListenPort: 8443,
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true},
|
|
},
|
|
}
|
|
assert.ErrorContains(t, rp.Validate(), "domain is required")
|
|
}
|
|
|
|
func TestValidate_TCPValid(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tcp-svc",
|
|
Mode: "tcp",
|
|
Domain: "cluster.test",
|
|
ListenPort: 5432,
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
}
|
|
|
|
func TestValidate_TCPMissingListenPort(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tcp-svc",
|
|
Mode: "tcp",
|
|
Domain: "cluster.test",
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate(), "TCP with listen_port=0 is valid (auto-assigned by manager)")
|
|
}
|
|
|
|
func TestValidate_L4MultipleTargets(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tcp-svc",
|
|
Mode: "tcp",
|
|
Domain: "cluster.test",
|
|
ListenPort: 5432,
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
|
|
{TargetId: "peer-2", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
|
|
},
|
|
}
|
|
assert.ErrorContains(t, rp.Validate(), "exactly one target")
|
|
}
|
|
|
|
func TestValidate_L4TargetMissingPort(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tcp-svc",
|
|
Mode: "tcp",
|
|
Domain: "cluster.test",
|
|
ListenPort: 5432,
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 0, Enabled: true},
|
|
},
|
|
}
|
|
assert.ErrorContains(t, rp.Validate(), "port is required")
|
|
}
|
|
|
|
func TestValidate_TLSInvalidTargetType(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tls-svc",
|
|
Mode: "tls",
|
|
Domain: "example.com",
|
|
ListenPort: 443,
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: "invalid", Protocol: "tcp", Port: 443, Enabled: true},
|
|
},
|
|
}
|
|
assert.Error(t, rp.Validate())
|
|
}
|
|
|
|
func TestValidate_TLSSubnetValid(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tls-subnet",
|
|
Mode: "tls",
|
|
Domain: "example.com",
|
|
ListenPort: 8443,
|
|
Targets: []*Target{
|
|
{TargetId: "subnet-1", TargetType: TargetTypeSubnet, Protocol: "tcp", Port: 443, Host: "10.0.0.5", Enabled: true},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
}
|
|
|
|
func TestValidate_L4DomainTargetValid(t *testing.T) {
|
|
modes := []struct {
|
|
mode string
|
|
port uint16
|
|
proto string
|
|
}{
|
|
{"tcp", 5432, "tcp"},
|
|
{"tls", 443, "tcp"},
|
|
{"udp", 5432, "udp"},
|
|
}
|
|
for _, m := range modes {
|
|
t.Run(m.mode, func(t *testing.T) {
|
|
rp := &Service{
|
|
Name: m.mode + "-domain",
|
|
Mode: m.mode,
|
|
Domain: "cluster.test",
|
|
ListenPort: m.port,
|
|
Targets: []*Target{
|
|
{TargetId: "resource-1", TargetType: TargetTypeDomain, Protocol: m.proto, Port: m.port, Enabled: true},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidate_HTTPProxyProtocolRejected(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets[0].ProxyProtocol = true
|
|
assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for HTTP")
|
|
}
|
|
|
|
func TestValidate_UDPProxyProtocolRejected(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "udp-svc",
|
|
Mode: "udp",
|
|
Domain: "cluster.test",
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "udp", Port: 5432, Enabled: true, ProxyProtocol: true},
|
|
},
|
|
}
|
|
assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for UDP")
|
|
}
|
|
|
|
func TestValidate_TCPProxyProtocolAllowed(t *testing.T) {
|
|
rp := &Service{
|
|
Name: "tcp-svc",
|
|
Mode: "tcp",
|
|
Domain: "cluster.test",
|
|
ListenPort: 5432,
|
|
Targets: []*Target{
|
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true, ProxyProtocol: true},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
}
|
|
|
|
func TestExposeServiceRequest_Validate_L4RejectsAuth(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
req ExposeServiceRequest
|
|
}{
|
|
{
|
|
name: "tcp with pin",
|
|
req: ExposeServiceRequest{Port: 8080, Mode: "tcp", Pin: "123456"},
|
|
},
|
|
{
|
|
name: "udp with password",
|
|
req: ExposeServiceRequest{Port: 8080, Mode: "udp", Password: "secret"},
|
|
},
|
|
{
|
|
name: "tls with user groups",
|
|
req: ExposeServiceRequest{Port: 443, Mode: "tls", UserGroups: []string{"admins"}},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.req.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "authentication is not supported")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExposeServiceRequest_Validate_HTTPAllowsAuth(t *testing.T) {
|
|
req := ExposeServiceRequest{Port: 8080, Mode: "http", Pin: "123456"}
|
|
require.NoError(t, req.Validate())
|
|
}
|
|
|
|
func TestValidate_HeaderAuths(t *testing.T) {
|
|
t.Run("single valid header", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Auth = AuthConfig{
|
|
HeaderAuths: []*HeaderAuthConfig{
|
|
{Enabled: true, Header: "X-API-Key", Value: "secret"},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
})
|
|
|
|
t.Run("multiple headers same canonical name allowed", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Auth = AuthConfig{
|
|
HeaderAuths: []*HeaderAuthConfig{
|
|
{Enabled: true, Header: "Authorization", Value: "Bearer token-1"},
|
|
{Enabled: true, Header: "Authorization", Value: "Bearer token-2"},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
})
|
|
|
|
t.Run("multiple headers different case same canonical allowed", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Auth = AuthConfig{
|
|
HeaderAuths: []*HeaderAuthConfig{
|
|
{Enabled: true, Header: "x-api-key", Value: "key-1"},
|
|
{Enabled: true, Header: "X-Api-Key", Value: "key-2"},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
})
|
|
|
|
t.Run("multiple different headers allowed", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Auth = AuthConfig{
|
|
HeaderAuths: []*HeaderAuthConfig{
|
|
{Enabled: true, Header: "Authorization", Value: "Bearer tok"},
|
|
{Enabled: true, Header: "X-API-Key", Value: "key"},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
})
|
|
|
|
t.Run("empty header name rejected", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Auth = AuthConfig{
|
|
HeaderAuths: []*HeaderAuthConfig{
|
|
{Enabled: true, Header: "", Value: "val"},
|
|
},
|
|
}
|
|
err := rp.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "header name is required")
|
|
})
|
|
|
|
t.Run("hop-by-hop header rejected", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Auth = AuthConfig{
|
|
HeaderAuths: []*HeaderAuthConfig{
|
|
{Enabled: true, Header: "Connection", Value: "val"},
|
|
},
|
|
}
|
|
err := rp.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "hop-by-hop")
|
|
})
|
|
|
|
t.Run("host header rejected", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Auth = AuthConfig{
|
|
HeaderAuths: []*HeaderAuthConfig{
|
|
{Enabled: true, Header: "Host", Value: "val"},
|
|
},
|
|
}
|
|
err := rp.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "Host header cannot be used")
|
|
})
|
|
|
|
t.Run("disabled entries skipped", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Auth = AuthConfig{
|
|
HeaderAuths: []*HeaderAuthConfig{
|
|
{Enabled: false, Header: "", Value: ""},
|
|
{Enabled: true, Header: "X-Key", Value: "val"},
|
|
},
|
|
}
|
|
require.NoError(t, rp.Validate())
|
|
})
|
|
|
|
t.Run("value too long rejected", func(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Auth = AuthConfig{
|
|
HeaderAuths: []*HeaderAuthConfig{
|
|
{Enabled: true, Header: "X-Key", Value: strings.Repeat("a", maxHeaderValueLen+1)},
|
|
},
|
|
}
|
|
err := rp.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "exceeds maximum length")
|
|
})
|
|
}
|
|
|
|
func TestValidate_HTTPClusterTarget(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets = []*Target{{
|
|
TargetId: "eu.proxy.netbird.io",
|
|
TargetType: TargetTypeCluster,
|
|
Protocol: "http",
|
|
Host: "backend.lan",
|
|
Options: TargetOptions{DirectUpstream: true},
|
|
Enabled: true,
|
|
}}
|
|
require.NoError(t, rp.Validate(), "HTTP cluster target with target_id, host, and direct_upstream must validate")
|
|
}
|
|
|
|
func TestValidate_HTTPClusterTarget_RequiresTargetId(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets = []*Target{{
|
|
TargetType: TargetTypeCluster,
|
|
Protocol: "http",
|
|
Host: "backend.lan",
|
|
Options: TargetOptions{DirectUpstream: true},
|
|
Enabled: true,
|
|
}}
|
|
assert.ErrorContains(t, rp.Validate(), "empty target_id", "cluster target must reject empty target_id")
|
|
}
|
|
|
|
// TestValidate_HTTPClusterTarget_RequiresHost pins the new cluster-target
|
|
// rule that operator-supplied Host is mandatory: cluster targets dial the
|
|
// upstream via the host network stack (direct_upstream is implied), so an
|
|
// empty Host leaves the proxy with nothing to dial.
|
|
func TestValidate_HTTPClusterTarget_RequiresHost(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets = []*Target{{
|
|
TargetId: "eu.proxy.netbird.io",
|
|
TargetType: TargetTypeCluster,
|
|
Protocol: "http",
|
|
Options: TargetOptions{DirectUpstream: true},
|
|
Enabled: true,
|
|
}}
|
|
assert.ErrorContains(t, rp.Validate(), "empty host", "cluster target must reject empty host")
|
|
}
|
|
|
|
// TestValidate_HTTPClusterTarget_RequiresDirectUpstream pins the second
|
|
// half of the cluster-target rule: DirectUpstream must be true so the
|
|
// stdlib transport branch in MultiTransport is taken. Without it the
|
|
// embedded NetBird client would try to dial the cluster address through
|
|
// the WG tunnel, which is the wrong network for a cluster upstream.
|
|
func TestValidate_HTTPClusterTarget_RequiresDirectUpstream(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Targets = []*Target{{
|
|
TargetId: "eu.proxy.netbird.io",
|
|
TargetType: TargetTypeCluster,
|
|
Protocol: "http",
|
|
Host: "backend.lan",
|
|
Enabled: true,
|
|
}}
|
|
assert.ErrorContains(t, rp.Validate(), "direct upstream disabled", "cluster target must reject direct_upstream=false")
|
|
}
|
|
|
|
// TestValidate_L4ClusterTarget_RequiresPort confirms that an L4 cluster
|
|
// target without an explicit port is rejected. buildPathMappings emits
|
|
// net.JoinHostPort(target.Host, target.Port) for every L4 target — so
|
|
// allowing port=0 would let the proxy ship ":0" upstreams. The port
|
|
// requirement is the same as every other L4 target type.
|
|
func TestValidate_L4ClusterTarget_RequiresPort(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Mode = ModeTCP
|
|
rp.ListenPort = 9000
|
|
rp.Targets = []*Target{{
|
|
TargetId: "eu.proxy.netbird.io",
|
|
TargetType: TargetTypeCluster,
|
|
Protocol: "tcp",
|
|
Enabled: true,
|
|
}}
|
|
assert.ErrorContains(t, rp.Validate(), "port is required",
|
|
"L4 cluster target must require an explicit port like other L4 target types")
|
|
|
|
rp.Targets[0].Port = 5432
|
|
rp.Targets[0].Host = "db.lan"
|
|
require.NoError(t, rp.Validate(), "L4 cluster target with host:port must validate")
|
|
}
|
|
|
|
func TestService_Copy_RoundtripsPrivate(t *testing.T) {
|
|
svc := validProxy()
|
|
svc.Private = true
|
|
svc.AccessGroups = []string{"grp-admins", "grp-ops"}
|
|
cp := svc.Copy()
|
|
require.NotNil(t, cp)
|
|
assert.True(t, cp.Private)
|
|
assert.Equal(t, []string{"grp-admins", "grp-ops"}, cp.AccessGroups)
|
|
|
|
cp.Private = false
|
|
assert.True(t, svc.Private)
|
|
|
|
cp.AccessGroups[0] = "grp-other"
|
|
assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
|
|
}
|
|
|
|
func TestService_APIRoundtrip_Private(t *testing.T) {
|
|
enabled := true
|
|
private := true
|
|
accessGroups := []string{"grp-admins"}
|
|
targets := []api.ServiceTarget{{
|
|
TargetId: "eu.proxy.netbird.io",
|
|
TargetType: api.ServiceTargetTargetType("cluster"),
|
|
Protocol: "http",
|
|
Port: 80,
|
|
Enabled: true,
|
|
}}
|
|
req := &api.ServiceRequest{
|
|
Name: "svc-private",
|
|
Domain: "myapp.eu.proxy.netbird.io",
|
|
Enabled: enabled,
|
|
Private: &private,
|
|
AccessGroups: &accessGroups,
|
|
Targets: &targets,
|
|
}
|
|
|
|
svc := &Service{}
|
|
require.NoError(t, svc.FromAPIRequest(req, "acc-1"))
|
|
assert.True(t, svc.Private)
|
|
assert.Equal(t, []string{"grp-admins"}, svc.AccessGroups)
|
|
|
|
resp := svc.ToAPIResponse()
|
|
require.NotNil(t, resp.Private)
|
|
assert.True(t, *resp.Private)
|
|
require.NotNil(t, resp.AccessGroups)
|
|
assert.Equal(t, []string{"grp-admins"}, *resp.AccessGroups)
|
|
}
|
|
|
|
func TestValidate_Private_RequiresAccessGroups(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Private = true
|
|
rp.Targets = []*Target{{
|
|
TargetId: "eu.proxy.netbird.io",
|
|
TargetType: TargetTypeCluster,
|
|
Protocol: "http",
|
|
Host: "backend.lan",
|
|
Options: TargetOptions{DirectUpstream: true},
|
|
Enabled: true,
|
|
}}
|
|
assert.ErrorContains(t, rp.Validate(), "access group")
|
|
}
|
|
|
|
func TestValidate_Private_RejectsBearerAuth(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Private = true
|
|
rp.AccessGroups = []string{"grp-admins"}
|
|
rp.Auth.BearerAuth = &BearerAuthConfig{
|
|
Enabled: true,
|
|
DistributionGroups: []string{"grp-sso"},
|
|
}
|
|
rp.Targets = []*Target{{
|
|
TargetId: "eu.proxy.netbird.io",
|
|
TargetType: TargetTypeCluster,
|
|
Protocol: "http",
|
|
Host: "backend.lan",
|
|
Options: TargetOptions{DirectUpstream: true},
|
|
Enabled: true,
|
|
}}
|
|
assert.ErrorContains(t, rp.Validate(), "mutually exclusive")
|
|
}
|
|
|
|
func TestValidate_Private_AcceptsNonClusterTargets(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Private = true
|
|
rp.AccessGroups = []string{"grp-admins"}
|
|
require.NoError(t, rp.Validate())
|
|
}
|
|
|
|
func TestValidate_Private_AcceptsClusterTargetWithAccessGroups(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Private = true
|
|
rp.AccessGroups = []string{"grp-admins"}
|
|
rp.Targets = []*Target{{
|
|
TargetId: "eu.proxy.netbird.io",
|
|
TargetType: TargetTypeCluster,
|
|
Protocol: "http",
|
|
Host: "backend.lan",
|
|
Options: TargetOptions{DirectUpstream: true},
|
|
Enabled: true,
|
|
}}
|
|
require.NoError(t, rp.Validate())
|
|
}
|
|
|
|
func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
|
|
rp := validProxy()
|
|
rp.Private = true
|
|
rp.AccessGroups = []string{"grp-admins"}
|
|
rp.Mode = ModeTCP
|
|
rp.Targets = []*Target{{
|
|
TargetId: "eu.proxy.netbird.io",
|
|
TargetType: TargetTypeCluster,
|
|
Protocol: "tcp",
|
|
Enabled: true,
|
|
}}
|
|
assert.ErrorContains(t, rp.Validate(), "HTTP")
|
|
}
|