1use std::ffi::OsStr;
14use std::io::{self, Write};
15use std::path::PathBuf;
16use std::process::ExitCode;
17
18use anyhow::{Context, Result, anyhow};
19use clap::{Parser, Subcommand, ValueEnum};
20use host_identity::ids::{resolver_from_ids, source_ids};
21use host_identity::sources::FileOverride;
22use host_identity::{HostId, ResolveOutcome, Resolver, SourceKind, UnknownSourceError, Wrap};
23use serde::Serialize;
24
25const HOST_IDENTITY_FILE_ENV: &str = "HOST_IDENTITY_FILE";
29
30#[cfg(feature = "network")]
31mod transport;
32
33pub const VERSION: &str = env!("CARGO_PKG_VERSION");
36
37const LONG_ABOUT: &str = "\
38Resolve a stable, collision-resistant host UUID across platforms, container \
39runtimes, cloud providers, and Kubernetes.
40
41host-identity walks a platform-appropriate chain of identity sources (env override, \
42/etc/machine-id, DMI, cloud metadata, Kubernetes pod UID, …) and returns the \
43first one that produces a credible identifier. Cloned-VM sentinels, empty \
44files, and systemd's literal `uninitialized` string are rejected rather than \
45silently hashed into a shared ID.
46
47Two environment variables pin identity explicitly when the automatic chain \
48gets it wrong. HOST_IDENTITY_FILE names a file whose contents are used as \
49the host identifier and takes precedence over every other source, including \
50HOST_IDENTITY. HOST_IDENTITY supplies the identifier inline and is consulted \
51next. Both work with the default chain and with explicit --sources.
52
53By default the chain uses only local sources. Pass --network to pull in \
54cloud-metadata and Kubernetes probes, which require an HTTP client and a \
55binary built with the `network` feature.";
56
57const EXAMPLES: &str = "\
58EXAMPLES:
59 Print the host UUID using the default local source chain:
60 host-identity
61
62 Include cloud-metadata and Kubernetes sources:
63 host-identity resolve --network
64
65 Build a custom chain from explicit source identifiers:
66 host-identity resolve --sources env-override,machine-id,dmi
67
68 Emit machine-readable output:
69 host-identity resolve --format json
70 host-identity audit --format json
71
72 Pin identity via environment override:
73 HOST_IDENTITY=11111111-2222-3333-4444-555555555555 host-identity
74
75 Pin identity via a file (takes precedence over HOST_IDENTITY):
76 HOST_IDENTITY_FILE=/etc/host-identity host-identity
77
78 List every source identifier compiled into this binary:
79 host-identity sources
80";
81
82#[derive(Parser)]
84#[command(
85 name = "host-identity",
86 version,
87 author,
88 about = "Resolve a stable host UUID across platforms, clouds, and Kubernetes",
89 long_about = LONG_ABOUT,
90 after_long_help = EXAMPLES,
91 args_conflicts_with_subcommands = true,
92)]
93pub struct Cli {
94 #[command(subcommand)]
95 command: Option<Command>,
96
97 #[command(flatten)]
100 resolve: ResolveArgs,
101}
102
103#[derive(Subcommand)]
104enum Command {
105 Resolve(ResolveArgs),
107 Audit(AuditArgs),
109 Sources {
111 #[arg(long)]
113 json: bool,
114 },
115}
116
117#[derive(Parser, Clone, Default)]
118struct ResolveArgs {
119 #[arg(long, value_enum, default_value_t = Format::Plain)]
121 format: Format,
122
123 #[arg(long, value_enum, default_value_t = WrapArg::V5)]
125 wrap: WrapArg,
126
127 #[arg(long, value_delimiter = ',')]
131 sources: Vec<String>,
132
133 #[arg(long)]
138 network: bool,
139
140 #[arg(long, value_name = "MS", value_parser = clap::value_parser!(u64).range(1..))]
145 network_timeout_ms: Option<u64>,
146}
147
148#[derive(Parser, Clone, Default)]
149struct AuditArgs {
150 #[command(flatten)]
151 resolve: ResolveArgs,
152}
153
154#[derive(ValueEnum, Clone, Copy, Default)]
155enum Format {
156 #[default]
157 Plain,
158 Summary,
159 Json,
160}
161
162#[derive(ValueEnum, Clone, Copy, Default)]
163enum WrapArg {
164 #[default]
165 V5,
166 V3,
167 Passthrough,
168}
169
170impl From<WrapArg> for Wrap {
171 fn from(w: WrapArg) -> Self {
172 match w {
173 WrapArg::V5 => Wrap::UuidV5Namespaced,
174 WrapArg::V3 => Wrap::UuidV3Nil,
175 WrapArg::Passthrough => Wrap::Passthrough,
176 }
177 }
178}
179
180const EXIT_USAGE: u8 = 2;
184
185#[derive(Debug)]
187enum CliError {
188 Usage(anyhow::Error),
189 Runtime(anyhow::Error),
190}
191
192impl CliError {
193 fn exit_code(&self) -> ExitCode {
194 match self {
195 Self::Usage(_) => ExitCode::from(EXIT_USAGE),
196 Self::Runtime(_) => ExitCode::FAILURE,
197 }
198 }
199 fn into_inner(self) -> anyhow::Error {
200 match self {
201 Self::Usage(e) | Self::Runtime(e) => e,
202 }
203 }
204}
205
206fn usage<T>(msg: anyhow::Error) -> Result<T, CliError> {
207 Err(CliError::Usage(msg))
208}
209
210fn runtime_err<E: Into<anyhow::Error>>(e: E) -> CliError {
211 CliError::Runtime(e.into())
212}
213
214fn runtime<T>(msg: anyhow::Error) -> Result<T, CliError> {
215 Err(CliError::Runtime(msg))
216}
217
218#[must_use]
220pub fn run() -> ExitCode {
221 let cli = Cli::parse();
222 let result = match cli.command {
223 Some(Command::Resolve(args)) => run_resolve(&args),
224 Some(Command::Audit(args)) => run_audit(&args.resolve),
225 Some(Command::Sources { json }) => run_sources(json),
226 None => run_resolve(&cli.resolve),
227 };
228 match result {
229 Ok(()) => ExitCode::SUCCESS,
230 Err(err) => {
231 let code = err.exit_code();
232 eprintln!("host-identity: {:#}", err.into_inner());
233 code
234 }
235 }
236}
237
238fn write_and_flush(bytes: &[u8]) -> io::Result<()> {
241 let stdout = io::stdout();
242 let mut lock = stdout.lock();
243 match lock.write_all(bytes).and_then(|()| lock.flush()) {
244 Ok(()) => Ok(()),
245 Err(err) if err.kind() == io::ErrorKind::BrokenPipe => Ok(()),
246 Err(err) => Err(err),
247 }
248}
249
250fn build_resolver(args: &ResolveArgs) -> Result<Resolver, CliError> {
251 if args.network_timeout_ms.is_some() && !args.network {
252 return usage(anyhow!("`--network-timeout-ms` requires `--network`"));
253 }
254 let resolver = match (args.sources.is_empty(), args.network) {
255 (true, false) => Resolver::with_defaults(),
256 (true, true) => network_defaults(args.network_timeout_ms).map_err(CliError::Usage)?,
257 (false, false) => {
258 resolver_from_ids(&args.sources).map_err(|e| CliError::Usage(map_unknown(e)))?
259 }
260 (false, true) => resolver_from_ids_network(&args.sources, args.network_timeout_ms)
261 .map_err(CliError::Usage)?,
262 };
263
264 let mut resolver = resolver.with_wrap(Wrap::from(args.wrap));
265 if let Some(file_override) = host_identity_file_override() {
266 resolver = resolver.prepend(file_override);
267 }
268 Ok(resolver)
269}
270
271fn host_identity_file_override() -> Option<FileOverride> {
276 file_override_from_env_value(std::env::var_os(HOST_IDENTITY_FILE_ENV).as_deref())
277}
278
279fn file_override_from_env_value(value: Option<&OsStr>) -> Option<FileOverride> {
286 let raw = value?;
287 if raw.is_empty() {
288 return None;
289 }
290 Some(FileOverride::new(PathBuf::from(raw)))
291}
292
293#[cfg(feature = "network")]
294#[allow(clippy::unnecessary_wraps)]
295fn network_defaults(timeout_ms: Option<u64>) -> Result<Resolver> {
296 Ok(Resolver::with_network_defaults(build_transport(timeout_ms)))
297}
298
299#[cfg(not(feature = "network"))]
300fn network_defaults(_timeout_ms: Option<u64>) -> Result<Resolver> {
301 Err(network_feature_disabled())
302}
303
304#[cfg(feature = "network")]
305fn resolver_from_ids_network(ids: &[String], timeout_ms: Option<u64>) -> Result<Resolver> {
306 host_identity::ids::resolver_from_ids_with_transport(ids, build_transport(timeout_ms))
307 .map_err(map_unknown)
308}
309
310#[cfg(not(feature = "network"))]
311fn resolver_from_ids_network(_ids: &[String], _timeout_ms: Option<u64>) -> Result<Resolver> {
312 Err(network_feature_disabled())
313}
314
315#[cfg(feature = "network")]
316fn build_transport(timeout_ms: Option<u64>) -> transport::UreqTransport {
317 let timeout = timeout_ms.map_or(
318 transport::DEFAULT_NETWORK_TIMEOUT,
319 std::time::Duration::from_millis,
320 );
321 transport::UreqTransport::with_timeout(timeout)
322}
323
324#[cfg(not(feature = "network"))]
325fn network_feature_disabled() -> anyhow::Error {
326 anyhow!("this build has no `network` feature; rebuild with `--features network`")
327}
328
329fn map_unknown(err: UnknownSourceError) -> anyhow::Error {
330 match err {
331 UnknownSourceError::Unknown(id) => anyhow!("unknown source identifier: `{id}`"),
332 UnknownSourceError::RequiresPath(id) => anyhow!(
333 "source `{id}` requires a caller-supplied path and cannot be built from an identifier",
334 ),
335 UnknownSourceError::RequiresTransport(id) => {
336 anyhow!("source `{id}` is a cloud source; pass `--network` to supply an HTTP transport")
337 }
338 UnknownSourceError::FeatureDisabled(id, feat) => anyhow!(
339 "source `{id}` requires the `{feat}` feature, which isn't enabled in this build",
340 ),
341 }
342}
343
344fn run_resolve(args: &ResolveArgs) -> Result<(), CliError> {
345 let resolver = build_resolver(args)?;
346 let id = resolver
347 .resolve()
348 .context("no source produced a host identity")
349 .map_err(CliError::Runtime)?;
350 print_host_id(&id, args.format).map_err(CliError::Runtime)
351}
352
353fn run_audit(args: &ResolveArgs) -> Result<(), CliError> {
354 let resolver = build_resolver(args)?;
355 let outcomes = resolver.resolve_all();
356 let mut buf = Vec::new();
357 match args.format {
358 Format::Json => {
359 let report: Vec<AuditEntry> = outcomes.iter().map(AuditEntry::from).collect();
360 serde_json::to_writer_pretty(&mut buf, &report).map_err(runtime_err)?;
361 buf.push(b'\n');
362 }
363 Format::Plain | Format::Summary => {
364 for (i, outcome) in outcomes.iter().enumerate() {
365 let kind = outcome.source();
366 let tail = match outcome {
367 ResolveOutcome::Found(id) => id.summary().to_string(),
368 ResolveOutcome::Skipped(_) => "(skipped)".to_owned(),
369 ResolveOutcome::Errored(_, err) => format!("ERROR {err}"),
370 };
371 writeln!(buf, "{i:>2}. {kind:<28} -> {tail}").map_err(runtime_err)?;
372 }
373 }
374 }
375 write_and_flush(&buf).map_err(runtime_err)?;
376
377 if !outcomes
380 .iter()
381 .any(|o| matches!(o, ResolveOutcome::Found(_)))
382 {
383 return runtime(anyhow!("no source produced a host identity"));
384 }
385 Ok(())
386}
387
388fn run_sources(json: bool) -> Result<(), CliError> {
389 let ids = available_source_ids();
390 let mut buf = Vec::new();
391 if json {
392 let entries: Vec<SourceEntry> = ids
393 .iter()
394 .map(|id| SourceEntry {
395 id,
396 description: describe_id(id),
397 })
398 .collect();
399 serde_json::to_writer_pretty(&mut buf, &entries).map_err(runtime_err)?;
400 buf.push(b'\n');
401 } else {
402 let width = ids
406 .iter()
407 .map(|id| id.chars().count())
408 .max()
409 .unwrap_or_default();
410 for id in &ids {
411 writeln!(buf, "{id:<width$} {}", describe_id(id), width = width)
412 .map_err(runtime_err)?;
413 }
414 }
415 write_and_flush(&buf).map_err(runtime_err)
416}
417
418fn describe_id(id: &str) -> &'static str {
419 SourceKind::from_id(id).map_or("", SourceKind::describe)
420}
421
422#[derive(Serialize)]
423struct SourceEntry {
424 id: &'static str,
425 description: &'static str,
426}
427
428fn print_host_id(id: &HostId, format: Format) -> Result<()> {
429 let mut buf = Vec::new();
430 match format {
431 Format::Plain => writeln!(buf, "{id}")?,
432 Format::Summary => writeln!(buf, "{}", id.summary())?,
433 Format::Json => {
434 let out = HostIdJson {
435 uuid: id.as_uuid().to_string(),
436 source: id.source().as_str(),
437 in_container: id.in_container(),
438 };
439 serde_json::to_writer_pretty(&mut buf, &out)?;
440 buf.push(b'\n');
441 }
442 }
443 write_and_flush(&buf)?;
444 Ok(())
445}
446
447#[derive(Serialize)]
448struct HostIdJson {
449 uuid: String,
450 source: &'static str,
451 in_container: bool,
452}
453
454#[derive(Serialize, Clone, Copy)]
455#[serde(rename_all = "lowercase")]
456enum AuditStatus {
457 Found,
458 Skipped,
459 Errored,
460}
461
462#[derive(Serialize)]
463struct AuditEntry {
464 source: &'static str,
465 status: AuditStatus,
466 uuid: Option<String>,
467 error: Option<String>,
468 in_container: Option<bool>,
469}
470
471impl From<&ResolveOutcome> for AuditEntry {
472 fn from(o: &ResolveOutcome) -> Self {
473 let source = o.source().as_str();
474 match o {
475 ResolveOutcome::Found(id) => Self {
476 source,
477 status: AuditStatus::Found,
478 uuid: Some(id.as_uuid().to_string()),
479 error: None,
480 in_container: Some(id.in_container()),
481 },
482 ResolveOutcome::Skipped(_) => Self {
483 source,
484 status: AuditStatus::Skipped,
485 uuid: None,
486 error: None,
487 in_container: None,
488 },
489 ResolveOutcome::Errored(_, err) => Self {
490 source,
491 status: AuditStatus::Errored,
492 uuid: None,
493 error: Some(err.to_string()),
494 in_container: None,
495 },
496 }
497 }
498}
499
500fn available_source_ids() -> Vec<&'static str> {
501 let mut ids = vec![
502 source_ids::ENV_OVERRIDE,
503 source_ids::FILE_OVERRIDE,
504 source_ids::MACHINE_ID,
505 source_ids::DBUS_MACHINE_ID,
506 source_ids::DMI,
507 source_ids::IO_PLATFORM_UUID,
508 source_ids::WINDOWS_MACHINE_GUID,
509 source_ids::FREEBSD_HOSTID,
510 source_ids::KENV_SMBIOS,
511 source_ids::BSD_KERN_HOSTID,
512 source_ids::ILLUMOS_HOSTID,
513 ];
514 #[cfg(feature = "container")]
515 {
516 ids.push(source_ids::CONTAINER);
517 ids.push(source_ids::LXC);
518 }
519 #[cfg(feature = "network")]
520 {
521 ids.extend_from_slice(&[
522 source_ids::AWS_IMDS,
523 source_ids::GCP_METADATA,
524 source_ids::AZURE_IMDS,
525 source_ids::DIGITAL_OCEAN_METADATA,
526 source_ids::HETZNER_METADATA,
527 source_ids::OCI_METADATA,
528 source_ids::KUBERNETES_POD_UID,
529 source_ids::KUBERNETES_SERVICE_ACCOUNT,
530 source_ids::KUBERNETES_DOWNWARD_API,
531 ]);
532 }
533 ids.sort_unstable();
534 ids
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn wrap_arg_maps_every_variant_to_library_wrap() {
543 assert!(matches!(Wrap::from(WrapArg::V5), Wrap::UuidV5Namespaced));
544 assert!(matches!(Wrap::from(WrapArg::V3), Wrap::UuidV3Nil));
545 assert!(matches!(
546 Wrap::from(WrapArg::Passthrough),
547 Wrap::Passthrough
548 ));
549 }
550
551 #[test]
552 fn available_source_ids_is_sorted_and_deduplicated() {
553 let ids = available_source_ids();
554 assert!(
555 ids.windows(2).all(|w| w[0] < w[1]),
556 "ids must be strictly sorted"
557 );
558 assert!(ids.contains(&source_ids::MACHINE_ID));
559 assert!(ids.contains(&source_ids::DMI));
560 }
561
562 #[test]
563 #[cfg(feature = "container")]
564 fn available_source_ids_includes_container_when_feature_enabled() {
565 assert!(available_source_ids().contains(&source_ids::CONTAINER));
566 assert!(available_source_ids().contains(&source_ids::LXC));
567 }
568
569 #[test]
570 fn build_resolver_defaults_when_no_flags_given() {
571 let args = ResolveArgs::default();
572 let resolver = build_resolver(&args).expect("defaults build");
573 assert!(
574 resolver
575 .source_kinds()
576 .contains(&host_identity::SourceKind::EnvOverride),
577 "default chain must include env-override",
578 );
579 }
580
581 #[test]
582 fn build_resolver_uses_ids_chain_when_sources_set() {
583 let args = ResolveArgs {
584 sources: vec!["env-override".into(), "machine-id".into()],
585 ..Default::default()
586 };
587 let resolver = build_resolver(&args).expect("ids build");
588 let kinds = resolver.source_kinds();
589 assert_eq!(kinds.len(), 2);
590 assert_eq!(kinds[0], host_identity::SourceKind::EnvOverride);
591 assert_eq!(kinds[1], host_identity::SourceKind::MachineId);
592 }
593
594 #[test]
595 fn build_resolver_rejects_unknown_source_id() {
596 let args = ResolveArgs {
597 sources: vec!["definitely-not-a-source".into()],
598 ..Default::default()
599 };
600 let err = build_resolver(&args).expect_err("unknown id must fail");
601 assert!(
602 err.into_inner()
603 .to_string()
604 .contains("unknown source identifier")
605 );
606 }
607
608 #[test]
609 #[cfg(feature = "network")]
610 fn build_resolver_network_defaults_includes_cloud_sources() {
611 let args = ResolveArgs {
612 network: true,
613 ..Default::default()
614 };
615 let resolver = build_resolver(&args).expect("network defaults build");
616 assert!(
617 resolver
618 .source_kinds()
619 .contains(&host_identity::SourceKind::AwsImds),
620 "--network should add cloud sources to the default chain",
621 );
622 }
623
624 #[test]
625 #[cfg(feature = "network")]
626 fn build_resolver_network_plus_ids_resolves_cloud_identifiers() {
627 let args = ResolveArgs {
628 sources: vec!["aws-imds".into()],
629 network: true,
630 ..Default::default()
631 };
632 let resolver = build_resolver(&args).expect("network + ids build");
633 assert_eq!(
634 resolver.source_kinds(),
635 vec![host_identity::SourceKind::AwsImds]
636 );
637 }
638
639 #[test]
640 #[cfg(not(feature = "network"))]
641 fn build_resolver_network_without_feature_errors() {
642 let args = ResolveArgs {
643 network: true,
644 ..Default::default()
645 };
646 let err = build_resolver(&args).expect_err("--network must fail without feature");
647 assert!(err.into_inner().to_string().contains("`network` feature"));
648 }
649
650 #[test]
651 fn build_resolver_rejects_network_timeout_without_network() {
652 let args = ResolveArgs {
653 network_timeout_ms: Some(500),
654 ..Default::default()
655 };
656 let err = build_resolver(&args).expect_err("must reject timeout without --network");
657 assert!(
658 err.into_inner()
659 .to_string()
660 .contains("requires `--network`")
661 );
662 }
663
664 #[test]
665 fn map_unknown_formats_each_variant_distinctly() {
666 let cases = [
667 (
668 UnknownSourceError::Unknown("weird".to_owned()),
669 "unknown source identifier",
670 ),
671 (
672 UnknownSourceError::RequiresPath("file-override"),
673 "caller-supplied path",
674 ),
675 (
676 UnknownSourceError::RequiresTransport("aws-imds"),
677 "pass `--network`",
678 ),
679 (
680 UnknownSourceError::FeatureDisabled("aws-imds", "aws"),
681 "isn't enabled in this build",
682 ),
683 ];
684 for (err, expected_fragment) in cases {
685 let msg = map_unknown(err).to_string();
686 assert!(
687 msg.contains(expected_fragment),
688 "message {msg:?} missing fragment {expected_fragment:?}",
689 );
690 }
691 }
692
693 #[test]
694 fn file_override_from_env_value_handles_absent_empty_and_set() {
695 assert!(file_override_from_env_value(None).is_none());
696 assert!(file_override_from_env_value(Some(OsStr::new(""))).is_none());
697 let fo = file_override_from_env_value(Some(OsStr::new("/tmp/host-id")))
698 .expect("non-empty value must yield a FileOverride");
699 assert_eq!(fo.path(), std::path::Path::new("/tmp/host-id"));
700 }
701
702 #[test]
703 fn host_id_json_schema_is_stable() {
704 let sample = HostIdJson {
708 uuid: "11111111-2222-3333-4444-555555555555".to_owned(),
709 source: "machine-id",
710 in_container: false,
711 };
712 let json = serde_json::to_value(&sample).unwrap();
713 let obj = json.as_object().unwrap();
714 assert_eq!(obj.len(), 3);
715 assert_eq!(obj["uuid"], "11111111-2222-3333-4444-555555555555");
716 assert_eq!(obj["source"], "machine-id");
717 assert_eq!(obj["in_container"], false);
718 }
719
720 #[test]
721 fn audit_entry_schema_is_stable_for_every_status() {
722 use host_identity::sources::FnSource;
723 let found_src = FnSource::new(SourceKind::custom("ok"), || Ok(Some("raw".into())));
724 let err_src = FnSource::new(SourceKind::custom("bad"), || {
725 Err(host_identity::Error::Platform {
726 source_kind: SourceKind::custom("bad"),
727 reason: "synthetic".into(),
728 })
729 });
730 let skip_src = FnSource::new(SourceKind::custom("skip"), || Ok(None));
731 let outcomes = Resolver::new()
732 .push(found_src)
733 .push(err_src)
734 .push(skip_src)
735 .resolve_all();
736 let entries: Vec<AuditEntry> = outcomes.iter().map(AuditEntry::from).collect();
737 let json = serde_json::to_value(&entries).unwrap();
738 let arr = json.as_array().unwrap();
739 assert_eq!(arr.len(), 3);
740 assert_eq!(arr[0]["status"], "found");
741 assert!(arr[0]["uuid"].is_string());
742 assert_eq!(arr[0]["error"], serde_json::Value::Null);
743 assert_eq!(arr[1]["status"], "errored");
744 assert!(arr[1]["error"].as_str().unwrap().contains("synthetic"));
745 assert_eq!(arr[1]["uuid"], serde_json::Value::Null);
746 assert_eq!(arr[2]["status"], "skipped");
747 for entry in arr {
749 let keys: Vec<_> = entry.as_object().unwrap().keys().collect();
750 assert_eq!(keys.len(), 5);
751 }
752 }
753
754 #[test]
755 #[cfg(feature = "network")]
756 fn available_source_ids_includes_every_cloud_and_k8s_source() {
757 let ids = available_source_ids();
758 for id in [
759 source_ids::AWS_IMDS,
760 source_ids::GCP_METADATA,
761 source_ids::AZURE_IMDS,
762 source_ids::DIGITAL_OCEAN_METADATA,
763 source_ids::HETZNER_METADATA,
764 source_ids::OCI_METADATA,
765 source_ids::KUBERNETES_POD_UID,
766 source_ids::KUBERNETES_SERVICE_ACCOUNT,
767 source_ids::KUBERNETES_DOWNWARD_API,
768 ] {
769 assert!(ids.contains(&id), "missing {id}");
770 }
771 }
772}