322 lines
9.8 KiB
Go
322 lines
9.8 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// Package connreject provides a bounded, in-memory, per-direction
|
|
// aggregator of recent connection-rejection events observed by the
|
|
// local node.
|
|
//
|
|
// A rejection event is any evidence that a connection attempt was
|
|
// blocked or dropped: a peer's TSMP reject message, an outbound TSMP
|
|
// reject we emitted because a peer's inbound connection violated our
|
|
// ACLs, a pendopen timeout where we never saw a reply, etc.
|
|
//
|
|
// The aggregator is split by [Direction]:
|
|
// - [Outgoing]: connections initiated by this node that were rejected
|
|
// or otherwise failed, keyed on the peer's address ("who we failed
|
|
// to reach").
|
|
// - [Incoming]: connections from a peer that this node rejected,
|
|
// keyed on the peer's address ("who we blocked").
|
|
//
|
|
// Repeated observations for the same (direction, proto, peer-address,
|
|
// reason) are aggregated into a single [Event], bumping Count and Last.
|
|
// Least-recently-used eviction keeps each direction's buffer bounded.
|
|
//
|
|
// This package contains only the data types and aggregator implementation;
|
|
// wiring into the rest of the system is done by feature/connreject.
|
|
package connreject
|
|
|
|
import (
|
|
"container/list"
|
|
"net/netip"
|
|
"runtime"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"tailscale.com/types/ipproto"
|
|
)
|
|
|
|
// Direction describes whether a rejection applied to a connection this
|
|
// node initiated (Outgoing) or a connection a peer initiated toward us
|
|
// (Incoming).
|
|
//
|
|
// Direction is a string type so it serializes directly to human-readable
|
|
// JSON without requiring MarshalText/UnmarshalText boilerplate.
|
|
type Direction string
|
|
|
|
// Direction values.
|
|
const (
|
|
DirectionUnknown Direction = ""
|
|
Outgoing Direction = "outgoing"
|
|
Incoming Direction = "incoming"
|
|
)
|
|
|
|
// Source describes how this node learned of a rejection.
|
|
type Source string
|
|
|
|
// Source values.
|
|
const (
|
|
SourceUnknown Source = ""
|
|
// SourceTSMPRecv is a TSMP reject message received from a peer.
|
|
SourceTSMPRecv Source = "tsmp_recv"
|
|
// SourceTSMPSent is a TSMP reject message this node emitted to a peer.
|
|
SourceTSMPSent Source = "tsmp_sent"
|
|
// SourcePendOpenTimeout is a pendopen-timer expiry with no response.
|
|
SourcePendOpenTimeout Source = "pendopen_timeout"
|
|
)
|
|
|
|
// Reason is a short, stable, machine-readable tag describing why a
|
|
// connection was rejected. Reason is part of the aggregation key in
|
|
// [Aggregator].
|
|
//
|
|
// These values are part of the wire format exposed over the debug
|
|
// endpoints; do not rename or repurpose them without bumping the
|
|
// capability version.
|
|
type Reason string
|
|
|
|
// Reason values.
|
|
const (
|
|
ReasonUnknown Reason = "unknown"
|
|
ReasonACL Reason = "acl"
|
|
ReasonShields Reason = "shields"
|
|
ReasonHostIPForwarding Reason = "host-ip-forwarding"
|
|
ReasonHostFirewall Reason = "host-firewall"
|
|
ReasonAppConnectorTransitIPUnknown Reason = "app-connector-transit-ip-unknown"
|
|
ReasonNoPeer Reason = "no-peer"
|
|
ReasonPeerUnreachable Reason = "peer-unreachable"
|
|
ReasonTimeout Reason = "timeout"
|
|
)
|
|
|
|
// Event is a single aggregated rejection observation.
|
|
//
|
|
// Events are aggregated by [Aggregator] on
|
|
// (Direction, Proto, peer-address, Reason). Src and Dst carry the ports
|
|
// observed on the most recent occurrence but do not participate in the
|
|
// aggregation key.
|
|
type Event struct {
|
|
// First is when this (key) was first observed. If unset on a Note,
|
|
// the aggregator fills it with the current time.
|
|
First time.Time
|
|
// Last is the most recent observation of this (key). If unset on a
|
|
// Note, the aggregator fills it with the current time.
|
|
Last time.Time
|
|
// Count is the number of observations merged into this Event. If
|
|
// unset on a Note, the aggregator treats it as 1.
|
|
Count uint32
|
|
// Direction indicates whether the event applies to an outgoing or
|
|
// incoming connection attempt.
|
|
Direction Direction
|
|
// Proto is the transport protocol of the rejected flow.
|
|
Proto ipproto.Proto
|
|
// Src is a representative source endpoint from the most recent
|
|
// observation. For Outgoing events this is our side; for Incoming
|
|
// events this is the peer side.
|
|
Src netip.AddrPort
|
|
// Dst is a representative destination endpoint from the most recent
|
|
// observation. For Outgoing events this is the peer side; for
|
|
// Incoming events this is our side.
|
|
Dst netip.AddrPort
|
|
// Reason is the machine-readable reason tag.
|
|
Reason Reason
|
|
// Source describes how we learned about this event.
|
|
Source Source
|
|
// MaybeBroken is true for the non-terminal form of an inbound TSMP
|
|
// reject (see packet.TailscaleRejectedHeader.MaybeBroken). It is
|
|
// informational and only meaningful when Source == SourceTSMPRecv.
|
|
MaybeBroken bool
|
|
}
|
|
|
|
// peerAddr returns the peer-side address for aggregation keying, based on
|
|
// the event's direction. For Outgoing, the peer is Dst; for Incoming, Src.
|
|
func (e Event) peerAddr() netip.Addr {
|
|
switch e.Direction {
|
|
case Outgoing:
|
|
return e.Dst.Addr()
|
|
case Incoming:
|
|
return e.Src.Addr()
|
|
}
|
|
return netip.Addr{}
|
|
}
|
|
|
|
// aggKey is the aggregation key for an entry in an [Aggregator].
|
|
type aggKey struct {
|
|
dir Direction
|
|
proto ipproto.Proto
|
|
addr netip.Addr
|
|
reason Reason
|
|
}
|
|
|
|
// entry is an internal aggregator entry.
|
|
type entry struct {
|
|
k aggKey
|
|
e Event
|
|
}
|
|
|
|
// dirBuf is an internal LRU+map for a single direction.
|
|
type dirBuf struct {
|
|
m map[aggKey]*list.Element // elements hold *entry
|
|
order *list.List // oldest (LRU) at Front, newest at Back
|
|
}
|
|
|
|
func newDirBuf() dirBuf {
|
|
return dirBuf{
|
|
m: make(map[aggKey]*list.Element),
|
|
order: list.New(),
|
|
}
|
|
}
|
|
|
|
// Aggregator owns bounded, per-direction LRU buffers for rejection
|
|
// [Event]s and a runtime enable flag. It is safe for concurrent use.
|
|
//
|
|
// An Aggregator is per-[LocalBackend] (or per-test); there is no
|
|
// package-level state.
|
|
type Aggregator struct {
|
|
max int // per-direction LRU capacity; <= 0 disables recording.
|
|
|
|
enabled atomic.Bool
|
|
|
|
mu sync.Mutex
|
|
outgoing dirBuf
|
|
incoming dirBuf
|
|
}
|
|
|
|
// NewAggregator returns an [Aggregator] with per-direction LRU buffers
|
|
// sized to max entries each. It starts disabled (calls to Note are
|
|
// silently dropped until SetEnabled(true) is called).
|
|
//
|
|
// A max of 0 or negative disables recording entirely.
|
|
func NewAggregator(max int) *Aggregator {
|
|
return &Aggregator{
|
|
max: max,
|
|
outgoing: newDirBuf(),
|
|
incoming: newDirBuf(),
|
|
}
|
|
}
|
|
|
|
// SetEnabled sets the runtime enable flag. When false, Note is a silent
|
|
// no-op. SetEnabled returns the previous value.
|
|
func (a *Aggregator) SetEnabled(v bool) (prev bool) {
|
|
return a.enabled.Swap(v)
|
|
}
|
|
|
|
// Enabled reports whether the aggregator is currently enabled.
|
|
func (a *Aggregator) Enabled() bool { return a.enabled.Load() }
|
|
|
|
// Note records a rejection observation. It is a no-op if the aggregator
|
|
// is disabled, if max is non-positive, or if e.Direction is not a
|
|
// routable value (Outgoing or Incoming).
|
|
//
|
|
// If an existing entry matches the aggregation key
|
|
// (Direction, Proto, peer-address, Reason), its Last, Count, Src, Dst,
|
|
// Source, and MaybeBroken are updated with the new observation, and it
|
|
// is moved to the "most recent" end of its buffer. Otherwise a new entry
|
|
// is appended, evicting the oldest entry if the buffer is over capacity.
|
|
func (a *Aggregator) Note(e Event) {
|
|
if !a.enabled.Load() || a.max <= 0 {
|
|
return
|
|
}
|
|
buf := a.bufFor(e.Direction)
|
|
if buf == nil {
|
|
return
|
|
}
|
|
if e.Last.IsZero() {
|
|
e.Last = time.Now()
|
|
}
|
|
if e.First.IsZero() {
|
|
e.First = e.Last
|
|
}
|
|
if e.Count == 0 {
|
|
e.Count = 1
|
|
}
|
|
k := aggKey{
|
|
dir: e.Direction,
|
|
proto: e.Proto,
|
|
addr: e.peerAddr(),
|
|
reason: e.Reason,
|
|
}
|
|
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
if el, ok := buf.m[k]; ok {
|
|
ent := el.Value.(*entry)
|
|
ent.e.Last = e.Last
|
|
ent.e.Count += e.Count
|
|
ent.e.Src = e.Src
|
|
ent.e.Dst = e.Dst
|
|
ent.e.Source = e.Source
|
|
ent.e.MaybeBroken = e.MaybeBroken
|
|
buf.order.MoveToBack(el)
|
|
return
|
|
}
|
|
|
|
buf.m[k] = buf.order.PushBack(&entry{k: k, e: e})
|
|
|
|
// Evict oldest if over capacity.
|
|
for buf.order.Len() > a.max {
|
|
old := buf.order.Front()
|
|
delete(buf.m, old.Value.(*entry).k)
|
|
buf.order.Remove(old)
|
|
}
|
|
}
|
|
|
|
// Outgoing returns a snapshot of the outgoing-rejection events in
|
|
// oldest-to-newest order.
|
|
func (a *Aggregator) Outgoing() []Event { return a.snapshot(&a.outgoing) }
|
|
|
|
// Incoming returns a snapshot of the incoming-rejection events in
|
|
// oldest-to-newest order.
|
|
func (a *Aggregator) Incoming() []Event { return a.snapshot(&a.incoming) }
|
|
|
|
func (a *Aggregator) snapshot(buf *dirBuf) []Event {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
out := make([]Event, 0, buf.order.Len())
|
|
for el := buf.order.Front(); el != nil; el = el.Next() {
|
|
out = append(out, el.Value.(*entry).e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bufFor returns the internal buffer for a direction, or nil if the
|
|
// direction is not a recognized routable value.
|
|
//
|
|
// The returned pointer is stable for the lifetime of a; safe to use
|
|
// before acquiring a.mu.
|
|
func (a *Aggregator) bufFor(d Direction) *dirBuf {
|
|
switch d {
|
|
case Outgoing:
|
|
return &a.outgoing
|
|
case Incoming:
|
|
return &a.incoming
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DefaultMax returns the default maximum number of entries per
|
|
// direction. On mobile platforms the default is smaller to respect tight
|
|
// memory budgets.
|
|
func DefaultMax() int {
|
|
switch runtime.GOOS {
|
|
case "ios", "android", "tvos":
|
|
return 32
|
|
}
|
|
return 256
|
|
}
|
|
|
|
// DebugRejectsResponse is the JSON response body shared by the
|
|
// debug-rejects LocalAPI endpoint, the GET /debug/rejects c2n endpoint,
|
|
// and the [client/local.Client.DebugRejects] method.
|
|
type DebugRejectsResponse struct {
|
|
// Enabled is whether [nodecap.ConnReject] is currently set
|
|
// on the node. When false, Outgoing and Incoming will be empty
|
|
// regardless of prior activity.
|
|
Enabled bool
|
|
// Outgoing is the list of rejections observed for connections this
|
|
// node initiated.
|
|
Outgoing []Event
|
|
// Incoming is the list of rejections this node has emitted for
|
|
// connections initiated by peers.
|
|
Incoming []Event
|
|
}
|