Files
tailscale/wgengine/connreject_pendopen.go
James Tucker 85c1efb466 net/{connreject,tstun},feature/connreject,wgengine,client/local: add connection-rejection diagnostics
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>
2026-09-08 16:22:43 -07:00

96 lines
3.0 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_debug && !ts_omit_connreject
package wgengine
import (
"tailscale.com/net/connreject"
"tailscale.com/net/flowtrack"
"tailscale.com/net/packet"
)
// notifyConnRejectTSMPRecv emits an Outgoing-direction event derived
// from an inbound TSMP rejected header, if a callback is installed.
func (e *userspaceEngine) notifyConnRejectTSMPRecv(rh packet.TailscaleRejectedHeader) {
fn := e.connReject.cb.Load()
if fn == nil {
return
}
fn(connreject.Event{
Direction: connreject.Outgoing,
Proto: rh.Proto,
Src: rh.Src,
Dst: rh.Dst,
Reason: rejectReasonToReason(rh.Reason),
Source: connreject.SourceTSMPRecv,
MaybeBroken: rh.MaybeBroken,
})
}
// notifyConnRejectOpenTimeout classifies a pendopen-timeout diagnosis
// and emits an Outgoing-direction event, if a callback is installed and
// the classification is not deliberately suppressed (see
// [classifyOpenTimeout]).
func (e *userspaceEngine) notifyConnRejectOpenTimeout(flow flowtrack.Tuple, d openTimeoutDiag) {
fn := e.connReject.cb.Load()
if fn == nil {
return
}
reason, source := classifyOpenTimeout(d)
if reason == "" {
return
}
fn(connreject.Event{
Direction: connreject.Outgoing,
Proto: flow.Proto(),
Src: flow.Src(),
Dst: flow.Dst(),
Reason: reason,
Source: source,
})
}
// classifyOpenTimeout maps an [openTimeoutDiag] to a [connreject.Reason]
// and [connreject.Source] for emission. A returned reason of "" means
// the caller should not emit any event (used for the deliberately-silent
// onlyZeroRoute case).
//
// If d.problem is set, the peer's TSMP-reported reason supersedes our
// own diagnosis and the event is tagged SourceTSMPRecv (the timeout
// confirms the previously non-terminal reject was actually terminal).
func classifyOpenTimeout(d openTimeoutDiag) (connreject.Reason, connreject.Source) {
if d.onlyZeroRoute {
return "", connreject.SourceUnknown
}
if !d.problem.IsZero() {
return rejectReasonToReason(d.problem), connreject.SourceTSMPRecv
}
switch {
case d.noPeer:
return connreject.ReasonNoPeer, connreject.SourcePendOpenTimeout
case d.peerUnreachable:
return connreject.ReasonPeerUnreachable, connreject.SourcePendOpenTimeout
}
return connreject.ReasonTimeout, connreject.SourcePendOpenTimeout
}
// rejectReasonToReason maps a [packet.TailscaleRejectReason] to its
// corresponding [connreject.Reason] tag.
func rejectReasonToReason(r packet.TailscaleRejectReason) connreject.Reason {
switch r {
case packet.RejectedDueToACLs:
return connreject.ReasonACL
case packet.RejectedDueToShieldsUp:
return connreject.ReasonShields
case packet.RejectedDueToIPForwarding:
return connreject.ReasonHostIPForwarding
case packet.RejectedDueToHostFirewall:
return connreject.ReasonHostFirewall
case packet.RejectedDueToUnknownAppConnectorTransitIP:
return connreject.ReasonAppConnectorTransitIPUnknown
}
return connreject.ReasonUnknown
}