host_identity/lib.rs
1//! Stable, collision-resistant host identity.
2//!
3//! Many agents and telemetry pipelines need a *stable* identifier for the
4//! host they run on: one that survives restarts and upgrades but distinguishes
5//! two otherwise-identical hosts. The obvious source on modern Linux is
6//! `/etc/machine-id`, but relying on it alone is unreliable: cloned VMs share
7//! IDs, LXC guests often inherit the host's ID, minimal container images
8//! have no file at all, and systemd writes the literal string `uninitialized`
9//! during early boot.
10//!
11//! `host-identity` exposes every known-good identity source as a composable
12//! [`Source`] implementation. Consumers can either take the default
13//! platform-appropriate chain or mix and match sources in any order to match
14//! their own policy.
15//!
16//! # The common case
17//!
18//! ```no_run
19//! // Local-only default chain: env override → platform sources →
20//! // (container ID when applicable). No network calls.
21//! let id = host_identity::resolve()?;
22//! println!("{id}");
23//! # Ok::<(), host_identity::Error>(())
24//! ```
25//!
26//! When you want cloud-metadata endpoints in the chain as well, use
27//! [`resolve_with_transport`] with your HTTP client of choice. That
28//! chain is strictly richer than the local default: it includes every
29//! cloud and Kubernetes source the feature set enabled, ordered so that
30//! per-pod identity outranks per-container outranks per-instance
31//! outranks per-host software state.
32//!
33//! # Identifier-based chains (config-driven)
34//!
35//! For operator-facing config files, build a chain from a list of
36//! short string identifiers:
37//!
38//! ```
39//! use host_identity::ids::{resolver_from_ids, source_ids};
40//!
41//! // Equivalent to:
42//! // Resolver::new()
43//! // .push(EnvOverride::new("HOST_IDENTITY"))
44//! // .push(MachineIdFile::default())
45//! // .push(DmiProductUuid::default())
46//! let resolver = resolver_from_ids([
47//! source_ids::ENV_OVERRIDE,
48//! source_ids::MACHINE_ID,
49//! source_ids::DMI,
50//! ]).unwrap();
51//! ```
52//!
53//! See [`ids`] for the full list of identifiers and the
54//! `_with_transport` variant for cloud sources.
55//!
56//! # Auditing every source
57//!
58//! [`resolve_all`] and [`resolve_all_with_transport`] walk the same
59//! chains without short-circuiting and return one [`ResolveOutcome`]
60//! per source. Use them when you want to see what every source would
61//! produce — operator diagnostics, debugging, cross-validation. To
62//! audit a caller-chosen subset, build the resolver with exactly those
63//! sources and call [`Resolver::resolve_all`]:
64//!
65//! ```no_run
66//! use host_identity::Resolver;
67//! use host_identity::sources::{MachineIdFile, DmiProductUuid};
68//!
69//! let outcomes = Resolver::new()
70//! .push(MachineIdFile::default())
71//! .push(DmiProductUuid::default())
72//! .resolve_all();
73//! for outcome in outcomes {
74//! println!("{:?} → {:?}", outcome.source(), outcome.host_id());
75//! }
76//! ```
77//!
78//! # Mixing and matching
79//!
80//! Every built-in source is a public type that implements [`Source`]. Chain
81//! them in any order, add your own, and pick the wrap strategy:
82//!
83//! ```no_run
84//! use host_identity::{Resolver, Wrap};
85//! use host_identity::sources::{EnvOverride, FileOverride, MachineIdFile, DmiProductUuid};
86//!
87//! let id = Resolver::new()
88//! .push(EnvOverride::new("MY_APP_HOST_ID"))
89//! .push(DmiProductUuid::default()) // SMBIOS first — stable across OS reinstalls
90//! .push(MachineIdFile::default())
91//! .push(FileOverride::new("/etc/my-app/host-id"))
92//! .with_wrap(Wrap::UuidV5Namespaced)
93//! .resolve()?;
94//! # Ok::<(), host_identity::Error>(())
95//! ```
96//!
97//! # Starting from the defaults
98//!
99//! [`Resolver::with_defaults`] pre-loads the platform's default chain. Use
100//! [`Resolver::prepend`] to add higher-priority sources (e.g. your own
101//! override) or [`Resolver::push`] to add fallbacks:
102//!
103//! ```no_run
104//! use host_identity::Resolver;
105//! use host_identity::sources::FileOverride;
106//!
107//! let id = Resolver::with_defaults()
108//! .push(FileOverride::new("/etc/host-identity")) // last-resort fallback
109//! .resolve()?;
110//! # Ok::<(), host_identity::Error>(())
111//! ```
112//!
113//! # Custom sources
114//!
115//! Implement [`Source`] directly, or wrap a closure with
116//! [`sources::FnSource`]:
117//!
118//! ```no_run
119//! use host_identity::sources::FnSource;
120//! use host_identity::{Resolver, SourceKind};
121//!
122//! let custom = FnSource::new(SourceKind::custom("hsm"), || {
123//! // Read from an HSM, a custom config file, an in-house identity
124//! // service, etc.
125//! Ok(Some("h-0abc1234".to_owned()))
126//! });
127//!
128//! let id = Resolver::new().push(custom).resolve()?;
129//! # Ok::<(), host_identity::Error>(())
130//! ```
131//!
132//! # Cloud-metadata sources
133//!
134//! Each major cloud provider has a dedicated source behind an opt-in
135//! feature flag. Sources are generic over a caller-supplied
136//! [`transport::HttpTransport`]; the crate ships no HTTP client of its
137//! own.
138//!
139//! | Source | Feature |
140//! | ----------------------------------- | -------------- |
141//! | [`sources::AwsImds`] | `aws` |
142//! | [`sources::GcpMetadata`] | `gcp` |
143//! | [`sources::AzureImds`] | `azure` |
144//! | [`sources::DigitalOceanMetadata`] | `digitalocean` |
145//! | [`sources::HetznerMetadata`] | `hetzner` |
146//! | [`sources::OciMetadata`] | `oci` |
147//! | [`sources::OpenStackMetadata`] | `openstack` |
148//!
149//! Implement [`transport::HttpTransport`] for your HTTP client of choice
150//! (adapters are ~10 lines against `reqwest`, `ureq`, `hyper`, etc.), or
151//! pass a closure — a blanket impl accepts any
152//! `Fn(http::Request<Vec<u8>>) -> Result<http::Response<Vec<u8>>, E>`.
153//! For providers the crate doesn't ship, implement
154//! [`sources::CloudEndpoint`] on a zero-sized type and alias
155//! [`sources::CloudMetadata`].
156//!
157//! # Kubernetes sources
158//!
159//! Feature `k8s` (no new dependencies) exposes
160//! [`sources::KubernetesPodUid`] (from `/proc/self/mountinfo`),
161//! [`sources::KubernetesServiceAccount`] (from the mounted SA secret),
162//! and [`sources::KubernetesDownwardApi`] (any file projected by a
163//! `downwardAPI` volume).
164
165#![warn(missing_docs)]
166#![forbid(unsafe_code)]
167
168mod error;
169mod hostid;
170pub mod ids;
171mod resolver;
172mod source;
173pub mod sources;
174#[cfg(feature = "_transport")]
175pub mod transport;
176mod wrap;
177
178pub use error::Error;
179pub use hostid::{HostId, HostIdSummary, ResolveOutcome};
180#[cfg(feature = "_transport")]
181pub use ids::resolver_from_ids_with_transport;
182pub use ids::{UnknownSourceError, resolver_from_ids};
183pub use resolver::Resolver;
184pub use source::{Probe, Source, SourceKind};
185pub use wrap::{DEFAULT_NAMESPACE, Wrap};
186
187/// Resolve a stable host identity using the default chain for this platform.
188///
189/// Equivalent to `Resolver::with_defaults().resolve()`. This chain is
190/// strictly local — no source makes network calls. Reach for
191/// [`resolve_with_transport`] when you want cloud-metadata endpoints in
192/// the chain, or [`Resolver`] when you need to reorder sources, add
193/// custom ones, or choose a different wrap strategy.
194///
195/// # Errors
196///
197/// Returns [`Error::NoSource`] if every source in the default chain
198/// returned `Ok(None)`, or propagates the first hard failure from a
199/// source (see [`Source::probe`]).
200pub fn resolve() -> Result<HostId, Error> {
201 Resolver::with_defaults().resolve()
202}
203
204/// Resolve a stable host identity using the default chain plus every
205/// cloud-metadata and Kubernetes source enabled at compile time.
206///
207/// Equivalent to `Resolver::with_network_defaults(transport).resolve()`.
208/// Requires a caller-supplied [`transport::HttpTransport`] — the crate
209/// ships no HTTP client. The transport must be `Clone + 'static`; wrap
210/// your client in `Arc` if needed.
211///
212/// Available only when at least one cloud feature is enabled (`aws`,
213/// `gcp`, `azure`, `digitalocean`, `hetzner`, `oci`). See
214/// [`Resolver::with_network_defaults`] for the full chain order.
215///
216/// # Errors
217///
218/// As [`resolve`]: [`Error::NoSource`] if every source returned
219/// `Ok(None)`, or the first hard failure produced by a source in the
220/// chain.
221#[cfg(feature = "_transport")]
222pub fn resolve_with_transport<T>(transport: T) -> Result<HostId, Error>
223where
224 T: transport::HttpTransport + Clone + 'static,
225{
226 Resolver::with_network_defaults(transport).resolve()
227}
228
229/// Walk the default local chain without short-circuiting and return every
230/// source's [`ResolveOutcome`] in order.
231///
232/// Complement to [`resolve`]: same chain, same wrap strategy — but every
233/// source is consulted regardless of whether earlier sources already
234/// succeeded or failed. Useful for auditing which sources on this host
235/// agree (or disagree) and for operator tooling that wants to present
236/// the full picture.
237///
238/// To audit a caller-chosen subset instead of the defaults, build the
239/// resolver directly: `Resolver::new().push(...).push(...).resolve_all()`.
240#[must_use]
241pub fn resolve_all() -> Vec<ResolveOutcome> {
242 Resolver::with_defaults().resolve_all()
243}
244
245/// Walk the network-enabled default chain without short-circuiting and
246/// return every source's [`ResolveOutcome`] in order.
247///
248/// Complement to [`resolve_with_transport`]: same chain, same wrap
249/// strategy — but every source is consulted. Useful for auditing which
250/// cloud or Kubernetes metadata endpoints are reachable from this host,
251/// and what each would yield.
252///
253/// Available only when at least one cloud feature is enabled.
254#[cfg(feature = "_transport")]
255#[must_use]
256pub fn resolve_all_with_transport<T>(transport: T) -> Vec<ResolveOutcome>
257where
258 T: transport::HttpTransport + Clone + 'static,
259{
260 Resolver::with_network_defaults(transport).resolve_all()
261}