// 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) } } }