kumo_machine_info/
lib.rs

1use anyhow::Context;
2use chrono::{DateTime, Utc};
3use reqwest::header::{HeaderMap, HeaderValue};
4use reqwest::Method;
5use serde::Deserialize;
6use serde_with::serde_as;
7use std::sync::LazyLock;
8use sysinfo::System;
9
10static MAC: LazyLock<[u8; 6]> = LazyLock::new(get_mac_address_once);
11
12/// Obtain the mac address of the first non-loopback interface on the system.
13/// If there are no candidate interfaces, fall back to the `gethostid()` function,
14/// which will attempt to load a host id from a file on the filesystem, or if that
15/// fails, resolve the hostname of the node to its IPv4 address using a reverse DNS
16/// lookup, and then derive some 32-bit number from that address through unspecified
17/// means.
18fn get_mac_address_once() -> [u8; 6] {
19    match mac_address::get_mac_address() {
20        Ok(Some(addr)) => addr.bytes(),
21        _ => {
22            // Fall back to gethostid, which is not great, but
23            // likely better than just random numbers
24            let host_id = unsafe { libc::gethostid() }.to_le_bytes();
25            let mac: [u8; 6] = [
26                host_id[0], host_id[1], host_id[2], host_id[3], host_id[4], host_id[5],
27            ];
28            mac
29        }
30    }
31}
32
33pub fn get_mac_address() -> &'static [u8; 6] {
34    &*MAC
35}
36
37#[derive(Debug)]
38pub struct MachineInfo {
39    pub hostname: String,
40    pub mac_address: String,
41    pub machine_uid: Option<String>,
42    pub node_id: Option<String>,
43    pub num_cores: usize,
44    pub kernel_version: Option<String>,
45    pub platform: String,
46    pub distribution: String,
47    pub os_version: String,
48    pub total_memory_bytes: u64,
49    pub container_runtime: Option<String>,
50    pub cpu_brand: String,
51    pub cloud_provider: Option<CloudProvider>,
52}
53
54#[derive(Debug)]
55pub enum CloudProvider {
56    /// AWS
57    Aws(aws::IdentityDocument),
58    /// MS Azure
59    Azure(azure::InstanceMetadata),
60    /// Google Cloud Platform
61    Gcp(gcp::InstanceMetadata),
62}
63
64impl CloudProvider {
65    fn augment_fingerprint(&self, components: &mut Vec<String>) {
66        match self {
67            Self::Aws(id) => {
68                components.push(format!("aws_instance_id={}", id.instance_id));
69            }
70            Self::Azure(instance) => {
71                components.push(format!("azure_vm_id={}", instance.compute.vm_id));
72            }
73            Self::Gcp(instance) => {
74                components.push(format!("gcp_id={}", instance.instance_id));
75            }
76        }
77    }
78}
79
80impl MachineInfo {
81    pub fn fingerprint(&self) -> String {
82        let mut components = vec![];
83        if let Some(provider) = &self.cloud_provider {
84            provider.augment_fingerprint(&mut components);
85        }
86        if let Some(uid) = &self.machine_uid {
87            components.push(format!("machine_uid={uid}"));
88        }
89        if let Some(id) = &self.node_id {
90            components.push(format!("node_id={id}"));
91        }
92        if components.is_empty() {
93            components.push(format!("mac={}", self.mac_address));
94        }
95        components.join(",")
96    }
97
98    pub fn new() -> Self {
99        let hostname = gethostname::gethostname().to_string_lossy().to_string();
100        let mac = get_mac_address();
101        let mac_address = format!(
102            "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
103            mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
104        );
105
106        let machine_uid = machine_uid::get().ok();
107
108        let arch = System::cpu_arch();
109
110        let mut system = System::new();
111        system.refresh_memory();
112        system.refresh_cpu_all();
113
114        let mut cpu_info = vec![];
115        for cpu in system.cpus() {
116            let info = cpu.brand().to_string();
117            if cpu_info.contains(&info) {
118                continue;
119            }
120            cpu_info.push(info);
121        }
122        let cpu_brand = cpu_info.join(", ");
123
124        Self {
125            hostname,
126            machine_uid,
127            mac_address,
128            node_id: None,
129            num_cores: num_cpus::get(),
130            platform: format!("{}/{arch}", std::env::consts::OS),
131            distribution: System::distribution_id(),
132            os_version: System::long_os_version()
133                .unwrap_or_else(|| std::env::consts::OS.to_string()),
134            total_memory_bytes: system.total_memory(),
135            container_runtime: in_container::get_container_runtime().map(|r| r.to_string()),
136            kernel_version: System::kernel_version(),
137            cpu_brand,
138            cloud_provider: None,
139        }
140    }
141
142    /// Concurrently query for a known cloud providers
143    pub async fn query_cloud_provider(&mut self) {
144        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
145        {
146            let tx = tx.clone();
147            tokio::task::spawn(async move {
148                if let Ok(id) = aws::IdentityDocument::query().await {
149                    tx.send(CloudProvider::Aws(id)).ok();
150                }
151            });
152        }
153        {
154            let tx = tx.clone();
155            tokio::task::spawn(async move {
156                if let Ok(id) = azure::InstanceMetadata::query().await {
157                    tx.send(CloudProvider::Azure(id)).ok();
158                }
159            });
160        }
161        {
162            let tx = tx.clone();
163            tokio::task::spawn(async move {
164                if let Ok(id) = gcp::InstanceMetadata::query().await {
165                    tx.send(CloudProvider::Gcp(id)).ok();
166                }
167            });
168        }
169        drop(tx);
170
171        tokio::select! {
172            biased;
173
174            // Prefer to get a positive result
175            provider = rx.recv() => {
176                self.cloud_provider = provider;
177            }
178
179            // Overall timeout if things are taking too long
180            _ = tokio::time::sleep(tokio::time::Duration::from_secs(3)) => {}
181        };
182    }
183}
184
185pub mod azure {
186    use super::*;
187    use serde_json::Value;
188    use std::collections::BTreeMap;
189
190    #[serde_as]
191    #[derive(Deserialize, Debug)]
192    #[serde(rename_all = "camelCase")]
193    pub struct InstanceMetadata {
194        pub compute: Compute,
195        pub network: Network,
196    }
197
198    #[serde_as]
199    #[derive(Deserialize, Debug)]
200    #[serde(rename_all = "camelCase")]
201    pub struct Compute {
202        pub az_environment: String,
203        pub vm_id: String,
204        pub vm_size: String,
205        pub location: String,
206        #[serde(flatten)]
207        pub unknown_: BTreeMap<String, Value>,
208    }
209
210    #[serde_as]
211    #[derive(Deserialize, Debug)]
212    #[serde(rename_all = "camelCase")]
213    pub struct Network {
214        pub interface: Vec<NetworkInterface>,
215    }
216
217    #[serde_as]
218    #[derive(Deserialize, Debug)]
219    #[serde(rename_all = "camelCase")]
220    pub struct NetworkInterface {
221        pub ipv4: Ipv4Info,
222        pub mac_address: String,
223        #[serde(flatten)]
224        pub unknown_: BTreeMap<String, Value>,
225    }
226
227    #[serde_as]
228    #[derive(Deserialize, Debug)]
229    #[serde(rename_all = "camelCase")]
230    pub struct Ipv4Info {
231        pub ip_address: Vec<IpAddressInfo>,
232        pub subnet: Vec<SubnetInfo>,
233    }
234
235    #[serde_as]
236    #[derive(Deserialize, Debug)]
237    #[serde(rename_all = "camelCase")]
238    pub struct IpAddressInfo {
239        pub private_ip_address: String,
240        #[serde(default)]
241        pub public_ip_address: String,
242    }
243    #[serde_as]
244    #[derive(Deserialize, Debug)]
245    #[serde(rename_all = "camelCase")]
246    pub struct SubnetInfo {
247        pub address: String,
248        pub prefix: String,
249    }
250
251    impl InstanceMetadata {
252        pub async fn query_via(base_url: &str) -> anyhow::Result<Self> {
253            let client = reqwest::Client::builder()
254                .no_proxy()
255                .timeout(std::time::Duration::from_secs(1))
256                .build()
257                .unwrap();
258
259            let mut headers = HeaderMap::new();
260            headers.insert("Metadata", HeaderValue::from_static("true"));
261
262            let request = client
263                .request(
264                    Method::GET,
265                    &format!("{base_url}/metadata/instance?api-version=2021-02-01"),
266                )
267                .headers(headers)
268                .build()?;
269            let response = client.execute(request).await?;
270
271            let status = response.status();
272
273            let body_text = response
274                .text()
275                .await
276                .context("failed to read response body")?;
277            if status.is_client_error() || status.is_server_error() {
278                anyhow::bail!("failed to query identity: {status:?} {body_text}");
279            }
280
281            Ok(serde_json::from_str(&body_text)?)
282        }
283
284        pub async fn query() -> anyhow::Result<Self> {
285            Self::query_via("http://169.254.169.254").await
286        }
287    }
288
289    #[cfg(test)]
290    #[tokio::test]
291    async fn test_metadata() {
292        use mockito::Server;
293
294        let mut server = Server::new_async().await;
295        let _mock = server
296            .mock("GET", "/metadata/instance?api-version=2021-02-01")
297            .match_header("Metadata", "true")
298            .with_status(200)
299            .with_body(
300                r#"{
301    "compute": {
302        "azEnvironment": "AZUREPUBLICCLOUD",
303        "additionalCapabilities": {
304            "hibernationEnabled": "true"
305        },
306        "hostGroup": {
307          "id": "testHostGroupId"
308        },
309        "extendedLocation": {
310            "type": "edgeZone",
311            "name": "microsoftlosangeles"
312        },
313        "evictionPolicy": "",
314        "isHostCompatibilityLayerVm": "true",
315        "licenseType":  "",
316        "location": "westus",
317        "name": "examplevmname",
318        "offer": "UbuntuServer",
319        "osProfile": {
320            "adminUsername": "admin",
321            "computerName": "examplevmname",
322            "disablePasswordAuthentication": "true"
323        },
324        "osType": "Linux",
325        "placementGroupId": "f67c14ab-e92c-408c-ae2d-da15866ec79a",
326        "plan": {
327            "name": "planName",
328            "product": "planProduct",
329            "publisher": "planPublisher"
330        },
331        "platformFaultDomain": "36",
332        "platformSubFaultDomain": "",
333        "platformUpdateDomain": "42",
334        "priority": "Regular",
335        "publicKeys": [{
336                "keyData": "ssh-rsa 0",
337                "path": "/home/user/.ssh/authorized_keys0"
338            },
339            {
340                "keyData": "ssh-rsa 1",
341                "path": "/home/user/.ssh/authorized_keys1"
342            }
343        ],
344        "publisher": "Canonical",
345        "resourceGroupName": "macikgo-test-may-23",
346        "resourceId": "/subscriptions/xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourceGroups/macikgo-test-may-23/providers/Microsoft.Compute/virtualMachines/examplevmname",
347        "securityProfile": {
348            "secureBootEnabled": "true",
349            "virtualTpmEnabled": "false",
350            "encryptionAtHost": "true",
351            "securityType": "TrustedLaunch"
352        },
353        "sku": "18.04-LTS",
354        "storageProfile": {
355            "dataDisks": [{
356                "bytesPerSecondThrottle": "979202048",
357                "caching": "None",
358                "createOption": "Empty",
359                "diskCapacityBytes": "274877906944",
360                "diskSizeGB": "1024",
361                "image": {
362                  "uri": ""
363                },
364                "isSharedDisk": "false",
365                "isUltraDisk": "true",
366                "lun": "0",
367                "managedDisk": {
368                  "id": "/subscriptions/xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourceGroups/macikgo-test-may-23/providers/Microsoft.Compute/disks/exampledatadiskname",
369                  "storageAccountType": "StandardSSD_LRS"
370                },
371                "name": "exampledatadiskname",
372                "opsPerSecondThrottle": "65280",
373                "vhd": {
374                  "uri": ""
375                },
376                "writeAcceleratorEnabled": "false"
377            }],
378            "imageReference": {
379                "id": "",
380                "offer": "UbuntuServer",
381                "publisher": "Canonical",
382                "sku": "16.04.0-LTS",
383                "version": "latest",
384                "communityGalleryImageId": "/CommunityGalleries/testgallery/Images/1804Gen2/Versions/latest",
385                "sharedGalleryImageId": "/SharedGalleries/1P/Images/gen2/Versions/latest",
386                "exactVersion": "1.1686127202.30113"
387            },
388            "osDisk": {
389                "caching": "ReadWrite",
390                "createOption": "FromImage",
391                "diskSizeGB": "30",
392                "diffDiskSettings": {
393                    "option": "Local"
394                },
395                "encryptionSettings": {
396                  "enabled": "false",
397                  "diskEncryptionKey": {
398                    "sourceVault": {
399                      "id": "/subscriptions/test-source-guid/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/test-kv"
400                    },
401                    "secretUrl": "https://test-disk.vault.azure.net/secrets/xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx"
402                  },
403                  "keyEncryptionKey": {
404                    "sourceVault": {
405                      "id": "/subscriptions/test-key-guid/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/test-kv"
406                    },
407                    "keyUrl": "https://test-key.vault.azure.net/secrets/xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx"
408                  }
409                },
410                "image": {
411                    "uri": ""
412                },
413                "managedDisk": {
414                    "id": "/subscriptions/xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourceGroups/macikgo-test-may-23/providers/Microsoft.Compute/disks/exampleosdiskname",
415                    "storageAccountType": "StandardSSD_LRS"
416                },
417                "name": "exampleosdiskname",
418                "osType": "Linux",
419                "vhd": {
420                    "uri": ""
421                },
422                "writeAcceleratorEnabled": "false"
423            },
424            "resourceDisk": {
425                "size": "4096"
426            }
427        },
428        "subscriptionId": "xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx",
429        "tags": "baz:bash;foo:bar",
430        "version": "15.05.22",
431        "virtualMachineScaleSet": {
432            "id": "/subscriptions/xxxxxxxx-xxxxx-xxx-xxx-xxxx/resourceGroups/resource-group-name/providers/Microsoft.Compute/virtualMachineScaleSets/virtual-machine-scale-set-name"
433        },
434        "vmId": "02aab8a4-74ef-476e-8182-f6d2ba4166a6",
435        "vmScaleSetName": "crpteste9vflji9",
436        "vmSize": "Standard_A3",
437        "zone": ""
438    },
439    "network": {
440        "interface": [{
441            "ipv4": {
442               "ipAddress": [{
443                    "privateIpAddress": "10.144.133.132",
444                    "publicIpAddress": ""
445                }],
446                "subnet": [{
447                    "address": "10.144.133.128",
448                    "prefix": "26"
449                }]
450            },
451            "ipv6": {
452                "ipAddress": [
453                 ]
454            },
455            "macAddress": "0011AAFFBB22"
456        }]
457    }
458}"#,
459            )
460            .create_async()
461            .await;
462
463        let id = InstanceMetadata::query_via(&server.url()).await.unwrap();
464        eprintln!("{id:#?}");
465        assert_eq!(id.compute.vm_id, "02aab8a4-74ef-476e-8182-f6d2ba4166a6");
466    }
467}
468
469pub mod aws {
470    use super::*;
471
472    #[serde_as]
473    #[derive(Deserialize, Debug)]
474    #[serde(rename_all = "camelCase")]
475    pub struct IdentityDocument {
476        #[serde(default)]
477        #[serde_as(as = "serde_with::DefaultOnNull<_>")]
478        pub devpay_product_codes: Vec<String>,
479        #[serde(default)]
480        #[serde_as(as = "serde_with::DefaultOnNull<_>")]
481        pub marketplace_product_codes: Vec<String>,
482        pub availability_zone: String,
483        pub private_ip: String,
484        pub version: String,
485        pub instance_id: String,
486        #[serde(default)]
487        #[serde_as(as = "serde_with::DefaultOnNull<_>")]
488        pub billing_products: Vec<String>,
489        pub instance_type: String,
490        pub account_id: String,
491        pub image_id: String,
492        pub pending_time: DateTime<Utc>,
493        pub architecture: String,
494        pub kernel_id: Option<String>,
495        pub ramdisk_id: Option<String>,
496        pub region: String,
497    }
498
499    impl IdentityDocument {
500        pub async fn query_via(base_url: &str) -> anyhow::Result<Self> {
501            let client = reqwest::Client::builder()
502                .no_proxy()
503                .timeout(std::time::Duration::from_secs(1))
504                .build()
505                .unwrap();
506
507            // For IMDSv2, attempt to obtain a token first.
508            // In IMDSv1, this is not required, so we allow
509            // for this to fail and proceed to the next step
510            // without it
511
512            let mut token = None;
513
514            {
515                let mut headers = HeaderMap::new();
516                headers.insert(
517                    "X-aws-ec2-metadata-token-ttl-seconds",
518                    HeaderValue::from_static("60"),
519                );
520
521                let request = client
522                    .request(Method::PUT, &format!("{base_url}/latest/api/token"))
523                    .headers(headers)
524                    .build()?;
525
526                // Note that for client.execute() to error it is likely a timeout
527                // or a routing issue: if this happens, then we assume that IMDS
528                // is not present, so we don't try with a second request that will
529                // encounter the same issue, but take longer.
530                // So we propagate that particular error out and stop
531                // further progress.
532                let response = client.execute(request).await?;
533
534                if response.status().is_success() {
535                    if let Ok(content) = response.text().await {
536                        token.replace(content.trim().to_string());
537                    }
538                } else {
539                    // Some kind of protocol error: perhaps they are running
540                    // IMDSv1 rather than v2, so continue below without a token
541                }
542            }
543
544            let mut headers = HeaderMap::new();
545            if let Some(token) = token.as_deref() {
546                headers.insert("X-aws-ec2-metadata-token", HeaderValue::from_str(token)?);
547            }
548
549            let request = client
550                .request(
551                    Method::GET,
552                    &format!("{base_url}/latest/dynamic/instance-identity/document"),
553                )
554                .headers(headers)
555                .build()?;
556            let response = client.execute(request).await?;
557
558            let status = response.status();
559
560            let body_text = response
561                .text()
562                .await
563                .context("failed to read response body")?;
564            if status.is_client_error() || status.is_server_error() {
565                anyhow::bail!("failed to query identity: {status:?} {body_text}");
566            }
567
568            Ok(serde_json::from_str(&body_text)?)
569        }
570
571        pub async fn query() -> anyhow::Result<Self> {
572            Self::query_via("http://169.254.169.254").await
573        }
574    }
575
576    #[cfg(test)]
577    #[tokio::test]
578    async fn test_aws_identity_v1() {
579        use mockito::Server;
580
581        let mut server = Server::new_async().await;
582        let _mock = server
583            .mock("GET", "/latest/dynamic/instance-identity/document")
584            .with_status(200)
585            .with_body(
586                r#"{
587    "devpayProductCodes" : null,
588    "marketplaceProductCodes" : [ "1abc2defghijklm3nopqrs4tu" ],
589    "availabilityZone" : "us-west-2b",
590    "privateIp" : "10.158.112.84",
591    "version" : "2017-09-30",
592    "instanceId" : "i-1234567890abcdef0",
593    "billingProducts" : null,
594    "instanceType" : "t2.micro",
595    "accountId" : "123456789012",
596    "imageId" : "ami-5fb8c835",
597    "pendingTime" : "2016-11-19T16:32:11Z",
598    "architecture" : "x86_64",
599    "kernelId" : null,
600    "ramdiskId" : null,
601    "region" : "us-west-2"
602}"#,
603            )
604            .create_async()
605            .await;
606
607        let id = IdentityDocument::query_via(&server.url()).await.unwrap();
608        eprintln!("{id:#?}");
609        assert_eq!(id.instance_id, "i-1234567890abcdef0");
610    }
611
612    #[cfg(test)]
613    #[tokio::test]
614    async fn test_aws_identity_v2() {
615        use mockito::Server;
616
617        let token = "fake-token";
618        let mut server = Server::new_async().await;
619        let _mock = server
620            .mock("PUT", "/latest/api/token")
621            .match_header("X-aws-ec2-metadata-token-ttl-seconds", "60")
622            .with_status(200)
623            .with_body(token)
624            .create_async()
625            .await;
626        let _mock = server
627            .mock("GET", "/latest/dynamic/instance-identity/document")
628            .with_status(200)
629            .match_header("X-aws-ec2-metadata-token", token)
630            .with_body(
631                r#"{
632    "devpayProductCodes" : null,
633    "marketplaceProductCodes" : [ "1abc2defghijklm3nopqrs4tu" ],
634    "availabilityZone" : "us-west-2b",
635    "privateIp" : "10.158.112.84",
636    "version" : "2017-09-30",
637    "instanceId" : "i-1234567890abcdef0",
638    "billingProducts" : null,
639    "instanceType" : "t2.micro",
640    "accountId" : "123456789012",
641    "imageId" : "ami-5fb8c835",
642    "pendingTime" : "2016-11-19T16:32:11Z",
643    "architecture" : "x86_64",
644    "kernelId" : null,
645    "ramdiskId" : null,
646    "region" : "us-west-2"
647}"#,
648            )
649            .create_async()
650            .await;
651
652        let id = IdentityDocument::query_via(&server.url()).await.unwrap();
653        eprintln!("{id:#?}");
654        assert_eq!(id.instance_id, "i-1234567890abcdef0");
655    }
656}
657
658pub mod gcp {
659    use super::*;
660
661    #[derive(Debug)]
662    pub struct InstanceMetadata {
663        pub instance_id: String,
664    }
665
666    impl InstanceMetadata {
667        pub async fn query_via(base_url: &str) -> anyhow::Result<Self> {
668            let client = reqwest::Client::builder()
669                .no_proxy()
670                .timeout(std::time::Duration::from_secs(1))
671                .build()
672                .unwrap();
673
674            let mut headers = HeaderMap::new();
675            headers.insert("Metadata-Flavor", HeaderValue::from_static("Google"));
676
677            let request = client
678                .request(
679                    Method::GET,
680                    &format!("{base_url}/computeMetadata/v1/instance/id"),
681                )
682                .headers(headers)
683                .build()?;
684            let response = client.execute(request).await?;
685            let status = response.status();
686
687            let instance_id = response
688                .text()
689                .await
690                .context("failed to read response body")?
691                .trim()
692                .to_string();
693            if status.is_client_error() || status.is_server_error() {
694                anyhow::bail!("failed to query identity: {status:?} {instance_id}");
695            }
696
697            Ok(Self { instance_id })
698        }
699
700        pub async fn query() -> anyhow::Result<Self> {
701            Self::query_via("http://metadata.google.internal").await
702        }
703    }
704
705    #[cfg(test)]
706    #[tokio::test]
707    async fn test_gcp() {
708        use mockito::Server;
709
710        let mut server = Server::new_async().await;
711        let _mock = server
712            .mock("GET", "/computeMetadata/v1/instance/id")
713            .with_status(200)
714            .with_body("some_id")
715            .create_async()
716            .await;
717
718        let id = InstanceMetadata::query_via(&server.url()).await.unwrap();
719        eprintln!("{id:#?}");
720        assert_eq!(id.instance_id, "some_id");
721    }
722}
723
724#[cfg(test)]
725mod test {
726    #[test]
727    fn test_machine_info() {
728        use super::*;
729        let info = MachineInfo::new();
730        eprintln!("{}", info.fingerprint());
731        eprintln!("{info:#?}");
732        /* It's hard to make a test assertion that will run anywhere
733         * because this code is all about being machine specific.
734         * This is here to help me see what the output looks like
735         * while hacking on this.
736         */
737        // panic!("{info:#?}");
738    }
739}