summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJoe Tsai <joetsai@digital-static.net>2025-10-31 13:34:05 -0700
committerJoe Tsai <joetsai@digital-static.net>2026-01-13 13:47:11 -0800
commitc9565a7cbafd14717c60f5221424428214fa6274 (patch)
tree09fe6345eb5b0cb5d34398f6090a1dcdcc1bb5d5
parent58042e2de39c9c2827fe0bad7c45e8631369325f (diff)
downloadtailscale-dsnet/netlog-tailcfg.tar.xz
tailscale-dsnet/netlog-tailcfg.zip
tailcfg: support LogUploadAuth and empty DataPlaneAuditLogIDdsnet/netlog-tailcfg
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>
-rw-r--r--logtail/config.go1
-rw-r--r--logtail/logtail.go5
-rw-r--r--tailcfg/tailcfg.go12
-rw-r--r--tailcfg/tailcfg_view.go2
-rw-r--r--wgengine/netlog/netlog.go20
-rw-r--r--wgengine/userspace.go21
-rw-r--r--wgengine/wgcfg/config.go28
-rw-r--r--wgengine/wgcfg/nmcfg/nmcfg.go28
-rw-r--r--wgengine/wgcfg/wgcfg_clone.go7
9 files changed, 80 insertions, 44 deletions
diff --git a/logtail/config.go b/logtail/config.go
index bf47dd8aa..44e0ff922 100644
--- a/logtail/config.go
+++ b/logtail/config.go
@@ -30,6 +30,7 @@ type Config struct {
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
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
SkipClientTime bool // if true, client_time is not written to logs
LowMemory bool // if true, logtail minimizes memory use
diff --git a/logtail/logtail.go b/logtail/logtail.go
index ce50c1c0a..50ccaf373 100644
--- a/logtail/logtail.go
+++ b/logtail/logtail.go
@@ -106,6 +106,7 @@ func NewLogger(cfg Config, logf tslogger.Logf) *Logger {
privateID: cfg.PrivateID,
stderr: cfg.Stderr,
stderrLevel: int64(cfg.StderrLevel),
+ httpAuth: cfg.HTTPAuth,
httpc: cfg.HTTPC,
url: cfg.BaseURL + "/c/" + cfg.Collection + "/" + cfg.PrivateID.String() + urlSuffix,
lowMem: cfg.LowMemory,
@@ -146,6 +147,7 @@ func NewLogger(cfg Config, logf tslogger.Logf) *Logger {
type Logger struct {
stderr io.Writer
stderrLevel int64 // accessed atomically
+ httpAuth string
httpc *http.Client
url string
lowMem bool
@@ -500,6 +502,9 @@ func (lg *Logger) upload(ctx context.Context, body []byte, origlen int) (retryAf
// TODO record logs to disk
panic("logtail: cannot build http request: " + err.Error())
}
+ if lg.httpAuth != "" {
+ req.Header.Add("Authorization", lg.httpAuth)
+ }
if origlen != -1 {
req.Header.Add("Content-Encoding", "zstd")
req.Header.Add("Orig-Content-Length", strconv.Itoa(origlen))
diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go
index 8468aa09e..fcc284d27 100644
--- a/tailcfg/tailcfg.go
+++ b/tailcfg/tailcfg.go
@@ -178,7 +178,8 @@ type CapabilityVersion int
// - 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
// - 131: 2025-11-25: client respects [NodeAttrDefaultAutoUpdate]
-const CurrentCapabilityVersion CapabilityVersion = 131
+// - 132: 2025-10-28: client is able to use NodeAttrLogUploadAuth when uploading logs, allow empty Node.DataPlaneAuditLogID, and disable embedded node info
+const CurrentCapabilityVersion CapabilityVersion = 132
// ID is an integer ID for a user, node, or login allocated by the
// control plane.
@@ -466,6 +467,8 @@ type Node struct {
ComputedNameWithHost string `json:",omitzero"` // either "ComputedName" or "ComputedName (computedHostIfDifferent)", if computedHostIfDifferent is set
// 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:",omitzero"`
// Expired is whether this node's key has expired. Control may send
@@ -2601,6 +2604,13 @@ const (
// NodeAttrLogExitFlows enables exit node destinations in network flow logs.
NodeAttrLogExitFlows NodeCapability = "log-exit-flows"
+ // NodeAttrExcludeNodeInfoInFlows disables embedded node information in network flow logs.
+ NodeAttrExcludeNodeInfoInFlows NodeCapability = "exclude-node-info-in-flows"
+
+ // NodeAttrLogUploadAuth specifies an the HTTP Authorization header
+ // to use when uploading logs.
+ NodeAttrLogUploadAuth NodeCapability = "log-upload-auth"
+
// NodeAttrAutoExitNode permits the automatic exit nodes feature.
NodeAttrAutoExitNode NodeCapability = "auto-exit-node"
diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go
index dbd29a87a..e2f0fbfbf 100644
--- a/tailcfg/tailcfg_view.go
+++ b/tailcfg/tailcfg_view.go
@@ -318,6 +318,8 @@ func (v NodeView) ComputedName() string { return v.ж.ComputedName }
func (v NodeView) ComputedNameWithHost() string { return v.ж.ComputedNameWithHost }
// 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 }
// Expired is whether this node's key has expired. Control may send
diff --git a/wgengine/netlog/netlog.go b/wgengine/netlog/netlog.go
index 12fe9c797..0d920947e 100644
--- a/wgengine/netlog/netlog.go
+++ b/wgengine/netlog/netlog.go
@@ -33,6 +33,7 @@ import (
"tailscale.com/util/eventbus"
"tailscale.com/util/set"
"tailscale.com/wgengine/router"
+ "tailscale.com/wgengine/wgcfg"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
@@ -87,8 +88,6 @@ func (nl *Logger) Running() bool {
return nl.shutdownLocked != nil
}
-var testClient *http.Client
-
// Startup starts an asynchronous network logger that monitors
// 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 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()
defer nl.mu.Unlock()
@@ -128,17 +127,20 @@ func (nl *Logger) Startup(logf logger.Logf, nm *netmap.NetworkMap, nodeLogID, do
if logf == nil {
logf = log.Printf
}
- httpc := &http.Client{Transport: logpolicy.NewLogtailTransport(logtail.DefaultHost, netMon, health, logf)}
- if testClient != nil {
- httpc = testClient
+ privID, copyID := conf.NodeID, conf.TailnetID
+ if conf.NodeID.IsZero() {
+ // 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{
Collection: "tailtraffic.log.tailscale.io",
- PrivateID: nodeLogID,
- CopyPrivateID: domainLogID,
+ PrivateID: privID,
+ CopyPrivateID: copyID,
Bus: bus,
Stderr: io.Discard,
CompressLogs: true,
+ HTTPAuth: conf.HTTPAuth,
HTTPC: httpc,
// 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) {
defer close(recorderDone)
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 nl.logf != nil {
nl.logf("netlog: json.Marshal error: %v", err)
diff --git a/wgengine/userspace.go b/wgengine/userspace.go
index 875011a9c..892802fb3 100644
--- a/wgengine/userspace.go
+++ b/wgengine/userspace.go
@@ -1028,12 +1028,12 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config,
if !engineChanged && !routerChanged && !dnsChanged && !listenPortChanged && !isSubnetRouterChanged && !peerMTUChanged {
return ErrNoChanges
}
- newLogIDs := cfg.NetworkLogging
- oldLogIDs := e.lastCfgFull.NetworkLogging
- netLogIDsNowValid := !newLogIDs.NodeID.IsZero() && !newLogIDs.DomainID.IsZero()
- netLogIDsWasValid := !oldLogIDs.NodeID.IsZero() && !oldLogIDs.DomainID.IsZero()
- netLogIDsChanged := netLogIDsNowValid && netLogIDsWasValid && newLogIDs != oldLogIDs
- netLogRunning := netLogIDsNowValid && !routerCfg.Equal(&router.Config{})
+ oldLogConf := e.lastCfgFull.NetworkLogging
+ newLogConf := cfg.NetworkLogging
+ netLogWasValid := !oldLogConf.TailnetID.IsZero()
+ netLogNowValid := !newLogConf.TailnetID.IsZero()
+ netLogChanged := netLogWasValid && netLogNowValid && newLogConf != oldLogConf
+ netLogRunning := netLogNowValid && !routerCfg.Equal(&router.Config{})
if !buildfeatures.HasNetLog || envknob.NoLogsNoSupport() {
netLogRunning = false
}
@@ -1091,7 +1091,7 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config,
// Shutdown the network logger because the IDs changed.
// 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")
ctx, cancel := context.WithTimeout(context.Background(), networkLoggerUploadTimeout)
defer cancel()
@@ -1103,11 +1103,8 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config,
// Startup the network logger.
// Do this before configuring the router so that we capture initial packets.
if buildfeatures.HasNetLog && netLogRunning && !e.networkLogger.Running() {
- nid := cfg.NetworkLogging.NodeID
- tid := cfg.NetworkLogging.DomainID
- 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: starting up network logger (tailnet:%s node:%s)", cfg.NetworkLogging.TailnetID.Public(), cfg.NetworkLogging.NodeID.Public())
+ if err := e.networkLogger.Startup(e.logf, cfg.NetworkLogging, nm, e.tundev, e.magicConn, e.netMon, e.health, e.eventBus); err != nil {
e.logf("wgengine: Reconfig: error starting up network logger: %v", err)
}
e.networkLogger.ReconfigRoutes(routerCfg)
diff --git a/wgengine/wgcfg/config.go b/wgengine/wgcfg/config.go
index 2734f6c6e..41e2bbe5e 100644
--- a/wgengine/wgcfg/config.go
+++ b/wgengine/wgcfg/config.go
@@ -23,14 +23,26 @@ type Config struct {
DNS []netip.Addr
Peers []Peer
- // NetworkLogging enables network logging.
- // It is disabled if either ID is the zero value.
- // LogExitFlowEnabled indicates whether or not exit flows should be logged.
- NetworkLogging struct {
- NodeID logid.PrivateID
- DomainID logid.PrivateID
- LogExitFlowEnabled bool
- }
+ NetworkLogging NetworkLoggingConfig
+}
+
+// NetworkLoggingConfig configures network flow logging.
+type NetworkLoggingConfig struct {
+ // TailnetID is the Tailnet-specific log ID to associate network flow logs with.
+ // 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 {
diff --git a/wgengine/wgcfg/nmcfg/nmcfg.go b/wgengine/wgcfg/nmcfg/nmcfg.go
index 487e78d81..764a2d383 100644
--- a/wgengine/wgcfg/nmcfg/nmcfg.go
+++ b/wgengine/wgcfg/nmcfg/nmcfg.go
@@ -52,20 +52,32 @@ func WGCfg(pk key.NodePrivate, nm *netmap.NetworkMap, logf logger.Logf, flags ne
// Setup log IDs for data plane audit logging.
if nm.SelfNode.Valid() {
canNetworkLog := nm.SelfNode.HasCap(tailcfg.CapabilityDataPlaneAuditLogs)
- logExitFlowEnabled := nm.SelfNode.HasCap(tailcfg.NodeAttrLogExitFlows)
- 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)
- }
+ if canNetworkLog && nm.DomainAuditLogID != "" {
domainID, errDomain := logid.ParsePrivateID(nm.DomainAuditLogID)
if errDomain != nil {
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 {
+ var uploadAuth string
+ if nm.SelfNode.HasCap(tailcfg.NodeAttrLogUploadAuth) {
+ uploadAuths, err := tailcfg.UnmarshalNodeCapViewJSON[string](nm.SelfNode.CapMap(), tailcfg.NodeAttrLogUploadAuth)
+ if len(uploadAuths) != 1 || err != nil {
+ logf("[v1] wgcfg: unable to parse log upload auth")
+ } else {
+ uploadAuth = uploadAuths[0]
+ }
+ }
+ cfg.NetworkLogging.TailnetID = domainID
cfg.NetworkLogging.NodeID = nodeID
- cfg.NetworkLogging.DomainID = domainID
- cfg.NetworkLogging.LogExitFlowEnabled = logExitFlowEnabled
+ cfg.NetworkLogging.HTTPAuth = uploadAuth
+ cfg.NetworkLogging.AnonymizeExitTraffic = !nm.SelfNode.HasCap(tailcfg.NodeAttrLogExitFlows) // anonymize by default
+ cfg.NetworkLogging.ExcludeNodeInfo = nm.SelfNode.HasCap(tailcfg.NodeAttrExcludeNodeInfoInFlows) // include by default
}
}
}
diff --git a/wgengine/wgcfg/wgcfg_clone.go b/wgengine/wgcfg/wgcfg_clone.go
index 9f3cabde1..d170f8d70 100644
--- a/wgengine/wgcfg/wgcfg_clone.go
+++ b/wgengine/wgcfg/wgcfg_clone.go
@@ -9,7 +9,6 @@ import (
"net/netip"
"tailscale.com/types/key"
- "tailscale.com/types/logid"
"tailscale.com/types/ptr"
)
@@ -39,11 +38,7 @@ var _ConfigCloneNeedsRegeneration = Config(struct {
MTU uint16
DNS []netip.Addr
Peers []Peer
- NetworkLogging struct {
- NodeID logid.PrivateID
- DomainID logid.PrivateID
- LogExitFlowEnabled bool
- }
+ NetworkLogging NetworkLoggingConfig
}{})
// Clone makes a deep copy of Peer.