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
12fn get_mac_address_once() -> [u8; 6] {
19 match mac_address::get_mac_address() {
20 Ok(Some(addr)) => addr.bytes(),
21 _ => {
22 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(aws::IdentityDocument),
58 Azure(azure::InstanceMetadata),
60 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 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 provider = rx.recv() => {
176 self.cloud_provider = provider;
177 }
178
179 _ = 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 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 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 }
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 }
739}