Files
netbird/client/link/forward_test.go
T
Maycon Santos 66a28fd666 [client] Add nblink, an unprivileged forwarder into the network (#7955)
Reaching an internal service meant installing the agent, which needs root
for a TUN device. nblink runs the embedded client in netstack mode and
forwards local HTTP listeners to upstreams inside the tunnel, so it works on
managed machines and in rootless containers. Upstream names resolve
in-process.

Forwards bind loopback unless --allow-public-bind is given. The listener
refuses rebound and cross-site browser requests and bounds stalled request
bodies. It ships as release archives and a distroless image that runs under
an arbitrary UID.

An embedded client given no StatePath now keeps its state in memory rather
than falling back to the installed agent's state file, which every embedded
client in a proxy process also shared. The state manager gets an explicit
in-memory store for it.
2026-10-12 13:54:15 +02:00

171 lines
5.7 KiB
Go

package link
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseForward(t *testing.T) {
tests := []struct {
name string
spec string
wantListen string
wantUpstream string
}{
{
name: "full listen address",
spec: "http://127.0.0.1:8080=https://grafana.internal",
wantListen: "127.0.0.1:8080",
wantUpstream: "https://grafana.internal",
},
{
name: "bare port defaults to loopback",
spec: "http://8080=https://grafana.internal",
wantListen: "127.0.0.1:8080",
wantUpstream: "https://grafana.internal",
},
{
name: "empty host defaults to loopback",
spec: "http://:8080=https://grafana.internal",
wantListen: "127.0.0.1:8080",
wantUpstream: "https://grafana.internal",
},
{
name: "upstream without scheme assumes https",
spec: "http://8080=grafana.internal",
wantListen: "127.0.0.1:8080",
wantUpstream: "https://grafana.internal",
},
{
name: "plain http upstream is preserved",
spec: "http://8080=http://grafana.internal:3000",
wantListen: "127.0.0.1:8080",
wantUpstream: "http://grafana.internal:3000",
},
{
name: "upstream query string survives the separator split",
spec: "http://8080=https://grafana.internal/d?orgId=1",
wantListen: "127.0.0.1:8080",
wantUpstream: "https://grafana.internal/d?orgId=1",
},
{
name: "explicit public bind is parsed, gating happens later",
spec: "http://0.0.0.0:8080=https://grafana.internal",
wantListen: "0.0.0.0:8080",
wantUpstream: "https://grafana.internal",
},
{
name: "ipv6 listen address",
spec: "http://[::1]:8080=https://grafana.internal",
wantListen: "[::1]:8080",
wantUpstream: "https://grafana.internal",
},
{
name: "port zero asks the OS for a free port",
spec: "http://0=https://grafana.internal",
wantListen: "127.0.0.1:0",
wantUpstream: "https://grafana.internal",
},
{
name: "localhost normalizes to a literal address",
spec: "http://localhost:8080=https://grafana.internal",
wantListen: "127.0.0.1:8080",
wantUpstream: "https://grafana.internal",
},
{
// A zone is part of the literal and is resolved the same way at
// the check and at the bind, and link-local cannot be bound
// without it.
name: "zone scoped ipv6 listen address",
spec: "http://[fe80::1%eth0]:8080=https://grafana.internal",
wantListen: "[fe80::1%eth0]:8080",
wantUpstream: "https://grafana.internal",
},
{
name: "a scheme inside the query is not mistaken for the upstream scheme",
spec: "http://8080=grafana.internal/login?next=https://grafana.internal/d",
wantListen: "127.0.0.1:8080",
wantUpstream: "https://grafana.internal/login?next=https://grafana.internal/d",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseForward(tc.spec)
require.NoError(t, err)
assert.Equal(t, ProtoHTTP, got.Proto, "scheme should select the HTTP forwarder")
assert.Equal(t, tc.wantListen, got.Listen, "listen address should be normalized")
assert.Equal(t, tc.wantUpstream, got.Upstream.String(), "upstream should round-trip")
})
}
}
func TestParseForwardErrors(t *testing.T) {
tests := []struct {
name string
spec string
wantMsg string
}{
{name: "empty", spec: "", wantMsg: "empty forward spec"},
{name: "no scheme", spec: "8080=grafana.internal", wantMsg: "missing scheme"},
{name: "no separator", spec: "http://8080", wantMsg: "missing '='"},
{name: "unknown scheme", spec: "gopher://8080=grafana.internal", wantMsg: "unknown scheme"},
{name: "port out of range", spec: "http://70000=grafana.internal", wantMsg: "out of range"},
{name: "port not a number", spec: "http://http=grafana.internal", wantMsg: "not a number"},
{name: "missing upstream", spec: "http://8080=", wantMsg: "missing upstream"},
{name: "bad upstream scheme", spec: "http://8080=ftp://grafana.internal", wantMsg: "must be http or https"},
{name: "listen host is a name", spec: "http://grafana.internal:8080=https://a.internal", wantMsg: "must be an IP address or localhost"},
{name: "upstream carries credentials", spec: "http://8080=https://user:pass@grafana.internal", wantMsg: "must not carry credentials"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ParseForward(tc.spec)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantMsg, "error should name the actual problem")
})
}
}
// Schemes the grammar reserves for later must fail with a message that says so,
// rather than looking like a typo.
func TestParseForwardPlannedSchemes(t *testing.T) {
for _, proto := range []string{"tcp", "udp", "socks5"} {
t.Run(proto, func(t *testing.T) {
_, err := ParseForward(proto + "://5432=db.internal:5432")
require.Error(t, err)
assert.Contains(t, err.Error(), "not supported yet",
"a reserved scheme should report that it is unimplemented")
})
}
}
func TestIsLoopback(t *testing.T) {
tests := []struct {
addr string
want bool
}{
{"127.0.0.1:8080", true},
{"127.0.0.53:8080", true},
{"[::1]:8080", true},
{"0.0.0.0:8080", false},
{"192.168.1.10:8080", false},
{"[::]:8080", false},
{"not-an-address", false},
// A v4-mapped form is still the loopback address.
{"[::ffff:127.0.0.1]:8080", true},
// Link-local is not loopback, so it stays behind the opt-in.
{"[fe80::1%eth0]:8080", false},
// Names never reach this check, because parseListen rejects them.
{"localhost:8080", false},
}
for _, tc := range tests {
t.Run(tc.addr, func(t *testing.T) {
assert.Equal(t, tc.want, isLoopback(tc.addr))
})
}
}