mirror of
https://github.com/tailscale/tailscale.git
synced 2025-12-01 15:32:05 +01:00
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>
197 lines
5.6 KiB
Go
197 lines
5.6 KiB
Go
// 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
|
||
}
|