Joe Tsai 478342a642
wgengine/netlog: embed node information in network flow logs (#17668)
This rewrites the netlog package to support embedding node information in network flow logs.
Some bit of complexity comes in trying to pre-compute the expected size of the log message
after JSON serialization to ensure that we can respect maximum body limits in log uploading.

We also fix a bug in tstun, where we were recording the IP address after SNAT,
which was resulting in non-sensible connection flows being logged.

Updates tailscale/corp#33352

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2025-10-28 14:48:37 -07:00

197 lines
5.6 KiB
Go
Raw Blame History

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_netlog && !ts_omit_logtail
package netlog
import (
"cmp"
"net/netip"
"slices"
"time"
"unicode/utf8"
"tailscale.com/tailcfg"
"tailscale.com/types/netlogtype"
"tailscale.com/util/set"
)
// maxLogSize is the maximum number of bytes for a log message.
const maxLogSize = 256 << 10
// record is the in-memory representation of a [netlogtype.Message].
// It uses maps to efficiently look-up addresses and connections.
// In contrast, [netlogtype.Message] is designed to be JSON serializable,
// where complex keys types are not well support in JSON objects.
type record struct {
selfNode nodeUser
start time.Time
end time.Time
seenNodes map[netip.Addr]nodeUser
virtConns map[netlogtype.Connection]countsType
physConns map[netlogtype.Connection]netlogtype.Counts
}
// nodeUser is a node with additional user profile information.
type nodeUser struct {
tailcfg.NodeView
user tailcfg.UserProfileView // UserProfileView for NodeView.User
}
// countsType is a counts with classification information about the connection.
type countsType struct {
netlogtype.Counts
connType connType
}
type connType uint8
const (
unknownTraffic connType = iota
virtualTraffic
subnetTraffic
exitTraffic
)
// toMessage converts a [record] into a [netlogtype.Message].
func (r record) toMessage(excludeNodeInfo, anonymizeExitTraffic bool) netlogtype.Message {
if !r.selfNode.Valid() {
return netlogtype.Message{}
}
m := netlogtype.Message{
NodeID: r.selfNode.StableID(),
Start: r.start.UTC(),
End: r.end.UTC(),
}
// Convert node fields.
if !excludeNodeInfo {
m.SrcNode = r.selfNode.toNode()
seenIDs := set.Of(r.selfNode.ID())
for _, node := range r.seenNodes {
if _, ok := seenIDs[node.ID()]; !ok && node.Valid() {
m.DstNodes = append(m.DstNodes, node.toNode())
seenIDs.Add(node.ID())
}
}
slices.SortFunc(m.DstNodes, func(x, y netlogtype.Node) int {
return cmp.Compare(x.NodeID, y.NodeID)
})
}
// Converter traffic fields.
anonymizedExitTraffic := make(map[netlogtype.Connection]netlogtype.Counts)
for conn, cnts := range r.virtConns {
switch cnts.connType {
case virtualTraffic:
m.VirtualTraffic = append(m.VirtualTraffic, netlogtype.ConnectionCounts{Connection: conn, Counts: cnts.Counts})
case subnetTraffic:
m.SubnetTraffic = append(m.SubnetTraffic, netlogtype.ConnectionCounts{Connection: conn, Counts: cnts.Counts})
default:
if anonymizeExitTraffic {
conn = netlogtype.Connection{ // scrub the IP protocol type
Src: netip.AddrPortFrom(conn.Src.Addr(), 0), // scrub the port number
Dst: netip.AddrPortFrom(conn.Dst.Addr(), 0), // scrub the port number
}
if !r.seenNodes[conn.Src.Addr()].Valid() {
conn.Src = netip.AddrPort{} // not a Tailscale node, so scrub the address
}
if !r.seenNodes[conn.Dst.Addr()].Valid() {
conn.Dst = netip.AddrPort{} // not a Tailscale node, so scrub the address
}
anonymizedExitTraffic[conn] = anonymizedExitTraffic[conn].Add(cnts.Counts)
continue
}
m.ExitTraffic = append(m.ExitTraffic, netlogtype.ConnectionCounts{Connection: conn, Counts: cnts.Counts})
}
}
for conn, cnts := range anonymizedExitTraffic {
m.ExitTraffic = append(m.ExitTraffic, netlogtype.ConnectionCounts{Connection: conn, Counts: cnts})
}
for conn, cnts := range r.physConns {
m.PhysicalTraffic = append(m.PhysicalTraffic, netlogtype.ConnectionCounts{Connection: conn, Counts: cnts})
}
// Sort the connections for deterministic results.
slices.SortFunc(m.VirtualTraffic, compareConnCnts)
slices.SortFunc(m.SubnetTraffic, compareConnCnts)
slices.SortFunc(m.ExitTraffic, compareConnCnts)
slices.SortFunc(m.PhysicalTraffic, compareConnCnts)
return m
}
func compareConnCnts(x, y netlogtype.ConnectionCounts) int {
return cmp.Or(
netip.AddrPort.Compare(x.Src, y.Src),
netip.AddrPort.Compare(x.Dst, y.Dst),
cmp.Compare(x.Proto, y.Proto))
}
// jsonLen computes an upper-bound on the size of the JSON representation.
func (nu nodeUser) jsonLen() int {
if !nu.Valid() {
return len(`{"nodeId":""}`)
}
n := netlogtype.MinNodeJSONSize + jsonQuotedLen(nu.Name())
if nu.Tags().Len() > 0 {
for _, tag := range nu.Tags().All() {
n += jsonQuotedLen(tag) + len(",")
}
} else if nu.user.Valid() && nu.user.ID() == nu.User() {
n += jsonQuotedLen(nu.user.LoginName())
}
return n
}
// toNode converts the [nodeUser] into a [netlogtype.Node].
func (nu nodeUser) toNode() netlogtype.Node {
if !nu.Valid() {
return netlogtype.Node{}
}
n := netlogtype.Node{NodeID: nu.StableID(), Name: nu.Name()}
var ipv4, ipv6 netip.Addr
for _, addr := range nu.Addresses().All() {
switch {
case addr.IsSingleIP() && addr.Addr().Is4():
ipv4 = addr.Addr()
case addr.IsSingleIP() && addr.Addr().Is6():
ipv6 = addr.Addr()
}
}
n.Addresses = []netip.Addr{ipv4, ipv6}
n.Addresses = slices.DeleteFunc(n.Addresses, func(a netip.Addr) bool { return !a.IsValid() })
if nu.Tags().Len() > 0 {
n.Tags = nu.Tags().AsSlice()
slices.Sort(n.Tags)
n.Tags = slices.Compact(n.Tags)
} else if nu.user.Valid() && nu.user.ID() == nu.User() {
n.User = nu.user.LoginName()
}
return n
}
// jsonQuotedLen computes the length of the JSON serialization of s
// according to [jsontext.AppendQuote].
func jsonQuotedLen(s string) int {
n := len(`"`) + len(s) + len(`"`)
for i, r := range s {
switch {
case r == '\b', r == '\t', r == '\n', r == '\f', r == '\r', r == '"', r == '\\':
n += len(`\X`) - 1
case r < ' ':
n += len(`\uXXXX`) - 1
case r == utf8.RuneError:
if _, m := utf8.DecodeRuneInString(s[i:]); m == 1 { // exactly an invalid byte
n += len("<22>") - 1
}
}
}
return n
}