| 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 | } |