mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
* fix(proxy): gate tunnel-peer fast-path on inbound listener marker
forwardWithTunnelPeer previously accepted any RFC1918 / ULA / CGNAT
source IP, so a public client whose address happened to fall in those
ranges could bypass the configured operator auth scheme by colliding
with a known tunnel IP. The fast-path is now gated on
TunnelLookupFromContext(r.Context()) being present — that context value
is attached only by the per-account inbound (overlay) listener, so the
host-facing listener never enters this branch.
Tests updated to reflect the new requirement: requests that don't
carry the inbound marker now fall through to the regular auth flow.
* fix(proxy): harden inbound listener resource + startup-ctx handling
Three correctness fixes on the per-account inbound path, with tests:
- Close the logrus ErrorLog PipeWriter on tearDown. WriterLevel hands
back an *io.PipeWriter backed by a pipe + scanner goroutine that the
caller owns; the two writers per account (https + plain) were never
closed, leaking the pipe and goroutine on every teardown.
- Run the post-Start hooks on context.Background(). runClientStartup
is launched in a goroutine from AddPeer and was inheriting the
caller's request-scoped ctx, so a cancelled request could abort the
inbound bring-up or fail the management status notification. The
tail is split into notifyClientReady so the contract is testable.
Tests cover the PipeWriter close behaviour and assert the readyHandler
+ NotifyStatus calls receive a non-cancelled background context.
* feat(proxy): short-circuit peer-own-target loops with 421
When a peer that hosts the target of a private service dials its own
service URL the request was being looped through the proxy and back
over WireGuard to the same peer — twice the WG round-trip for no
benefit, with no signal to the caller that something was wrong.
Add isSelfTargetLoop to ReverseProxy.ServeHTTP: when the request
arrived on the per-account overlay listener (IsOverlayOrigin) and the
source tunnel IP matches the target host, refuse the request with 421
Misdirected Request and a body pointing the operator at the backend
directly.
The gate is scoped to overlay origin so requests on the public
listener that happen to share a source IP with the target host are
forwarded normally.
* fix(management): private-service validation + tunnel-IP lookup semantics
- Require an explicit port for L4 cluster targets. validateL4Target
exempted TargetTypeCluster from the port check, but buildPathMappings
serializes every L4 target via net.JoinHostPort(host, port) — port=0
shipped a ":0" upstream. Cluster targets use the same Host/Port
fields, so the same requirement applies.
- GetPeerByIP returns NotFound on a tunnel-IP miss instead of mapping
every error to Internal. The proxy's ValidateTunnelPeer probes IPs
that legitimately aren't in the roster; the miss is expected and now
distinguishable from a real store failure.
- Thread ctx into getClusterCapability's gorm query so a cancelled
request doesn't keep the store busy.
Tests updated for the L4-cluster port requirement and the GetPeerByIP
NotFound path.
* fix(client): include offlinePeers in PeerStateByIP lookup
ReplaceOfflinePeers moves peers into d.offlinePeers but PeerStateByIP
only scanned d.peers. Callers (the local DNS filter via
localPeerConnectivity, embed.Client.IdentityForIP used by the
proxy's tunnel-peer validator) were treating known-but-offline peers
as unknown, which:
- causes the DNS filter to keep returning records pointing at peers
that have no live tunnel, AND
- makes the proxy's local-roster check deny a request from such a
peer rather than letting the cached management RPC carry the
authorisation decision.
Search both slices in PeerStateByIP. Adds a unit test for the IPv4
and IPv6 offline-match paths.
* fix(rest): reject empty Delete path params in reverse-proxy clients
ReverseProxyClustersAPI.Delete and ReverseProxyTokensAPI.Delete passed
the path parameter into url.PathEscape without an empty check.
PathEscape("") returns "" which collapses the request onto the
collection endpoint ("/api/reverse-proxies/clusters/" /
"/api/reverse-proxies/proxy-tokens/"), so a caller bug delete with no
id reached a routable URL with surprising semantics (typically 405).
Short-circuit with a typed error before the request is built. Tests
mount a handler on the collection path that fails the test if hit, so
the regression is impossible to reintroduce silently.
* chore(api,ci,docs,test): private-service schema, proto-check, fixups
Non-functional cleanups and contract/CI hardening around the
private-service work:
API schema (openapi.yml):
- Require a non-empty access_groups and mode=http when private=true,
on both Service and ServiceRequest, mirroring
validatePrivateRequirements. mode stays optional-but-constrained
(empty defaults to http server-side), matching runtime.
CI (proto-version-check.yml):
- Cover renamed .pb.go files (read base via previous_filename).
- Match protoc-gen-go-grpc version headers (optional "- " prefix and
-gen-go-grpc suffix) so grpc-generated files are in scope.
Docs / comments:
- Reword Config field docs to say defaults are applied at Server.Start
(initDefaults), not New.
- Rename the obsolete --private-inbound flag to --private across
comments and the proto doc.
Pre-existing test fixups surfaced by review:
- Repair the integration-tagged validate_session_test.go (SignToken
signature growth + new Manager interface methods).
- Fix the CI-skip boolean precedence so Windows isn't skipped
unconditionally.
- Guard the router.HTTPListener type assertion with comma-ok.
* fix(proxy): background ctx for already-started AddPeer notification
The earlier ctx fix covered the async runClientStartup path but missed
the synchronous branch: when a service is added to an already-started
client, AddPeer called NotifyStatus with the caller's request-scoped
ctx. A cancelled request/stream could drop the connected notification
to management. Use context.Background() here too, matching
notifyClientReady.
Extends TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus to
pass a pre-cancelled caller ctx and assert the notification still ran
on a non-cancelled context.
* use the cmd context for roundtripper
1318 lines
36 KiB
Go
1318 lines
36 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()
|
|
rp.Targets = append(rp.Targets, &Target{
|
|
TargetId: "resource-1",
|
|
TargetType: TargetTypeHost,
|
|
Host: "example.org",
|
|
Port: 443,
|
|
Protocol: "https",
|
|
Enabled: true,
|
|
})
|
|
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 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 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")
|
|
}
|