diff --git a/control/controlclient/map.go b/control/controlclient/map.go index 9aa8e3710..77f06fbb8 100644 --- a/control/controlclient/map.go +++ b/control/controlclient/map.go @@ -92,6 +92,7 @@ type mapSession struct { collectServices bool lastDomain string lastDomainAuditLogID string + lastLogUploadAuth string lastHealth []string lastDisplayMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage lastPopBrowserURL string @@ -413,6 +414,9 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) { if resp.DomainDataPlaneAuditLogID != "" { ms.lastDomainAuditLogID = resp.DomainDataPlaneAuditLogID } + if resp.LogUploadAuth != "" { + ms.lastLogUploadAuth = resp.LogUploadAuth + } if resp.Health != nil { ms.lastHealth = resp.Health } @@ -872,6 +876,7 @@ func (ms *mapSession) netmap() *netmap.NetworkMap { UserProfiles: make(map[tailcfg.UserID]tailcfg.UserProfileView), Domain: ms.lastDomain, DomainAuditLogID: ms.lastDomainAuditLogID, + LogUploadAuth: ms.lastLogUploadAuth, DNS: *ms.lastDNSConfig, PacketFilter: ms.lastParsedPacketFilter, PacketFilterRules: ms.lastPacketFilterRules, 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 2879c6b0d..68325f0f9 100644 --- a/logtail/logtail.go +++ b/logtail/logtail.go @@ -104,6 +104,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, @@ -144,6 +145,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 @@ -489,6 +491,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 346957803..b5f383199 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -177,7 +177,8 @@ type CapabilityVersion int // - 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) // - 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 // control plane. @@ -465,6 +466,8 @@ type Node struct { 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. + // If empty, but [MapResponse.DomainDataPlaneAuditLogID] is non-empty, + // then logs are only uploaded under the domain-specific log ID. DataPlaneAuditLogID string `json:",omitempty"` // 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. 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 // is setting debug settings on a node. Debug *Debug `json:",omitempty"` @@ -2598,6 +2604,9 @@ 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" + // 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/types/netmap/netmap.go b/types/netmap/netmap.go index c54562f4d..2859e4027 100644 --- a/types/netmap/netmap.go +++ b/types/netmap/netmap.go @@ -71,6 +71,9 @@ type NetworkMap struct { // If this is empty, then data-plane audit logging is disabled. DomainAuditLogID string + // LogUploadAuth, if non-empty, is the HTTP Authorization used when uploading logs. + LogUploadAuth string + // UserProfiles contains the profile information of UserIDs referenced // in SelfNode and Peers. UserProfiles map[tailcfg.UserID]tailcfg.UserProfileView diff --git a/types/netmap/nodemut.go b/types/netmap/nodemut.go index f4de1bf0b..a33706d8b 100644 --- a/types/netmap/nodemut.go +++ b/types/netmap/nodemut.go @@ -167,6 +167,7 @@ func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool { res.SSHPolicy != nil || res.TKAInfo != nil || res.DomainDataPlaneAuditLogID != "" || + res.LogUploadAuth != "" || res.Debug != nil || res.ControlDialPlan != nil || res.ClientVersion != nil || 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 8ad771fc5..28a444e59 100644 --- a/wgengine/userspace.go +++ b/wgengine/userspace.go @@ -1006,12 +1006,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 } @@ -1069,7 +1069,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() @@ -1081,11 +1081,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..2f5ef1c88 100644 --- a/wgengine/wgcfg/nmcfg/nmcfg.go +++ b/wgengine/wgcfg/nmcfg/nmcfg.go @@ -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. 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 { + cfg.NetworkLogging.TailnetID = domainID cfg.NetworkLogging.NodeID = nodeID - cfg.NetworkLogging.DomainID = domainID - cfg.NetworkLogging.LogExitFlowEnabled = logExitFlowEnabled + cfg.NetworkLogging.HTTPAuth = nm.LogUploadAuth + 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.