package tailnet import ( "crypto/sha256" "encoding/hex" "net/netip" "sync" "time" "golang.org/x/xerrors" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/wrapperspb" "tailscale.com/tailcfg" "tailscale.com/types/netmap" "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/tailnet/proto" ) const ( TelemetryApplicationSSH string = "ssh" TelemetryApplicationSpeedtest string = "speedtest" TelemetryApplicationVSCode string = "vscode" ) // Responsible for storing and anonymizing networking telemetry state. type TelemetryStore struct { mu sync.Mutex hashSalt string // A cache to avoid hashing the same IP or hostname multiple times. hashCache map[string]string cleanDerpMap *tailcfg.DERPMap cleanNetCheck *proto.Netcheck nodeIDSelf uint64 homeDerp int32 application string // nil if not connected connectedIP *netip.Addr // 0 if not connected nodeIDRemote uint64 p2p bool p2pSetupTime time.Duration lastDerpTime time.Time } func newTelemetryStore() (*TelemetryStore, error) { hashSalt, err := cryptorand.String(16) if err != nil { return nil, err } return &TelemetryStore{ hashSalt: hashSalt, hashCache: make(map[string]string), }, nil } // newEvent returns the current telemetry state as an event func (b *TelemetryStore) newEvent() *proto.TelemetryEvent { b.mu.Lock() defer b.mu.Unlock() out := &proto.TelemetryEvent{ Time: timestamppb.Now(), ClientVersion: buildinfo.Version(), DerpMap: DERPMapToProto(b.cleanDerpMap), LatestNetcheck: b.cleanNetCheck, NodeIdSelf: b.nodeIDSelf, NodeIdRemote: b.nodeIDRemote, HomeDerp: b.homeDerp, Application: b.application, } if b.p2pSetupTime > 0 { out.P2PSetup = durationpb.New(b.p2pSetupTime) } return out } func (b *TelemetryStore) markConnected(ip *netip.Addr, application string) { b.mu.Lock() defer b.mu.Unlock() b.lastDerpTime = time.Now() b.connectedIP = ip b.application = application } func (b *TelemetryStore) pingPeer(conn *Conn) { b.mu.Lock() defer b.mu.Unlock() if b.connectedIP == nil { return } ip := *b.connectedIP go func() { _, _, _, _ = conn.Ping(conn.watchCtx, ip) }() } func (b *TelemetryStore) changedConntype(addr string) bool { b.mu.Lock() defer b.mu.Unlock() if b.p2p && addr != "" { return false } else if !b.p2p && addr != "" { b.p2p = true b.p2pSetupTime = time.Since(b.lastDerpTime) return true } else if b.p2p && addr == "" { b.p2p = false b.lastDerpTime = time.Now() b.p2pSetupTime = 0 return true } return false } func (b *TelemetryStore) updateRemoteNodeIDLocked(nm *netmap.NetworkMap) { if b.connectedIP == nil { return } ip := *b.connectedIP for _, p := range nm.Peers { for _, a := range p.Addresses { if a.Addr() == ip && a.IsSingleIP() { b.nodeIDRemote = uint64(p.ID) } } } } func (b *TelemetryStore) updateNetworkMap(nm *netmap.NetworkMap) { b.mu.Lock() defer b.mu.Unlock() if nm == nil { return } b.updateDerpMapLocked(nm.DERPMap) b.updateRemoteNodeIDLocked(nm) b.updateByNodeLocked(nm.SelfNode) } // Given a DERPMap, anonymise all IPs and hostnames. // Keep track of seen hostnames/cert names to anonymize them from future logs. // b.mu must NOT be held. func (b *TelemetryStore) updateDerpMap(cur *tailcfg.DERPMap) { b.mu.Lock() defer b.mu.Unlock() b.updateDerpMapLocked(cur) } func (b *TelemetryStore) updateDerpMapLocked(cur *tailcfg.DERPMap) { if cur == nil { return } cleanMap := cur.Clone() for _, r := range cleanMap.Regions { for _, n := range r.Nodes { ipv4, _, _ := b.processIPLocked(n.IPv4) n.IPv4 = ipv4 ipv6, _, _ := b.processIPLocked(n.IPv6) n.IPv6 = ipv6 stunIP, _, _ := b.processIPLocked(n.STUNTestIP) n.STUNTestIP = stunIP hn := b.hashAddrorHostname(n.HostName) n.HostName = hn cn := b.hashAddrorHostname(n.CertName) n.CertName = cn } } b.cleanDerpMap = cleanMap } // Update the telemetry store with the current self node state. // Returns true if the home DERP has changed. func (b *TelemetryStore) updateByNodeLocked(n *tailcfg.Node) bool { if n == nil { return false } b.nodeIDSelf = uint64(n.ID) derpIP, err := netip.ParseAddrPort(n.DERP) if err != nil { return false } newHome := int32(derpIP.Port()) if b.homeDerp != newHome { b.homeDerp = newHome return true } return false } // Store an anonymized proto.Netcheck given a tailscale NetInfo. func (b *TelemetryStore) setNetInfo(ni *tailcfg.NetInfo) { b.mu.Lock() defer b.mu.Unlock() b.cleanNetCheck = &proto.Netcheck{ UDP: ni.UDP, IPv6: ni.IPv6, IPv4: ni.IPv4, IPv6CanSend: ni.IPv6CanSend, IPv4CanSend: ni.IPv4CanSend, ICMPv4: ni.ICMPv4, OSHasIPv6: wrapperspb.Bool(ni.OSHasIPv6.EqualBool(true)), MappingVariesByDestIP: wrapperspb.Bool(ni.MappingVariesByDestIP.EqualBool(true)), HairPinning: wrapperspb.Bool(ni.HairPinning.EqualBool(true)), UPnP: wrapperspb.Bool(ni.UPnP.EqualBool(true)), PMP: wrapperspb.Bool(ni.PMP.EqualBool(true)), PCP: wrapperspb.Bool(ni.PCP.EqualBool(true)), PreferredDERP: int64(ni.PreferredDERP), RegionV4Latency: make(map[int64]*durationpb.Duration), RegionV6Latency: make(map[int64]*durationpb.Duration), } v4hash, v4fields, err := b.processIPLocked(ni.GlobalV4) if err == nil { b.cleanNetCheck.GlobalV4 = &proto.Netcheck_NetcheckIP{ Hash: v4hash, Fields: v4fields, } } v6hash, v6fields, err := b.processIPLocked(ni.GlobalV6) if err == nil { b.cleanNetCheck.GlobalV6 = &proto.Netcheck_NetcheckIP{ Hash: v6hash, Fields: v6fields, } } for rid, seconds := range ni.DERPLatencyV4 { b.cleanNetCheck.RegionV4Latency[int64(rid)] = durationpb.New(time.Duration(seconds * float64(time.Second))) } for rid, seconds := range ni.DERPLatencyV6 { b.cleanNetCheck.RegionV6Latency[int64(rid)] = durationpb.New(time.Duration(seconds * float64(time.Second))) } } func (b *TelemetryStore) toEndpoint(ipport string) *proto.TelemetryEvent_P2PEndpoint { b.mu.Lock() defer b.mu.Unlock() addrport, err := netip.ParseAddrPort(ipport) if err != nil { return nil } addr := addrport.Addr() fields := addrToFields(addr) hashStr := b.hashAddrorHostname(addr.String()) return &proto.TelemetryEvent_P2PEndpoint{ Hash: hashStr, Port: int32(addrport.Port()), Fields: fields, } } // processIPLocked will look up the IP in the cache, or hash and salt it and add // to the cache. It will also add it to hashedIPs. // // b.mu must be held. func (b *TelemetryStore) processIPLocked(ip string) (string, *proto.IPFields, error) { addr, err := netip.ParseAddr(ip) if err != nil { return "", nil, xerrors.Errorf("failed to parse IP %q: %w", ip, err) } fields := addrToFields(addr) hashStr := b.hashAddrorHostname(ip) return hashStr, fields, nil } func (b *TelemetryStore) hashAddrorHostname(addr string) string { if hashStr, ok := b.hashCache[addr]; ok { return hashStr } hash := sha256.Sum256([]byte(b.hashSalt + addr)) hashStr := hex.EncodeToString(hash[:]) b.hashCache[addr] = hashStr return hashStr } func addrToFields(addr netip.Addr) *proto.IPFields { version := int32(4) if addr.Is6() { version = 6 } class := proto.IPFields_PUBLIC switch { case addr.IsLoopback(): class = proto.IPFields_LOOPBACK case addr.IsLinkLocalUnicast(): class = proto.IPFields_LINK_LOCAL case addr.IsLinkLocalMulticast(): class = proto.IPFields_LINK_LOCAL case addr.IsPrivate(): class = proto.IPFields_PRIVATE } return &proto.IPFields{ Version: version, Class: class, } }