tailcfg: support LogUploadAuth and empty DataPlaneAuditLogID

This updates the Tailscale protocol to support the following:
* Network flow logs to be uploaded with a custom HTTP Authorization.
* Network flow logs to be uploaded under a TailnetID
without also needing to be associated with a particular NodeID.
* Network flow logs to to exclude embedded node information
based on a capability flag (see #17668).

Updates tailscale/corp#33352

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
dsnet/netlog-tailcfg
Joe Tsai 1 month ago
parent da508c504d
commit d8ef50fe3c

@ -92,6 +92,7 @@ type mapSession struct {
collectServices bool collectServices bool
lastDomain string lastDomain string
lastDomainAuditLogID string lastDomainAuditLogID string
lastLogUploadAuth string
lastHealth []string lastHealth []string
lastDisplayMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage lastDisplayMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
lastPopBrowserURL string lastPopBrowserURL string
@ -413,6 +414,9 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
if resp.DomainDataPlaneAuditLogID != "" { if resp.DomainDataPlaneAuditLogID != "" {
ms.lastDomainAuditLogID = resp.DomainDataPlaneAuditLogID ms.lastDomainAuditLogID = resp.DomainDataPlaneAuditLogID
} }
if resp.LogUploadAuth != "" {
ms.lastLogUploadAuth = resp.LogUploadAuth
}
if resp.Health != nil { if resp.Health != nil {
ms.lastHealth = resp.Health ms.lastHealth = resp.Health
} }
@ -872,6 +876,7 @@ func (ms *mapSession) netmap() *netmap.NetworkMap {
UserProfiles: make(map[tailcfg.UserID]tailcfg.UserProfileView), UserProfiles: make(map[tailcfg.UserID]tailcfg.UserProfileView),
Domain: ms.lastDomain, Domain: ms.lastDomain,
DomainAuditLogID: ms.lastDomainAuditLogID, DomainAuditLogID: ms.lastDomainAuditLogID,
LogUploadAuth: ms.lastLogUploadAuth,
DNS: *ms.lastDNSConfig, DNS: *ms.lastDNSConfig,
PacketFilter: ms.lastParsedPacketFilter, PacketFilter: ms.lastParsedPacketFilter,
PacketFilterRules: ms.lastPacketFilterRules, PacketFilterRules: ms.lastPacketFilterRules,

@ -30,6 +30,7 @@ type Config struct {
PrivateID logid.PrivateID // private ID for the primary log stream PrivateID logid.PrivateID // private ID for the primary log stream
CopyPrivateID logid.PrivateID // private ID for a log stream that is a superset of this log stream CopyPrivateID logid.PrivateID // private ID for a log stream that is a superset of this log stream
BaseURL string // if empty defaults to "https://log.tailscale.com" BaseURL string // if empty defaults to "https://log.tailscale.com"
HTTPAuth string // if set, specifies the Authorization HTTP header to send
HTTPC *http.Client // if empty defaults to http.DefaultClient HTTPC *http.Client // if empty defaults to http.DefaultClient
SkipClientTime bool // if true, client_time is not written to logs SkipClientTime bool // if true, client_time is not written to logs
LowMemory bool // if true, logtail minimizes memory use LowMemory bool // if true, logtail minimizes memory use

@ -104,6 +104,7 @@ func NewLogger(cfg Config, logf tslogger.Logf) *Logger {
privateID: cfg.PrivateID, privateID: cfg.PrivateID,
stderr: cfg.Stderr, stderr: cfg.Stderr,
stderrLevel: int64(cfg.StderrLevel), stderrLevel: int64(cfg.StderrLevel),
httpAuth: cfg.HTTPAuth,
httpc: cfg.HTTPC, httpc: cfg.HTTPC,
url: cfg.BaseURL + "/c/" + cfg.Collection + "/" + cfg.PrivateID.String() + urlSuffix, url: cfg.BaseURL + "/c/" + cfg.Collection + "/" + cfg.PrivateID.String() + urlSuffix,
lowMem: cfg.LowMemory, lowMem: cfg.LowMemory,
@ -144,6 +145,7 @@ func NewLogger(cfg Config, logf tslogger.Logf) *Logger {
type Logger struct { type Logger struct {
stderr io.Writer stderr io.Writer
stderrLevel int64 // accessed atomically stderrLevel int64 // accessed atomically
httpAuth string
httpc *http.Client httpc *http.Client
url string url string
lowMem bool lowMem bool
@ -489,6 +491,9 @@ func (lg *Logger) upload(ctx context.Context, body []byte, origlen int) (retryAf
// TODO record logs to disk // TODO record logs to disk
panic("logtail: cannot build http request: " + err.Error()) panic("logtail: cannot build http request: " + err.Error())
} }
if lg.httpAuth != "" {
req.Header.Add("Authorization", lg.httpAuth)
}
if origlen != -1 { if origlen != -1 {
req.Header.Add("Content-Encoding", "zstd") req.Header.Add("Content-Encoding", "zstd")
req.Header.Add("Orig-Content-Length", strconv.Itoa(origlen)) req.Header.Add("Orig-Content-Length", strconv.Itoa(origlen))

@ -177,7 +177,8 @@ type CapabilityVersion int
// - 128: 2025-10-02: can handle C2N /debug/health. // - 128: 2025-10-02: can handle C2N /debug/health.
// - 129: 2025-10-04: Fixed sleep/wake deadlock in magicsock when using peer relay (PR #17449) // - 129: 2025-10-04: Fixed sleep/wake deadlock in magicsock when using peer relay (PR #17449)
// - 130: 2025-10-06: client can send key.HardwareAttestationPublic and key.HardwareAttestationKeySignature in MapRequest // - 130: 2025-10-06: client can send key.HardwareAttestationPublic and key.HardwareAttestationKeySignature in MapRequest
const CurrentCapabilityVersion CapabilityVersion = 130 // - 131: 2025-10-28: client is able to use MapResponse.LogUploadAuth when uploading logs, allow empty Node.DataPlaneAuditLogID, and disable embedded node info
const CurrentCapabilityVersion CapabilityVersion = 131
// ID is an integer ID for a user, node, or login allocated by the // ID is an integer ID for a user, node, or login allocated by the
// control plane. // control plane.
@ -465,6 +466,8 @@ type Node struct {
ComputedNameWithHost string `json:",omitempty"` // either "ComputedName" or "ComputedName (computedHostIfDifferent)", if computedHostIfDifferent is set ComputedNameWithHost string `json:",omitempty"` // either "ComputedName" or "ComputedName (computedHostIfDifferent)", if computedHostIfDifferent is set
// DataPlaneAuditLogID is the per-node logtail ID used for data plane audit logging. // DataPlaneAuditLogID is the per-node logtail ID used for data plane audit logging.
// If empty, but [MapResponse.DomainDataPlaneAuditLogID] is non-empty,
// then logs are only uploaded under the domain-specific log ID.
DataPlaneAuditLogID string `json:",omitempty"` DataPlaneAuditLogID string `json:",omitempty"`
// Expired is whether this node's key has expired. Control may send // Expired is whether this node's key has expired. Control may send
@ -2135,6 +2138,9 @@ type MapResponse struct {
// used when writing data plane audit logs. // used when writing data plane audit logs.
DomainDataPlaneAuditLogID string `json:",omitempty"` DomainDataPlaneAuditLogID string `json:",omitempty"`
// LogUploadAuth, if non-empty, is the HTTP Authorization used when uploading logs.
LogUploadAuth string `json:",omitempty"`
// Debug is normally nil, except for when the control server // Debug is normally nil, except for when the control server
// is setting debug settings on a node. // is setting debug settings on a node.
Debug *Debug `json:",omitempty"` Debug *Debug `json:",omitempty"`
@ -2598,6 +2604,9 @@ const (
// NodeAttrLogExitFlows enables exit node destinations in network flow logs. // NodeAttrLogExitFlows enables exit node destinations in network flow logs.
NodeAttrLogExitFlows NodeCapability = "log-exit-flows" NodeAttrLogExitFlows NodeCapability = "log-exit-flows"
// NodeAttrExcludeNodeInfoInFlows disables embedded node information in network flow logs.
NodeAttrExcludeNodeInfoInFlows NodeCapability = "exclude-node-info-in-flows"
// NodeAttrAutoExitNode permits the automatic exit nodes feature. // NodeAttrAutoExitNode permits the automatic exit nodes feature.
NodeAttrAutoExitNode NodeCapability = "auto-exit-node" NodeAttrAutoExitNode NodeCapability = "auto-exit-node"

@ -318,6 +318,8 @@ func (v NodeView) ComputedName() string { return v.ж.ComputedName }
func (v NodeView) ComputedNameWithHost() string { return v.ж.ComputedNameWithHost } func (v NodeView) ComputedNameWithHost() string { return v.ж.ComputedNameWithHost }
// DataPlaneAuditLogID is the per-node logtail ID used for data plane audit logging. // DataPlaneAuditLogID is the per-node logtail ID used for data plane audit logging.
// If empty, but [MapResponse.DomainDataPlaneAuditLogID] is non-empty,
// then logs are only uploaded under the domain-specific log ID.
func (v NodeView) DataPlaneAuditLogID() string { return v.ж.DataPlaneAuditLogID } func (v NodeView) DataPlaneAuditLogID() string { return v.ж.DataPlaneAuditLogID }
// Expired is whether this node's key has expired. Control may send // Expired is whether this node's key has expired. Control may send

@ -71,6 +71,9 @@ type NetworkMap struct {
// If this is empty, then data-plane audit logging is disabled. // If this is empty, then data-plane audit logging is disabled.
DomainAuditLogID string DomainAuditLogID string
// LogUploadAuth, if non-empty, is the HTTP Authorization used when uploading logs.
LogUploadAuth string
// UserProfiles contains the profile information of UserIDs referenced // UserProfiles contains the profile information of UserIDs referenced
// in SelfNode and Peers. // in SelfNode and Peers.
UserProfiles map[tailcfg.UserID]tailcfg.UserProfileView UserProfiles map[tailcfg.UserID]tailcfg.UserProfileView

@ -167,6 +167,7 @@ func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool {
res.SSHPolicy != nil || res.SSHPolicy != nil ||
res.TKAInfo != nil || res.TKAInfo != nil ||
res.DomainDataPlaneAuditLogID != "" || res.DomainDataPlaneAuditLogID != "" ||
res.LogUploadAuth != "" ||
res.Debug != nil || res.Debug != nil ||
res.ControlDialPlan != nil || res.ControlDialPlan != nil ||
res.ClientVersion != nil || res.ClientVersion != nil ||

@ -33,6 +33,7 @@ import (
"tailscale.com/util/eventbus" "tailscale.com/util/eventbus"
"tailscale.com/util/set" "tailscale.com/util/set"
"tailscale.com/wgengine/router" "tailscale.com/wgengine/router"
"tailscale.com/wgengine/wgcfg"
jsonv2 "github.com/go-json-experiment/json" jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext" "github.com/go-json-experiment/json/jsontext"
@ -87,8 +88,6 @@ func (nl *Logger) Running() bool {
return nl.shutdownLocked != nil return nl.shutdownLocked != nil
} }
var testClient *http.Client
// Startup starts an asynchronous network logger that monitors // Startup starts an asynchronous network logger that monitors
// statistics for the provided tun and/or sock device. // statistics for the provided tun and/or sock device.
// //
@ -115,7 +114,7 @@ var testClient *http.Client
// The sock is used to populated the PhysicalTraffic field in [netlogtype.Message]. // The sock is used to populated the PhysicalTraffic field in [netlogtype.Message].
// //
// The netMon parameter is optional; if non-nil it's used to do faster interface lookups. // The netMon parameter is optional; if non-nil it's used to do faster interface lookups.
func (nl *Logger) Startup(logf logger.Logf, nm *netmap.NetworkMap, nodeLogID, domainLogID logid.PrivateID, tun, sock Device, netMon *netmon.Monitor, health *health.Tracker, bus *eventbus.Bus, logExitFlowEnabledEnabled bool) error { func (nl *Logger) Startup(logf logger.Logf, conf wgcfg.NetworkLoggingConfig, nm *netmap.NetworkMap, tun, sock Device, netMon *netmon.Monitor, health *health.Tracker, bus *eventbus.Bus) error {
nl.mu.Lock() nl.mu.Lock()
defer nl.mu.Unlock() defer nl.mu.Unlock()
@ -128,17 +127,20 @@ func (nl *Logger) Startup(logf logger.Logf, nm *netmap.NetworkMap, nodeLogID, do
if logf == nil { if logf == nil {
logf = log.Printf logf = log.Printf
} }
httpc := &http.Client{Transport: logpolicy.NewLogtailTransport(logtail.DefaultHost, netMon, health, logf)} privID, copyID := conf.NodeID, conf.TailnetID
if testClient != nil { if conf.NodeID.IsZero() {
httpc = testClient // If NodeID is zero, then only upload for the specified TailnetID.
privID, copyID = conf.TailnetID, logid.PrivateID{}
} }
httpc := &http.Client{Transport: logpolicy.NewLogtailTransport(logtail.DefaultHost, netMon, health, logf)}
logger := logtail.NewLogger(logtail.Config{ logger := logtail.NewLogger(logtail.Config{
Collection: "tailtraffic.log.tailscale.io", Collection: "tailtraffic.log.tailscale.io",
PrivateID: nodeLogID, PrivateID: privID,
CopyPrivateID: domainLogID, CopyPrivateID: copyID,
Bus: bus, Bus: bus,
Stderr: io.Discard, Stderr: io.Discard,
CompressLogs: true, CompressLogs: true,
HTTPAuth: conf.HTTPAuth,
HTTPC: httpc, HTTPC: httpc,
// TODO(joetsai): Set Buffer? Use an in-memory buffer for now. // TODO(joetsai): Set Buffer? Use an in-memory buffer for now.
@ -166,7 +168,7 @@ func (nl *Logger) Startup(logf logger.Logf, nm *netmap.NetworkMap, nodeLogID, do
go func(recordsChan chan record) { go func(recordsChan chan record) {
defer close(recorderDone) defer close(recorderDone)
for rec := range recordsChan { for rec := range recordsChan {
msg := rec.toMessage(false, !logExitFlowEnabledEnabled) msg := rec.toMessage(conf.ExcludeNodeInfo, conf.AnonymizeExitTraffic)
if b, err := jsonv2.Marshal(msg, jsontext.AllowInvalidUTF8(true)); err != nil { if b, err := jsonv2.Marshal(msg, jsontext.AllowInvalidUTF8(true)); err != nil {
if nl.logf != nil { if nl.logf != nil {
nl.logf("netlog: json.Marshal error: %v", err) nl.logf("netlog: json.Marshal error: %v", err)

@ -1006,12 +1006,12 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config,
if !engineChanged && !routerChanged && !dnsChanged && !listenPortChanged && !isSubnetRouterChanged && !peerMTUChanged { if !engineChanged && !routerChanged && !dnsChanged && !listenPortChanged && !isSubnetRouterChanged && !peerMTUChanged {
return ErrNoChanges return ErrNoChanges
} }
newLogIDs := cfg.NetworkLogging oldLogConf := e.lastCfgFull.NetworkLogging
oldLogIDs := e.lastCfgFull.NetworkLogging newLogConf := cfg.NetworkLogging
netLogIDsNowValid := !newLogIDs.NodeID.IsZero() && !newLogIDs.DomainID.IsZero() netLogWasValid := !oldLogConf.TailnetID.IsZero()
netLogIDsWasValid := !oldLogIDs.NodeID.IsZero() && !oldLogIDs.DomainID.IsZero() netLogNowValid := !newLogConf.TailnetID.IsZero()
netLogIDsChanged := netLogIDsNowValid && netLogIDsWasValid && newLogIDs != oldLogIDs netLogChanged := netLogWasValid && netLogNowValid && newLogConf != oldLogConf
netLogRunning := netLogIDsNowValid && !routerCfg.Equal(&router.Config{}) netLogRunning := netLogNowValid && !routerCfg.Equal(&router.Config{})
if !buildfeatures.HasNetLog || envknob.NoLogsNoSupport() { if !buildfeatures.HasNetLog || envknob.NoLogsNoSupport() {
netLogRunning = false netLogRunning = false
} }
@ -1069,7 +1069,7 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config,
// Shutdown the network logger because the IDs changed. // Shutdown the network logger because the IDs changed.
// Let it be started back up by subsequent logic. // Let it be started back up by subsequent logic.
if buildfeatures.HasNetLog && netLogIDsChanged && e.networkLogger.Running() { if buildfeatures.HasNetLog && netLogChanged && e.networkLogger.Running() {
e.logf("wgengine: Reconfig: shutting down network logger") e.logf("wgengine: Reconfig: shutting down network logger")
ctx, cancel := context.WithTimeout(context.Background(), networkLoggerUploadTimeout) ctx, cancel := context.WithTimeout(context.Background(), networkLoggerUploadTimeout)
defer cancel() defer cancel()
@ -1081,11 +1081,8 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config,
// Startup the network logger. // Startup the network logger.
// Do this before configuring the router so that we capture initial packets. // Do this before configuring the router so that we capture initial packets.
if buildfeatures.HasNetLog && netLogRunning && !e.networkLogger.Running() { if buildfeatures.HasNetLog && netLogRunning && !e.networkLogger.Running() {
nid := cfg.NetworkLogging.NodeID e.logf("wgengine: Reconfig: starting up network logger (tailnet:%s node:%s)", cfg.NetworkLogging.TailnetID.Public(), cfg.NetworkLogging.NodeID.Public())
tid := cfg.NetworkLogging.DomainID if err := e.networkLogger.Startup(e.logf, cfg.NetworkLogging, nm, e.tundev, e.magicConn, e.netMon, e.health, e.eventBus); err != nil {
logExitFlowEnabled := cfg.NetworkLogging.LogExitFlowEnabled
e.logf("wgengine: Reconfig: starting up network logger (node:%s tailnet:%s)", nid.Public(), tid.Public())
if err := e.networkLogger.Startup(e.logf, nm, nid, tid, e.tundev, e.magicConn, e.netMon, e.health, e.eventBus, logExitFlowEnabled); err != nil {
e.logf("wgengine: Reconfig: error starting up network logger: %v", err) e.logf("wgengine: Reconfig: error starting up network logger: %v", err)
} }
e.networkLogger.ReconfigRoutes(routerCfg) e.networkLogger.ReconfigRoutes(routerCfg)

@ -23,14 +23,26 @@ type Config struct {
DNS []netip.Addr DNS []netip.Addr
Peers []Peer Peers []Peer
// NetworkLogging enables network logging. NetworkLogging NetworkLoggingConfig
// It is disabled if either ID is the zero value. }
// LogExitFlowEnabled indicates whether or not exit flows should be logged.
NetworkLogging struct { // NetworkLoggingConfig configures network flow logging.
NodeID logid.PrivateID type NetworkLoggingConfig struct {
DomainID logid.PrivateID // TailnetID is the Tailnet-specific log ID to associate network flow logs with.
LogExitFlowEnabled bool // If non-zero, then network flow logging is enabled.
} TailnetID logid.PrivateID
// NodeID is the node-specific log ID to associate network flow logs with.
// It may be zero while TailnetID is non-zero.
NodeID logid.PrivateID
// HTTPAuth is an optional HTTP Authorization header to upload log with.
HTTPAuth string
// AnonymizeExitTraffic specifies whether to anonymize exit node traffic.
// Enable this to better preserve privacy.
AnonymizeExitTraffic bool
// ExcludeNodeInfo specifies whether to exclude node information.
// Enable this to reduce the size of each log message.
ExcludeNodeInfo bool
} }
func (c *Config) Equal(o *Config) bool { func (c *Config) Equal(o *Config) bool {

@ -52,20 +52,23 @@ func WGCfg(pk key.NodePrivate, nm *netmap.NetworkMap, logf logger.Logf, flags ne
// Setup log IDs for data plane audit logging. // Setup log IDs for data plane audit logging.
if nm.SelfNode.Valid() { if nm.SelfNode.Valid() {
canNetworkLog := nm.SelfNode.HasCap(tailcfg.CapabilityDataPlaneAuditLogs) canNetworkLog := nm.SelfNode.HasCap(tailcfg.CapabilityDataPlaneAuditLogs)
logExitFlowEnabled := nm.SelfNode.HasCap(tailcfg.NodeAttrLogExitFlows) if canNetworkLog && nm.DomainAuditLogID != "" {
if canNetworkLog && nm.SelfNode.DataPlaneAuditLogID() != "" && nm.DomainAuditLogID != "" {
nodeID, errNode := logid.ParsePrivateID(nm.SelfNode.DataPlaneAuditLogID())
if errNode != nil {
logf("[v1] wgcfg: unable to parse node audit log ID: %v", errNode)
}
domainID, errDomain := logid.ParsePrivateID(nm.DomainAuditLogID) domainID, errDomain := logid.ParsePrivateID(nm.DomainAuditLogID)
if errDomain != nil { if errDomain != nil {
logf("[v1] wgcfg: unable to parse domain audit log ID: %v", errDomain) logf("[v1] wgcfg: unable to parse domain audit log ID: %v", errDomain)
} }
nodeID, errNode := logid.ParsePrivateID(nm.SelfNode.DataPlaneAuditLogID())
if nm.SelfNode.DataPlaneAuditLogID() == "" {
errNode = nil // may be empty while DomainAuditLogID is non-empty
} else if errNode != nil {
logf("[v1] wgcfg: unable to parse node audit log ID: %v", errNode)
}
if errNode == nil && errDomain == nil { if errNode == nil && errDomain == nil {
cfg.NetworkLogging.TailnetID = domainID
cfg.NetworkLogging.NodeID = nodeID cfg.NetworkLogging.NodeID = nodeID
cfg.NetworkLogging.DomainID = domainID cfg.NetworkLogging.HTTPAuth = nm.LogUploadAuth
cfg.NetworkLogging.LogExitFlowEnabled = logExitFlowEnabled cfg.NetworkLogging.AnonymizeExitTraffic = !nm.SelfNode.HasCap(tailcfg.NodeAttrLogExitFlows) // anonymize by default
cfg.NetworkLogging.ExcludeNodeInfo = nm.SelfNode.HasCap(tailcfg.NodeAttrExcludeNodeInfoInFlows) // include by default
} }
} }
} }

@ -9,7 +9,6 @@ import (
"net/netip" "net/netip"
"tailscale.com/types/key" "tailscale.com/types/key"
"tailscale.com/types/logid"
"tailscale.com/types/ptr" "tailscale.com/types/ptr"
) )
@ -39,11 +38,7 @@ var _ConfigCloneNeedsRegeneration = Config(struct {
MTU uint16 MTU uint16
DNS []netip.Addr DNS []netip.Addr
Peers []Peer Peers []Peer
NetworkLogging struct { NetworkLogging NetworkLoggingConfig
NodeID logid.PrivateID
DomainID logid.PrivateID
LogExitFlowEnabled bool
}
}{}) }{})
// Clone makes a deep copy of Peer. // Clone makes a deep copy of Peer.

Loading…
Cancel
Save