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
300 lines
8.2 KiB
Go
300 lines
8.2 KiB
Go
package peer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestAddPeer(t *testing.T) {
|
|
key := "abc"
|
|
ip := "100.108.254.1"
|
|
status := NewRecorder("https://mgm")
|
|
err := status.AddPeer(key, "abc.netbird", ip, "")
|
|
assert.NoError(t, err, "shouldn't return error")
|
|
|
|
_, exists := status.peers[key]
|
|
assert.True(t, exists, "value was found")
|
|
|
|
err = status.AddPeer(key, "abc.netbird", ip, "")
|
|
|
|
assert.Error(t, err, "should return error on duplicate")
|
|
}
|
|
|
|
func TestGetPeer(t *testing.T) {
|
|
key := "abc"
|
|
ip := "100.108.254.1"
|
|
status := NewRecorder("https://mgm")
|
|
err := status.AddPeer(key, "abc.netbird", ip, "")
|
|
assert.NoError(t, err, "shouldn't return error")
|
|
|
|
peerStatus, err := status.GetPeer(key)
|
|
assert.NoError(t, err, "shouldn't return error on getting peer")
|
|
|
|
assert.Equal(t, key, peerStatus.PubKey, "retrieved public key should match")
|
|
|
|
_, err = status.GetPeer("non_existing_key")
|
|
assert.Error(t, err, "should return error when peer doesn't exist")
|
|
}
|
|
|
|
func TestUpdatePeerState(t *testing.T) {
|
|
key := "abc"
|
|
ip := "10.10.10.10"
|
|
fqdn := "peer-a.netbird.local"
|
|
status := NewRecorder("https://mgm")
|
|
require.NoError(t, status.AddPeer(key, fqdn, ip, ""))
|
|
|
|
peerState := State{
|
|
PubKey: key,
|
|
ConnStatusUpdate: time.Now(),
|
|
ConnStatus: StatusConnecting,
|
|
}
|
|
|
|
err := status.UpdatePeerState(peerState)
|
|
assert.NoError(t, err, "shouldn't return error")
|
|
|
|
state, exists := status.peers[key]
|
|
assert.True(t, exists, "state should be found")
|
|
assert.Equal(t, ip, state.IP, "ip should be equal")
|
|
}
|
|
|
|
func TestStatus_PeerStateByIP(t *testing.T) {
|
|
status := NewRecorder("https://mgm")
|
|
req := require.New(t)
|
|
|
|
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", ""))
|
|
req.NoError(status.AddPeer("pk-2", "peer-2.netbird", "100.64.0.11", ""))
|
|
|
|
state, ok := status.PeerStateByIP("100.64.0.10")
|
|
req.True(ok, "known tunnel IP should resolve to a peer state")
|
|
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
|
req.Equal("peer-1.netbird", state.FQDN, "matching state must carry the right FQDN")
|
|
|
|
_, ok = status.PeerStateByIP("100.64.0.99")
|
|
req.False(ok, "unknown IP must report ok=false")
|
|
}
|
|
|
|
func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
|
|
status := NewRecorder("https://mgm")
|
|
req := require.New(t)
|
|
|
|
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
|
|
|
|
state, ok := status.PeerStateByIP("fd00::1")
|
|
req.True(ok, "IPv6-only match must resolve to the peer state")
|
|
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
|
}
|
|
|
|
// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
|
|
// been moved into the offline slice via ReplaceOfflinePeers. Callers
|
|
// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
|
|
// rather than unknown — otherwise authentication / DNS filtering treats
|
|
// known-but-offline peers as foreign IPs.
|
|
func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
|
|
status := NewRecorder("https://mgm")
|
|
req := require.New(t)
|
|
|
|
status.ReplaceOfflinePeers([]State{
|
|
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
|
|
})
|
|
|
|
state, ok := status.PeerStateByIP("100.64.0.20")
|
|
req.True(ok, "offline peer must resolve by IPv4 tunnel address")
|
|
req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
|
|
|
|
state, ok = status.PeerStateByIP("fd00::20")
|
|
req.True(ok, "offline peer must resolve by IPv6 tunnel address")
|
|
req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
|
|
}
|
|
|
|
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
|
key := "abc"
|
|
fqdn := "peer-a.netbird.local"
|
|
status := NewRecorder("https://mgm")
|
|
peerState := State{
|
|
PubKey: key,
|
|
Mux: new(sync.RWMutex),
|
|
}
|
|
|
|
status.peers[key] = peerState
|
|
|
|
err := status.UpdatePeerFQDN(key, fqdn)
|
|
assert.NoError(t, err, "shouldn't return error")
|
|
|
|
state, exists := status.peers[key]
|
|
assert.True(t, exists, "state should be found")
|
|
assert.Equal(t, fqdn, state.FQDN, "fqdn should be equal")
|
|
}
|
|
|
|
func TestGetPeerStateChangeNotifierLogic(t *testing.T) {
|
|
key := "abc"
|
|
ip := "10.10.10.10"
|
|
status := NewRecorder("https://mgm")
|
|
_ = status.AddPeer(key, "abc.netbird", ip, "")
|
|
|
|
sub := status.SubscribeToPeerStateChanges(context.Background(), key)
|
|
assert.NotNil(t, sub, "channel shouldn't be nil")
|
|
|
|
peerState := State{
|
|
PubKey: key,
|
|
ConnStatus: StatusConnecting,
|
|
Relayed: false,
|
|
ConnStatusUpdate: time.Now(),
|
|
}
|
|
|
|
err := status.UpdatePeerRelayedStateToDisconnected(peerState)
|
|
assert.NoError(t, err, "shouldn't return error")
|
|
|
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
defer cancel()
|
|
select {
|
|
case <-sub.eventsChan:
|
|
case <-timeoutCtx.Done():
|
|
t.Errorf("timed out waiting for event")
|
|
}
|
|
}
|
|
|
|
func TestRemovePeer(t *testing.T) {
|
|
key := "abc"
|
|
status := NewRecorder("https://mgm")
|
|
peerState := State{
|
|
PubKey: key,
|
|
Mux: new(sync.RWMutex),
|
|
}
|
|
|
|
status.peers[key] = peerState
|
|
|
|
err := status.RemovePeer(key)
|
|
assert.NoError(t, err, "shouldn't return error")
|
|
|
|
_, exists := status.peers[key]
|
|
assert.False(t, exists, "state value shouldn't be found")
|
|
|
|
err = status.RemovePeer("not existing")
|
|
assert.Error(t, err, "should return error when peer doesn't exist")
|
|
}
|
|
|
|
func TestUpdateLocalPeerState(t *testing.T) {
|
|
localPeerState := LocalPeerState{
|
|
IP: "10.10.10.10",
|
|
PubKey: "abc",
|
|
KernelInterface: false,
|
|
}
|
|
status := NewRecorder("https://mgm")
|
|
|
|
status.UpdateLocalPeerState(localPeerState)
|
|
|
|
assert.Equal(t, localPeerState, status.localPeer, "local peer status should be equal")
|
|
}
|
|
|
|
func TestCleanLocalPeerState(t *testing.T) {
|
|
emptyLocalPeerState := LocalPeerState{}
|
|
localPeerState := LocalPeerState{
|
|
IP: "10.10.10.10",
|
|
PubKey: "abc",
|
|
KernelInterface: false,
|
|
}
|
|
status := NewRecorder("https://mgm")
|
|
|
|
status.localPeer = localPeerState
|
|
|
|
status.CleanLocalPeerState()
|
|
|
|
assert.Equal(t, emptyLocalPeerState, status.localPeer, "local peer status should be empty")
|
|
}
|
|
|
|
func TestUpdateSignalState(t *testing.T) {
|
|
url := "https://signal"
|
|
var tests = []struct {
|
|
name string
|
|
connected bool
|
|
want bool
|
|
err error
|
|
}{
|
|
{"should mark as connected", true, true, nil},
|
|
{"should mark as disconnected", false, false, errors.New("test")},
|
|
}
|
|
|
|
status := NewRecorder("https://mgm")
|
|
status.UpdateSignalAddress(url)
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if test.connected {
|
|
status.MarkSignalConnected()
|
|
} else {
|
|
status.MarkSignalDisconnected(test.err)
|
|
}
|
|
assert.Equal(t, test.want, status.signalState, "signal status should be equal")
|
|
assert.Equal(t, test.err, status.signalError)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdateManagementState(t *testing.T) {
|
|
url := "https://management"
|
|
var tests = []struct {
|
|
name string
|
|
connected bool
|
|
want bool
|
|
err error
|
|
}{
|
|
{"should mark as connected", true, true, nil},
|
|
{"should mark as disconnected", false, false, errors.New("test")},
|
|
}
|
|
|
|
status := NewRecorder(url)
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if test.connected {
|
|
status.MarkManagementConnected()
|
|
} else {
|
|
status.MarkManagementDisconnected(test.err)
|
|
}
|
|
assert.Equal(t, test.want, status.managementState, "signalState status should be equal")
|
|
assert.Equal(t, test.err, status.managementError)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetFullStatus(t *testing.T) {
|
|
key1 := "abc"
|
|
key2 := "def"
|
|
signalAddr := "https://signal"
|
|
managementState := ManagementState{
|
|
URL: "https://mgm",
|
|
Connected: true,
|
|
}
|
|
signalState := SignalState{
|
|
URL: signalAddr,
|
|
Connected: true,
|
|
}
|
|
peerState1 := State{
|
|
PubKey: key1,
|
|
}
|
|
|
|
peerState2 := State{
|
|
PubKey: key2,
|
|
}
|
|
|
|
status := NewRecorder("https://mgm")
|
|
status.UpdateSignalAddress(signalAddr)
|
|
|
|
status.managementState = managementState.Connected
|
|
status.signalState = signalState.Connected
|
|
status.peers[key1] = peerState1
|
|
status.peers[key2] = peerState2
|
|
|
|
fullStatus := status.GetFullStatus()
|
|
|
|
assert.Equal(t, managementState, fullStatus.ManagementState, "management status should be equal")
|
|
assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal")
|
|
assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match")
|
|
}
|