mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-13 06:19:11 -04:00
Adds an opt-in, in-memory aggregator of recent connection-rejection events (TSMP rejects received from peers, outbound TSMP rejects we emit on ACL-blocked inbound flows, and pendopen timeouts) keyed by (direction, proto, peer-address, reason). The aggregated data is exposed over a new debug-rejects LocalAPI endpoint and a GET /debug/rejects c2n endpoint, intended for future GUI/CLI consumption when diagnosing why a connection failed. Architecture: - net/connreject holds the data types and a per-LocalBackend Aggregator (LRU-bounded, default 256 entries on desktop / 32 on mobile, per direction). - feature/connreject is a self-registering ipnext.Extension that owns one Aggregator per LocalBackend, installs note callbacks on the tundev and engine, subscribes to OnSelfChange to flip the runtime gate, and serves the LocalAPI/c2n endpoints. - wgengine.Engine and *tstun.Wrapper each gain a SetConnRejectNote setter; data-plane sites use a single atomic.Pointer load + nil check, so the cost when no consumer is installed is one MOV. Gating: - Compile-time: ts_omit_connreject build tag (standard feature/buildfeatures + condregister plumbing). Trims ~41 KB. - Runtime: nodecap.ConnReject node attribute, off by default at the control plane. May be removed once the feature is enabled by default. Updates CapabilityVersion to 146 (clients understand nodecap.ConnReject and can serve GET /debug/rejects). Adds Proto/Src/Dst accessors on flowtrack.Tuple (used by pendopen to construct events without exposing the tuple's internals to the aggregator). Updates #1094 Updates #14802 Change-Id: I83e8f24a7e66fa2d158d128bd25fbe851134941b Signed-off-by: James Tucker <james@tailscale.com>
257 lines
8.2 KiB
Go
257 lines
8.2 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !ts_omit_debug && !ts_omit_connreject
|
|
|
|
package wgengine
|
|
|
|
import (
|
|
"net/netip"
|
|
"testing"
|
|
|
|
"tailscale.com/net/connreject"
|
|
"tailscale.com/net/packet"
|
|
"tailscale.com/types/ipproto"
|
|
"tailscale.com/wgengine/filter"
|
|
)
|
|
|
|
// TestInboundTSMPRecordsRejection asserts that a parsed inbound TSMP reject
|
|
// packet is delivered as an outgoing-direction [connreject.Event] to the
|
|
// installed callback when trackOpenPreFilterIn processes it.
|
|
func TestInboundTSMPRecordsRejection(t *testing.T) {
|
|
t.Parallel()
|
|
var events []connreject.Event
|
|
e := &userspaceEngine{}
|
|
e.SetConnRejectCallback(func(evt connreject.Event) { events = append(events, evt) })
|
|
|
|
rh := packet.TailscaleRejectedHeader{
|
|
IPSrc: netip.MustParseAddr("100.0.0.2"),
|
|
IPDst: netip.MustParseAddr("100.0.0.1"),
|
|
Src: netip.MustParseAddrPort("100.0.0.1:12345"),
|
|
Dst: netip.MustParseAddrPort("100.0.0.2:443"),
|
|
Proto: ipproto.TCP,
|
|
Reason: packet.RejectedDueToACLs,
|
|
}
|
|
raw := packet.Generate(rh, nil)
|
|
var pp packet.Parsed
|
|
pp.Decode(raw)
|
|
|
|
_ = e.trackOpenPreFilterIn(&pp, nil)
|
|
|
|
if len(events) != 1 {
|
|
t.Fatalf("got %d events, want 1", len(events))
|
|
}
|
|
got := events[0]
|
|
if got.Direction != connreject.Outgoing {
|
|
t.Errorf("Direction = %v, want Outgoing", got.Direction)
|
|
}
|
|
if got.Source != connreject.SourceTSMPRecv {
|
|
t.Errorf("Source = %v, want tsmp_recv", got.Source)
|
|
}
|
|
if got.Reason != connreject.ReasonACL {
|
|
t.Errorf("Reason = %q, want %q", got.Reason, connreject.ReasonACL)
|
|
}
|
|
if got.Proto != ipproto.TCP {
|
|
t.Errorf("Proto = %v, want TCP", got.Proto)
|
|
}
|
|
if got.Dst.Addr() != netip.MustParseAddr("100.0.0.2") {
|
|
t.Errorf("Dst.Addr = %v, want 100.0.0.2", got.Dst.Addr())
|
|
}
|
|
}
|
|
|
|
// TestInboundTSMPAppConnectorTransitIPRecordsAndContinues verifies that an
|
|
// unmapped app-connector transit IP is recorded with its specific reason while
|
|
// the TSMP packet remains accepted for tstun's mapping-recovery handler.
|
|
func TestInboundTSMPAppConnectorTransitIPRecordsAndContinues(t *testing.T) {
|
|
t.Parallel()
|
|
var events []connreject.Event
|
|
e := &userspaceEngine{}
|
|
e.SetConnRejectCallback(func(evt connreject.Event) { events = append(events, evt) })
|
|
|
|
rh := packet.TailscaleRejectedHeader{
|
|
IPSrc: netip.MustParseAddr("100.0.0.2"),
|
|
IPDst: netip.MustParseAddr("100.0.0.1"),
|
|
Src: netip.MustParseAddrPort("100.0.0.1:12345"),
|
|
Dst: netip.MustParseAddrPort("100.0.0.2:443"),
|
|
Proto: ipproto.TCP,
|
|
Reason: packet.RejectedDueToUnknownAppConnectorTransitIP,
|
|
}
|
|
raw := packet.Generate(rh, nil)
|
|
var pp packet.Parsed
|
|
pp.Decode(raw)
|
|
|
|
if got := e.trackOpenPreFilterIn(&pp, nil); got != filter.Accept {
|
|
t.Fatalf("filter response = %v, want Accept so tstun can handle mapping recovery", got)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Fatalf("got %d events, want 1", len(events))
|
|
}
|
|
if got := events[0].Reason; got != connreject.ReasonAppConnectorTransitIPUnknown {
|
|
t.Errorf("Reason = %q, want %q", got, connreject.ReasonAppConnectorTransitIPUnknown)
|
|
}
|
|
if got := events[0].Source; got != connreject.SourceTSMPRecv {
|
|
t.Errorf("Source = %q, want %q", got, connreject.SourceTSMPRecv)
|
|
}
|
|
}
|
|
|
|
// TestInboundTSMPNonTerminalRecordsMaybeBroken asserts that a MaybeBroken
|
|
// TSMP reject is published with MaybeBroken=true.
|
|
func TestInboundTSMPNonTerminalRecordsMaybeBroken(t *testing.T) {
|
|
t.Parallel()
|
|
var events []connreject.Event
|
|
e := &userspaceEngine{}
|
|
e.SetConnRejectCallback(func(evt connreject.Event) { events = append(events, evt) })
|
|
|
|
rh := packet.TailscaleRejectedHeader{
|
|
IPSrc: netip.MustParseAddr("100.0.0.2"),
|
|
IPDst: netip.MustParseAddr("100.0.0.1"),
|
|
Src: netip.MustParseAddrPort("100.0.0.1:12345"),
|
|
Dst: netip.MustParseAddrPort("100.0.0.2:443"),
|
|
Proto: ipproto.TCP,
|
|
Reason: packet.RejectedDueToHostFirewall,
|
|
MaybeBroken: true,
|
|
}
|
|
raw := packet.Generate(rh, nil)
|
|
var pp packet.Parsed
|
|
pp.Decode(raw)
|
|
|
|
_ = e.trackOpenPreFilterIn(&pp, nil)
|
|
|
|
if len(events) != 1 {
|
|
t.Fatalf("got %d events, want 1", len(events))
|
|
}
|
|
got := events[0]
|
|
if !got.MaybeBroken {
|
|
t.Error("MaybeBroken = false, want true")
|
|
}
|
|
if got.Reason != connreject.ReasonHostFirewall {
|
|
t.Errorf("Reason = %q, want %q", got.Reason, connreject.ReasonHostFirewall)
|
|
}
|
|
}
|
|
|
|
// TestSetConnRejectCallbackUninstall verifies that SetConnRejectCallback(nil)
|
|
// safely clears the previously installed callback and that
|
|
// notifyConnRejectTSMPRecv is a safe no-op afterward.
|
|
func TestSetConnRejectCallbackUninstall(t *testing.T) {
|
|
t.Parallel()
|
|
var events []connreject.Event
|
|
e := &userspaceEngine{}
|
|
e.SetConnRejectCallback(func(evt connreject.Event) { events = append(events, evt) })
|
|
|
|
rh := packet.TailscaleRejectedHeader{
|
|
IPSrc: netip.MustParseAddr("100.0.0.2"),
|
|
IPDst: netip.MustParseAddr("100.0.0.1"),
|
|
Src: netip.MustParseAddrPort("100.0.0.1:12345"),
|
|
Dst: netip.MustParseAddrPort("100.0.0.2:443"),
|
|
Proto: ipproto.TCP,
|
|
Reason: packet.RejectedDueToACLs,
|
|
}
|
|
raw := packet.Generate(rh, nil)
|
|
var pp packet.Parsed
|
|
pp.Decode(raw)
|
|
|
|
// First emit while installed: should deliver.
|
|
_ = e.trackOpenPreFilterIn(&pp, nil)
|
|
if len(events) != 1 {
|
|
t.Fatalf("after install: got %d events, want 1", len(events))
|
|
}
|
|
|
|
// Uninstall and emit again: must not panic and must not deliver.
|
|
e.SetConnRejectCallback(nil)
|
|
_ = e.trackOpenPreFilterIn(&pp, nil)
|
|
if len(events) != 1 {
|
|
t.Errorf("after uninstall: got %d events, want still 1", len(events))
|
|
}
|
|
|
|
// Reinstall and emit a third time: should deliver again.
|
|
e.SetConnRejectCallback(func(evt connreject.Event) { events = append(events, evt) })
|
|
_ = e.trackOpenPreFilterIn(&pp, nil)
|
|
if len(events) != 2 {
|
|
t.Errorf("after reinstall: got %d events, want 2", len(events))
|
|
}
|
|
}
|
|
|
|
func TestClassifyOpenTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
in openTimeoutDiag
|
|
wantReason connreject.Reason
|
|
wantSource connreject.Source
|
|
}{
|
|
{
|
|
name: "only-zero-route-suppresses-recording",
|
|
in: openTimeoutDiag{onlyZeroRoute: true},
|
|
wantReason: "",
|
|
wantSource: connreject.SourceUnknown,
|
|
},
|
|
{
|
|
name: "only-zero-route-suppresses-even-with-problem",
|
|
in: openTimeoutDiag{onlyZeroRoute: true, problem: packet.RejectedDueToACLs},
|
|
wantReason: "",
|
|
wantSource: connreject.SourceUnknown,
|
|
},
|
|
{
|
|
name: "problem-supersedes-diagnosis",
|
|
in: openTimeoutDiag{problem: packet.RejectedDueToACLs, noPeer: true},
|
|
wantReason: connreject.ReasonACL,
|
|
wantSource: connreject.SourceTSMPRecv,
|
|
},
|
|
{
|
|
name: "problem-host-firewall",
|
|
in: openTimeoutDiag{problem: packet.RejectedDueToHostFirewall},
|
|
wantReason: connreject.ReasonHostFirewall,
|
|
wantSource: connreject.SourceTSMPRecv,
|
|
},
|
|
{
|
|
name: "noPeer",
|
|
in: openTimeoutDiag{noPeer: true},
|
|
wantReason: connreject.ReasonNoPeer,
|
|
wantSource: connreject.SourcePendOpenTimeout,
|
|
},
|
|
{
|
|
name: "peerUnreachable",
|
|
in: openTimeoutDiag{peerUnreachable: true},
|
|
wantReason: connreject.ReasonPeerUnreachable,
|
|
wantSource: connreject.SourcePendOpenTimeout,
|
|
},
|
|
{
|
|
name: "plain-timeout",
|
|
in: openTimeoutDiag{},
|
|
wantReason: connreject.ReasonTimeout,
|
|
wantSource: connreject.SourcePendOpenTimeout,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gotReason, gotSource := classifyOpenTimeout(tc.in)
|
|
if gotReason != tc.wantReason {
|
|
t.Errorf("reason = %q, want %q", gotReason, tc.wantReason)
|
|
}
|
|
if gotSource != tc.wantSource {
|
|
t.Errorf("source = %q, want %q", gotSource, tc.wantSource)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRejectReasonToReason(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
in packet.TailscaleRejectReason
|
|
want connreject.Reason
|
|
}{
|
|
{packet.RejectedDueToACLs, connreject.ReasonACL},
|
|
{packet.RejectedDueToShieldsUp, connreject.ReasonShields},
|
|
{packet.RejectedDueToIPForwarding, connreject.ReasonHostIPForwarding},
|
|
{packet.RejectedDueToHostFirewall, connreject.ReasonHostFirewall},
|
|
{packet.RejectedDueToUnknownAppConnectorTransitIP, connreject.ReasonAppConnectorTransitIPUnknown},
|
|
{packet.TailscaleRejectReasonNone, connreject.ReasonUnknown},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := rejectReasonToReason(tc.in); got != tc.want {
|
|
t.Errorf("rejectReasonToReason(%v) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|