master
go 660 lines 18.1 KB
Raw
1 package telemetry
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "os"
10 "path"
11 "runtime"
12 "slices"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/google/uuid"
18 logging "github.com/ipfs/go-log/v2"
19 ipfs "github.com/ipfs/kubo"
20 "github.com/ipfs/kubo/config"
21 "github.com/ipfs/kubo/core"
22 "github.com/ipfs/kubo/core/corerepo"
23 "github.com/ipfs/kubo/plugin"
24 "github.com/libp2p/go-libp2p/core/network"
25 "github.com/libp2p/go-libp2p/core/pnet"
26 multiaddr "github.com/multiformats/go-multiaddr"
27 manet "github.com/multiformats/go-multiaddr/net"
28 )
29
30 var log = logging.Logger("telemetry")
31
32 // Caching for virtualization detection - these values never change during process lifetime
33 var (
34 containerDetectionOnce sync.Once
35 vmDetectionOnce sync.Once
36 isContainerCached bool
37 isVMCached bool
38 )
39
40 const (
41 modeEnvVar = "IPFS_TELEMETRY"
42 uuidFilename = "telemetry_uuid"
43 endpoint = "https://telemetry.ipshipyard.dev"
44 sendDelay = 15 * time.Minute // delay before first telemetry collection after daemon start
45 sendInterval = 24 * time.Hour // interval between telemetry collections after the first one
46 httpTimeout = 30 * time.Second // timeout for telemetry HTTP requests
47 )
48
49 type pluginMode int
50
51 const (
52 modeAuto pluginMode = iota
53 modeOn
54 modeOff
55 )
56
57 // repoSizeBuckets defines size thresholds for categorizing repository sizes.
58 // Each value represents the upper limit of a bucket in bytes (except the last)
59 var repoSizeBuckets = []uint64{
60 1 << 30, // 1 GB
61 5 << 30, // 5 GB
62 10 << 30, // 10 GB
63 100 << 30, // 100 GB
64 500 << 30, // 500 GB
65 1 << 40, // 1 TB
66 10 << 40, // 10 TB
67 11 << 40, // + anything more than 10TB falls here.
68 }
69
70 var uptimeBuckets = []time.Duration{
71 1 * 24 * time.Hour,
72 2 * 24 * time.Hour,
73 3 * 24 * time.Hour,
74 7 * 24 * time.Hour,
75 14 * 24 * time.Hour,
76 30 * 24 * time.Hour,
77 31 * 24 * time.Hour, // + anything more than 30 days falls here.
78 }
79
80 // A LogEvent is the object sent to the telemetry endpoint.
81 // See https://github.com/ipfs/kubo/blob/master/docs/telemetry.md for details.
82 type LogEvent struct {
83 UUID string `json:"uuid"`
84
85 AgentVersion string `json:"agent_version"`
86
87 PrivateNetwork bool `json:"private_network"`
88
89 BootstrappersCustom bool `json:"bootstrappers_custom"`
90
91 RepoSizeBucket uint64 `json:"repo_size_bucket"`
92
93 UptimeBucket time.Duration `json:"uptime_bucket"`
94
95 ReproviderStrategy string `json:"reprovider_strategy"`
96 ProvideDHTSweepEnabled bool `json:"provide_dht_sweep_enabled"`
97 ProvideDHTIntervalCustom bool `json:"provide_dht_interval_custom"`
98 ProvideDHTMaxWorkersCustom bool `json:"provide_dht_max_workers_custom"`
99
100 RoutingType string `json:"routing_type"`
101 RoutingAcceleratedDHTClient bool `json:"routing_accelerated_dht_client"`
102 RoutingDelegatedCount int `json:"routing_delegated_count"`
103
104 AutoNATServiceMode string `json:"autonat_service_mode"`
105 AutoNATReachability string `json:"autonat_reachability"`
106
107 AutoConf bool `json:"autoconf"`
108 AutoConfCustom bool `json:"autoconf_custom"`
109
110 SwarmEnableHolePunching bool `json:"swarm_enable_hole_punching"`
111 SwarmCircuitAddresses bool `json:"swarm_circuit_addresses"`
112 SwarmIPv4PublicAddresses bool `json:"swarm_ipv4_public_addresses"`
113 SwarmIPv6PublicAddresses bool `json:"swarm_ipv6_public_addresses"`
114
115 AutoTLSAutoWSS bool `json:"auto_tls_auto_wss"`
116 AutoTLSDomainSuffixCustom bool `json:"auto_tls_domain_suffix_custom"`
117
118 DiscoveryMDNSEnabled bool `json:"discovery_mdns_enabled"`
119
120 PlatformOS string `json:"platform_os"`
121 PlatformArch string `json:"platform_arch"`
122 PlatformContainerized bool `json:"platform_containerized"`
123 PlatformVM bool `json:"platform_vm"`
124 }
125
126 var Plugins = []plugin.Plugin{
127 &telemetryPlugin{},
128 }
129
130 type telemetryPlugin struct {
131 uuidFilename string
132 mode pluginMode
133 endpoint string
134 runOnce bool // test-only flag: when true, sends telemetry immediately without delay
135 sendDelay time.Duration
136
137 node *core.IpfsNode
138 config *config.Config
139 event *LogEvent
140 startTime time.Time
141 }
142
143 func (p *telemetryPlugin) Name() string {
144 return "telemetry"
145 }
146
147 func (p *telemetryPlugin) Version() string {
148 return "0.0.1"
149 }
150
151 func readFromConfig(cfg any, key string) string {
152 if cfg == nil {
153 return ""
154 }
155
156 pcfg, ok := cfg.(map[string]any)
157 if !ok {
158 return ""
159 }
160
161 val, ok := pcfg[key].(string)
162 if !ok {
163 return ""
164 }
165 return val
166 }
167
168 func (p *telemetryPlugin) Init(env *plugin.Environment) error {
169 // logging.SetLogLevel("telemetry", "DEBUG")
170 log.Debug("telemetry plugin Init()")
171 p.event = &LogEvent{}
172 p.startTime = time.Now()
173
174 repoPath := env.Repo
175 p.uuidFilename = path.Join(repoPath, uuidFilename)
176
177 v := os.Getenv(modeEnvVar)
178 if v != "" {
179 log.Debug("mode set from env-var")
180 } else if pmode := readFromConfig(env.Config, "Mode"); pmode != "" {
181 v = pmode
182 log.Debug("mode set from config")
183 }
184
185 // read "Delay" from the config. Parse as duration. Set p.sendDelay to it
186 // or set default.
187 if delayStr := readFromConfig(env.Config, "Delay"); delayStr != "" {
188 delay, err := time.ParseDuration(delayStr)
189 if err != nil {
190 log.Debug("sendDelay set from default")
191 p.sendDelay = sendDelay
192 } else {
193 log.Debug("sendDelay set from config")
194 p.sendDelay = delay
195 }
196 } else {
197 log.Debug("sendDelay set from default")
198 p.sendDelay = sendDelay
199 }
200
201 p.endpoint = endpoint
202 if ep := readFromConfig(env.Config, "Endpoint"); ep != "" {
203 log.Debug("endpoint set from config", ep)
204 p.endpoint = ep
205 }
206
207 switch v {
208 case "off":
209 p.mode = modeOff
210 log.Debug("telemetry disabled via opt-out")
211 // Remove UUID file if it exists when user opts out
212 if _, err := os.Stat(p.uuidFilename); err == nil {
213 if err := os.Remove(p.uuidFilename); err != nil {
214 log.Debugf("failed to remove telemetry UUID file: %s", err)
215 } else {
216 log.Debug("removed existing telemetry UUID file due to opt-out")
217 }
218 }
219 return nil
220 case "auto":
221 p.mode = modeAuto
222 default:
223 p.mode = modeOn
224 }
225 log.Debug("telemetry mode: ", p.mode)
226 return nil
227 }
228
229 func (p *telemetryPlugin) loadUUID() error {
230 // Generate or read our UUID from disk
231 b, err := os.ReadFile(p.uuidFilename)
232 if err != nil {
233 if !os.IsNotExist(err) {
234 log.Errorf("error reading telemetry uuid from disk: %s", err)
235 return err
236 }
237 uid, err := uuid.NewRandom()
238 if err != nil {
239 log.Errorf("cannot generate telemetry uuid: %s", err)
240 return err
241 }
242 p.event.UUID = uid.String()
243 p.mode = modeAuto
244 log.Debugf("new telemetry UUID %s. Mode set to Auto", uid)
245
246 // Write the UUID to disk
247 if err := os.WriteFile(p.uuidFilename, []byte(p.event.UUID), 0600); err != nil {
248 log.Errorf("cannot write telemetry uuid: %s", err)
249 return err
250 }
251 return nil
252 }
253
254 v := string(b)
255 v = strings.TrimSpace(v)
256 uid, err := uuid.Parse(v)
257 if err != nil {
258 log.Errorf("cannot parse telemetry uuid: %s", err)
259 return err
260 }
261 log.Debugf("uuid read from disk %s", uid)
262 p.event.UUID = uid.String()
263 return nil
264 }
265
266 func (p *telemetryPlugin) hasDefaultBootstrapPeers() bool {
267 // With autoconf, default bootstrap is represented as ["auto"]
268 currentPeers := p.config.Bootstrap
269 return len(currentPeers) == 1 && currentPeers[0] == "auto"
270 }
271
272 func (p *telemetryPlugin) showInfo() {
273 fmt.Printf(`
274
275 ℹ️ Anonymous telemetry will be enabled in %s
276
277 Kubo will collect anonymous usage data to help improve the software:
278 • What: Feature usage and configuration (no personal data)
279 Use GOLOG_LOG_LEVEL="telemetry=debug" to inspect collected data
280 • When: First collection in %s, then every 24h
281 • How: HTTP POST to %s
282 Anonymous ID: %s
283
284 No data sent yet. To opt-out before collection starts:
285 • Set environment: %s=off
286 • Or run: ipfs config Plugins.Plugins.telemetry.Config.Mode off
287 • Then restart daemon
288
289 This message is shown only once.
290 Learn more: https://github.com/ipfs/kubo/blob/master/docs/telemetry.md
291
292
293 `, p.sendDelay, p.sendDelay, endpoint, p.event.UUID, modeEnvVar)
294 }
295
296 // Start finishes telemetry initialization once the IpfsNode is ready,
297 // collects telemetry data and sends it to the endpoint.
298 func (p *telemetryPlugin) Start(n *core.IpfsNode) error {
299 // We should not be crashing the daemon due to problems with telemetry
300 // so this is always going to return nil and panics are going to be
301 // handled.
302 defer func() {
303 if r := recover(); r != nil {
304 log.Errorf("telemetry plugin panicked: %v", r)
305 }
306 }()
307
308 p.node = n
309 cfg, err := n.Repo.Config()
310 if err != nil {
311 log.Error("error getting the repo.Config: %s", err)
312 return nil
313 }
314 p.config = cfg
315 if p.mode == modeOff {
316 log.Debug("telemetry collection skipped: opted out")
317 return nil
318 }
319
320 if !n.IsDaemon || !n.IsOnline {
321 log.Debugf("skipping telemetry. Daemon: %t. Online: %t", n.IsDaemon, n.IsOnline)
322 return nil
323 }
324
325 // loadUUID might switch to modeAuto when generating a new uuid
326 if err := p.loadUUID(); err != nil {
327 p.mode = modeOff
328 return nil
329 }
330
331 if p.mode == modeAuto {
332 p.showInfo()
333 }
334
335 // runOnce is only used in tests to send telemetry immediately.
336 // In production, this is always false, ensuring users get the 15-minute delay.
337 if p.runOnce {
338 p.prepareEvent()
339 return p.sendTelemetry()
340 }
341
342 go func() {
343 timer := time.NewTimer(p.sendDelay)
344 for range timer.C {
345 p.prepareEvent()
346 if err := p.sendTelemetry(); err != nil {
347 log.Warnf("telemetry submission failed: %s (will retry in %s)", err, sendInterval)
348 }
349 timer.Reset(sendInterval)
350 }
351 }()
352
353 return nil
354 }
355
356 func (p *telemetryPlugin) prepareEvent() {
357 p.collectBasicInfo()
358 p.collectRoutingInfo()
359 p.collectProvideInfo()
360 p.collectAutoNATInfo()
361 p.collectAutoConfInfo()
362 p.collectSwarmInfo()
363 p.collectAutoTLSInfo()
364 p.collectDiscoveryInfo()
365 p.collectPlatformInfo()
366 }
367
368 func (p *telemetryPlugin) collectBasicInfo() {
369 p.event.AgentVersion = ipfs.GetUserAgentVersion()
370
371 privNet := false
372 if pnet.ForcePrivateNetwork {
373 privNet = true
374 } else if key, _ := p.node.Repo.SwarmKey(); key != nil {
375 privNet = true
376 }
377 p.event.PrivateNetwork = privNet
378
379 p.event.BootstrappersCustom = !p.hasDefaultBootstrapPeers()
380
381 repoSizeBucket := repoSizeBuckets[len(repoSizeBuckets)-1]
382 sizeStat, err := corerepo.RepoSize(context.Background(), p.node)
383 if err == nil {
384 for _, b := range repoSizeBuckets {
385 if sizeStat.RepoSize > b {
386 continue
387 }
388 repoSizeBucket = b
389 break
390 }
391 p.event.RepoSizeBucket = repoSizeBucket
392 } else {
393 log.Debugf("error setting sizeStat: %s", err)
394 }
395
396 uptime := time.Since(p.startTime)
397 uptimeBucket := uptimeBuckets[len(uptimeBuckets)-1]
398 for _, bucket := range uptimeBuckets {
399 if uptime > bucket {
400 continue
401
402 }
403 uptimeBucket = bucket
404 break
405 }
406 p.event.UptimeBucket = uptimeBucket
407 }
408
409 func (p *telemetryPlugin) collectRoutingInfo() {
410 p.event.RoutingType = p.config.Routing.Type.WithDefault("auto")
411 p.event.RoutingAcceleratedDHTClient = p.config.Routing.AcceleratedDHTClient.WithDefault(false)
412 p.event.RoutingDelegatedCount = len(p.config.Routing.DelegatedRouters)
413 }
414
415 func (p *telemetryPlugin) collectProvideInfo() {
416 p.event.ReproviderStrategy = p.config.Provide.Strategy.WithDefault(config.DefaultProvideStrategy)
417 p.event.ProvideDHTSweepEnabled = p.config.Provide.DHT.SweepEnabled.WithDefault(config.DefaultProvideDHTSweepEnabled)
418 p.event.ProvideDHTIntervalCustom = !p.config.Provide.DHT.Interval.IsDefault()
419 p.event.ProvideDHTMaxWorkersCustom = !p.config.Provide.DHT.MaxWorkers.IsDefault()
420 }
421
422 type reachabilityHost interface {
423 Reachability() network.Reachability
424 }
425
426 func (p *telemetryPlugin) collectAutoNATInfo() {
427 autonat := p.config.AutoNAT.ServiceMode
428 if autonat == config.AutoNATServiceUnset {
429 autonat = config.AutoNATServiceEnabled
430 }
431 autoNATSvcModeB, err := autonat.MarshalText()
432 if err == nil {
433 autoNATSvcMode := string(autoNATSvcModeB)
434 if autoNATSvcMode == "" {
435 autoNATSvcMode = "unset"
436 }
437 p.event.AutoNATServiceMode = autoNATSvcMode
438 }
439
440 h := p.node.PeerHost
441 reachHost, ok := h.(reachabilityHost)
442 if ok {
443 p.event.AutoNATReachability = reachHost.Reachability().String()
444 }
445 }
446
447 func (p *telemetryPlugin) collectSwarmInfo() {
448 p.event.SwarmEnableHolePunching = p.config.Swarm.EnableHolePunching.WithDefault(true)
449
450 var circuitAddrs, publicIP4Addrs, publicIP6Addrs bool
451 for _, addr := range p.node.PeerHost.Addrs() {
452 if manet.IsPublicAddr(addr) {
453 if _, err := addr.ValueForProtocol(multiaddr.P_IP4); err == nil {
454 publicIP4Addrs = true
455 } else if _, err := addr.ValueForProtocol(multiaddr.P_IP6); err == nil {
456 publicIP6Addrs = true
457 }
458 }
459 if _, err := addr.ValueForProtocol(multiaddr.P_CIRCUIT); err == nil {
460 circuitAddrs = true
461 }
462 }
463
464 p.event.SwarmCircuitAddresses = circuitAddrs
465 p.event.SwarmIPv4PublicAddresses = publicIP4Addrs
466 p.event.SwarmIPv6PublicAddresses = publicIP6Addrs
467 }
468
469 func (p *telemetryPlugin) collectAutoTLSInfo() {
470 p.event.AutoTLSAutoWSS = p.config.AutoTLS.AutoWSS.WithDefault(config.DefaultAutoWSS)
471 domainSuffix := p.config.AutoTLS.DomainSuffix.WithDefault(config.DefaultDomainSuffix)
472 p.event.AutoTLSDomainSuffixCustom = domainSuffix != config.DefaultDomainSuffix
473 }
474
475 func (p *telemetryPlugin) collectAutoConfInfo() {
476 p.event.AutoConf = p.config.AutoConf.Enabled.WithDefault(config.DefaultAutoConfEnabled)
477 p.event.AutoConfCustom = p.config.AutoConf.URL.WithDefault(config.DefaultAutoConfURL) != config.DefaultAutoConfURL
478 }
479
480 func (p *telemetryPlugin) collectDiscoveryInfo() {
481 p.event.DiscoveryMDNSEnabled = p.config.Discovery.MDNS.Enabled
482 }
483
484 func (p *telemetryPlugin) collectPlatformInfo() {
485 p.event.PlatformOS = runtime.GOOS
486 p.event.PlatformArch = runtime.GOARCH
487 p.event.PlatformContainerized = isRunningInContainer()
488 p.event.PlatformVM = isRunningInVM()
489 }
490
491 func isRunningInContainer() bool {
492 containerDetectionOnce.Do(func() {
493 isContainerCached = detectContainer()
494 })
495 return isContainerCached
496 }
497
498 func detectContainer() bool {
499 // Docker creates /.dockerenv inside containers
500 if _, err := os.Stat("/.dockerenv"); err == nil {
501 return true
502 }
503
504 // Kubernetes mounts service account tokens inside pods
505 if _, err := os.Stat("/var/run/secrets/kubernetes.io"); err == nil {
506 return true
507 }
508
509 // systemd-nspawn creates this file inside containers
510 if _, err := os.Stat("/run/systemd/container"); err == nil {
511 return true
512 }
513
514 // Check if our process is running inside a container cgroup
515 // Look for container-specific patterns in the cgroup path after "::/"
516 if content, err := os.ReadFile("/proc/self/cgroup"); err == nil {
517 for line := range strings.Lines(string(content)) {
518 // cgroup lines format: "ID:subsystem:/path"
519 // We want to check the path part after the last ":"
520 parts := strings.SplitN(line, ":", 3)
521 if len(parts) == 3 {
522 cgroupPath := parts[2]
523 // Check for container-specific paths
524 containerIndicators := []string{
525 "/docker/", // Docker containers
526 "/containerd/", // containerd runtime
527 "/cri-o/", // CRI-O runtime
528 "/lxc/", // LXC containers
529 "/podman/", // Podman containers
530 "/kubepods/", // Kubernetes pods
531 }
532 for _, indicator := range containerIndicators {
533 if strings.Contains(cgroupPath, indicator) {
534 return true
535 }
536 }
537 }
538 }
539 }
540
541 // WSL is technically a container-like environment
542 if runtime.GOOS == "linux" {
543 if content, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil {
544 osrelease := strings.ToLower(string(content))
545 if strings.Contains(osrelease, "microsoft") || strings.Contains(osrelease, "wsl") {
546 return true
547 }
548 }
549 }
550
551 // LXC sets container environment variable
552 if content, err := os.ReadFile("/proc/1/environ"); err == nil {
553 if strings.Contains(string(content), "container=lxc") {
554 return true
555 }
556 }
557
558 // Additional check: In containers, PID 1 is often not systemd/init
559 if content, err := os.ReadFile("/proc/1/comm"); err == nil {
560 pid1 := strings.TrimSpace(string(content))
561 // Common container init processes
562 containerInits := []string{"tini", "dumb-init", "s6-svscan", "runit"}
563 if slices.Contains(containerInits, pid1) {
564 return true
565 }
566 }
567
568 return false
569 }
570
571 func isRunningInVM() bool {
572 vmDetectionOnce.Do(func() {
573 isVMCached = detectVM()
574 })
575 return isVMCached
576 }
577
578 func detectVM() bool {
579 // Check for VM-specific files and drivers that only exist inside VMs
580 vmIndicators := []string{
581 "/proc/xen", // Xen hypervisor guest
582 "/sys/hypervisor/uuid", // KVM/Xen hypervisor guest
583 "/dev/vboxguest", // VirtualBox guest additions
584 "/sys/module/vmw_balloon", // VMware balloon driver (guest only)
585 "/sys/module/hv_vmbus", // Hyper-V VM bus driver (guest only)
586 }
587
588 for _, path := range vmIndicators {
589 if _, err := os.Stat(path); err == nil {
590 return true
591 }
592 }
593
594 // Check DMI for VM vendors - these strings only appear inside VMs
595 // DMI (Desktop Management Interface) is populated by the hypervisor
596 dmiFiles := map[string][]string{
597 "/sys/class/dmi/id/sys_vendor": {
598 "qemu", "kvm", "vmware", "virtualbox", "xen",
599 "parallels", // Parallels Desktop
600 // Note: Removed "microsoft corporation" as it can match Surface devices
601 },
602 "/sys/class/dmi/id/product_name": {
603 "virtualbox", "vmware", "kvm", "qemu",
604 "hvm domu", // Xen HVM guest
605 // Note: Removed generic "virtual machine" to avoid false positives
606 },
607 "/sys/class/dmi/id/chassis_vendor": {
608 "qemu", "oracle", // Oracle for VirtualBox
609 },
610 }
611
612 for path, signatures := range dmiFiles {
613 if content, err := os.ReadFile(path); err == nil {
614 contentStr := strings.ToLower(strings.TrimSpace(string(content)))
615 for _, sig := range signatures {
616 if strings.Contains(contentStr, sig) {
617 return true
618 }
619 }
620 }
621 }
622
623 return false
624 }
625
626 func (p *telemetryPlugin) sendTelemetry() error {
627 data, err := json.MarshalIndent(p.event, "", " ")
628 if err != nil {
629 return err
630 }
631
632 log.Debugf("sending telemetry:\n %s", data)
633
634 req, err := http.NewRequest("POST", p.endpoint, bytes.NewBuffer(data))
635 if err != nil {
636 return err
637 }
638 req.Header.Set("Content-Type", "application/json")
639 req.Header.Set("User-Agent", ipfs.GetUserAgentVersion())
640 req.Close = true
641
642 // Use client with timeout to prevent hanging
643 client := &http.Client{
644 Timeout: httpTimeout,
645 }
646 resp, err := client.Do(req)
647 if err != nil {
648 log.Debugf("failed to send telemetry: %s", err)
649 return err
650 }
651 defer resp.Body.Close()
652
653 if resp.StatusCode >= 400 {
654 err := fmt.Errorf("telemetry endpoint returned HTTP %d", resp.StatusCode)
655 log.Debug(err)
656 return err
657 }
658 log.Debugf("telemetry sent successfully (%d)", resp.StatusCode)
659 return nil
660 }