mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 08:59:08 +02:00
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.
171 lines
5.7 KiB
Go
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))
|
|
})
|
|
}
|
|
}
|