diff --git a/Cargo.lock b/Cargo.lock index 5de370ce5..7b42a1745 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34fde25430d87a9388dadbe6e34d7f72a462c8b43ac8d309b42b0a8505d7e2a5" +[[package]] +name = "anyhow" +version = "1.0.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf8dcb5b4bbaa28653b647d8c77bd4ed40183b48882e130c1f1ffb73de069fd7" + [[package]] name = "async-recursion" version = "0.3.1" @@ -52,9 +58,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.41" +version = "0.1.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b246867b8b3b6ae56035f1eb1ed557c1d8eae97f0d53696138a50fa0e3a3b8c0" +checksum = "8d3a45e77e34375a7923b1e8febb049bb011f064714a8e17a1a616fef01da13d" dependencies = [ "proc-macro2", "quote", @@ -82,18 +88,33 @@ checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" name = "aziot" version = "0.1.0" dependencies = [ + "anyhow", + "async-trait", "aziot-certd-config", + "aziot-check-common", "aziot-identityd-config", "aziot-keyd-config", "aziot-keys-common", "aziot-tpmd-config", - "backtrace", "base64", + "byte-unit", "bytes", + "chrono", + "colored", "derive_more", + "erased-serde", + "http-common", + "hyper", + "hyper-openssl", + "mini-sntp", "nix", + "openssl", "rustyline", + "serde", + "serde_json", "structopt", + "sysinfo", + "tokio", "toml", "url", ] @@ -160,12 +181,22 @@ dependencies = [ name = "aziot-certd-config" version = "0.1.0" dependencies = [ + "hex", "http-common", + "openssl", "serde", "toml", "url", ] +[[package]] +name = "aziot-check-common" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "aziot-cloud-client-async-common" version = "0.1.0" @@ -200,10 +231,8 @@ dependencies = [ "async-recursion", "aziot-cert-client-async", "aziot-cloud-client-async-common", - "aziot-key-client", "aziot-key-client-async", "aziot-key-common", - "aziot-key-openssl-engine", "aziot-tpm-client-async", "aziot-tpm-common", "base64", @@ -232,10 +261,8 @@ dependencies = [ "aziot-cert-common-http", "aziot-cloud-client-async-common", "aziot-identity-common", - "aziot-key-client", "aziot-key-client-async", "aziot-key-common", - "aziot-key-openssl-engine", "aziot-tpm-client-async", "aziot-tpm-common", "base64", @@ -272,7 +299,6 @@ dependencies = [ "aziot-cert-common-http", "aziot-identity-common", "aziot-key-common", - "aziot-key-common-http", "http-common", "serde", "serde_json", @@ -611,6 +637,15 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e8c087f005730276d1096a652e92a8bacee2e2472bcc9715a74d2bec38b5820" +[[package]] +name = "byte-unit" +version = "4.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8758c32833faaae35b24a73d332e62d0528e89076ae841c63940e37008b153" +dependencies = [ + "utf8-width", +] + [[package]] name = "bytes" version = "0.5.6" @@ -643,6 +678,7 @@ checksum = "c74d84029116787153e02106bf53e66828452a4b325cc8652b788b5967c0a0b6" dependencies = [ "num-integer", "num-traits", + "serde", "time", ] @@ -663,13 +699,30 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.44" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e56268c17a6248366d66d4a47a3381369d068cce8409bb1716ed77ea32163bb" +checksum = "eb6210b637171dfba4cda12e579ac6dc73f5165ad56133e5d72ef3131f320855" dependencies = [ "cc", ] +[[package]] +name = "colored" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" +dependencies = [ + "atty", + "lazy_static", + "winapi 0.3.9", +] + +[[package]] +name = "const_fn" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c478836e029dcef17fb47c89023448c64f781a046e0300e257ad8225ae59afab" + [[package]] name = "core-foundation" version = "0.7.0" @@ -692,6 +745,53 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8aebca1129a03dc6dc2b127edd729435bbc4a37e1d5f4d7513165089ceb02634" +[[package]] +name = "crossbeam-channel" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca26ee1f8d361640700bde38b2c37d8c22b3ce2d360e1fc1c74ea4b0aa7d775" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94af6efb46fef72616855b036a624cf27ba656ffc9be1b9a3c931cfc7749a9a9" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0f606a85340376eef0d6d8fec399e6d4a544d648386c6645eb6d0653b27d9f" +dependencies = [ + "cfg-if 1.0.0", + "const_fn", + "crossbeam-utils", + "lazy_static", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec91540d98355f690a86367e566ecad2e9e579f230230eb7c21398372be73ea5" +dependencies = [ + "autocfg", + "cfg-if 1.0.0", + "const_fn", + "lazy_static", +] + [[package]] name = "crypto-mac" version = "0.8.0" @@ -743,6 +843,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dtoa" version = "0.4.6" @@ -777,6 +883,15 @@ dependencies = [ "termcolor", ] +[[package]] +name = "erased-serde" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ca8b296792113e1500fd935ae487be6e00ce318952a6880555554824d6ebf38" +dependencies = [ + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -816,24 +931,24 @@ checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" [[package]] name = "futures-channel" -version = "0.3.5" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f366ad74c28cca6ba456d95e6422883cfb4b252a83bed929c83abfdbbf2967d5" +checksum = "4b7109687aa4e177ef6fe84553af6280ef2778bdb7783ba44c9dc3399110fe64" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.5" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59f5fff90fd5d971f936ad674802482ba441b6f09ba5e15fd8b39145582ca399" +checksum = "847ce131b72ffb13b6109a221da9ad97a64cbe48feb1028356b836b47b8f1748" [[package]] name = "futures-macro" -version = "0.3.5" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0b5a30a4328ab5473878237c447333c093297bded83a4983d10f4deea240d39" +checksum = "77408a692f1f97bcc61dc001d752e00643408fbc922e4d634c655df50d595556" dependencies = [ "proc-macro-hack", "proc-macro2", @@ -843,29 +958,29 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.5" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f2032893cb734c7a05d85ce0cc8b8c4075278e93b24b66f9de99d6eb0fa8acc" +checksum = "f878195a49cee50e006b02b93cf7e0a95a38ac7b776b4c4d9cc1207cd20fcb3d" [[package]] name = "futures-task" -version = "0.3.5" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb66b5f09e22019b1ab0830f7785bcea8e7a42148683f99214f73f8ec21a626" +checksum = "7c554eb5bf48b2426c4771ab68c6b14468b6e76cc90996f528c3338d761a4d0d" dependencies = [ "once_cell", ] [[package]] name = "futures-util" -version = "0.3.5" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8764574ff08b701a084482c3c7031349104b07ac897393010494beaa18ce32c6" +checksum = "d304cff4a7b99cfb7986f7d43fbe93d175e72e704a8860787cc95e9ffd85cbd2" dependencies = [ "futures-core", "futures-macro", "futures-task", - "pin-project", + "pin-project 1.0.2", "pin-utils", "proc-macro-hack", "proc-macro-nested", @@ -982,7 +1097,6 @@ dependencies = [ "futures-util", "http", "hyper", - "lazy_static", "libc", "log", "nix", @@ -1021,7 +1135,7 @@ dependencies = [ "httparse", "itoa", "log", - "pin-project", + "pin-project 0.4.22", "socket2", "time", "tokio", @@ -1206,6 +1320,15 @@ version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400" +[[package]] +name = "memoffset" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "043175f069eda7b85febe4a74abbaeff828d9f8b448515d3151a14a3542811aa" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.16" @@ -1222,6 +1345,13 @@ dependencies = [ "unicase", ] +[[package]] +name = "mini-sntp" +version = "0.1.0" +dependencies = [ + "chrono", +] + [[package]] name = "miniz_oxide" version = "0.4.0" @@ -1314,6 +1444,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ntapi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "num-integer" version = "0.1.43" @@ -1443,7 +1582,16 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12e3a6cdbfe94a5e4572812a0201f8c0ed98c1c452c7b8563ce2276988ef9c17" dependencies = [ - "pin-project-internal", + "pin-project-internal 0.4.22", +] + +[[package]] +name = "pin-project" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ccc2237c2c489783abd8c4c80e5450fc0e98644555b1364da68cc29aa151ca7" +dependencies = [ + "pin-project-internal 1.0.2", ] [[package]] @@ -1457,6 +1605,17 @@ dependencies = [ "syn", ] +[[package]] +name = "pin-project-internal" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8e8d2bf0b23038a4424865103a4df472855692821aab4e4f5c3312d461d9e5f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.1.7" @@ -1531,9 +1690,9 @@ dependencies = [ [[package]] name = "proc-macro-hack" -version = "0.5.16" +version = "0.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e0456befd48169b9f13ef0f0ad46d492cf9d2dbb918bcf38e01eed4ce3ec5e4" +checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" [[package]] name = "proc-macro-nested" @@ -1543,9 +1702,9 @@ checksum = "eba180dafb9038b050a4c280019bbedf9f2467b61e5d892dcad585bb57aadc5a" [[package]] name = "proc-macro2" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beae6331a816b1f65d04c45b078fd8e6c93e8071771f41b8163255bbd8d7c8fa" +checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" dependencies = [ "unicode-xid", ] @@ -1600,6 +1759,31 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rayon" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b0d8e0819fadc20c74ea8373106ead0600e3a67ef1fe8da56e39b9ae7275674" +dependencies = [ + "autocfg", + "crossbeam-deque", + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab346ac5921dc62ffa9f89b7a773907511cdfa5490c572ae9be1be33e8afa4a" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-utils", + "lazy_static", + "num_cpus", +] + [[package]] name = "redox_syscall" version = "0.1.56" @@ -1771,9 +1955,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.56" +version = "1.0.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3433e879a558dde8b5e8feb2a04899cf34fdde1fafb894687e52105fc1162ac3" +checksum = "dcac07dbffa1c65e7f816ab9eba78eb142c6d44410f4eeba1e26e4f5dfa56b95" dependencies = [ "itoa", "ryu", @@ -1861,9 +2045,9 @@ checksum = "502d53007c02d7605a05df1c1a73ee436952781653da5d0bf57ad608f66932c1" [[package]] name = "syn" -version = "1.0.33" +version = "1.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8d5d96e8cbb005d6959f119f773bfaebb5684296108fb32600c00cde305b2cd" +checksum = "443b4178719c5a851e1bde36ce12da21d74a0e60b4d982ec3385a933c812f0f6" dependencies = [ "proc-macro2", "quote", @@ -1881,6 +2065,21 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67330cbee3b2a819e3365a773f05e884a136603687f812bf24db5b6c3d76b696" +dependencies = [ + "cfg-if 0.1.10", + "doc-comment", + "libc", + "ntapi", + "once_cell", + "rayon", + "winapi 0.3.9", +] + [[package]] name = "tempfile" version = "3.1.0" @@ -2087,6 +2286,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-width" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9071ac216321a4470a69fb2b28cfc68dcd1a39acd877c8be8e014df6772d8efa" + [[package]] name = "utf8parse" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index faf03c8af..38153d47f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "aziot", + "aziot/aziot-check-common", "aziotd", @@ -31,6 +32,8 @@ members = [ "key/aziot-keys", "key/aziot-keys-common", + "mini-sntp", + "openssl2", "openssl-build", "openssl-sys2", diff --git a/Makefile b/Makefile index 238921827..3d71a4010 100644 --- a/Makefile +++ b/Makefile @@ -215,7 +215,7 @@ dist: # Copy source files cp -R \ - ./aziot ./aziotd ./cert ./http-common ./identity ./iotedged ./key ./openssl-build ./openssl-sys2 ./openssl2 ./pkcs11 ./tpm \ + ./aziot ./aziotd ./cert ./http-common ./identity ./iotedged ./key ./mini-sntp ./openssl-build ./openssl-sys2 ./openssl2 ./pkcs11 ./tpm \ /tmp/aziot-identity-service-$(PACKAGE_VERSION) cp ./Cargo.toml ./Cargo.lock ./CODE_OF_CONDUCT.md ./CONTRIBUTING.md ./LICENSE ./Makefile ./README.md ./rust-toolchain ./SECURITY.md /tmp/aziot-identity-service-$(PACKAGE_VERSION) diff --git a/aziot/Cargo.toml b/aziot/Cargo.toml index 022bd377e..4b06bcd9f 100644 --- a/aziot/Cargo.toml +++ b/aziot/Cargo.toml @@ -6,21 +6,35 @@ edition = "2018" [dependencies] -backtrace = "0.3" +anyhow = "1.0.34" +async-trait = "0.1.42" base64 = "0.12" +byte-unit = "4.0.9" +chrono = { version = "0.4", features = ["serde"] } +colored = "2.0.0" derive_more = "0.99.11" +erased-serde = "0.3.12" +hyper = "0.13" +hyper-openssl = "0.8" nix = "0.18" +openssl = "0.10" rustyline = "6" +serde = { version = "1", features = ["derive"] } +serde_json = "1.0.59" structopt = "0.3" +sysinfo = "0.15.3" +tokio = { version = "0.2", features = ["macros", "fs", "io-util"] } toml = "0.5" url = "2" +aziot-check-common = { path = "./aziot-check-common" } aziot-certd-config = { path = "../cert/aziot-certd-config" } aziot-identityd-config = { path = "../identity/aziot-identityd-config" } aziot-keyd-config = { path = "../key/aziot-keyd-config" } aziot-keys-common = { path = "../key/aziot-keys-common" } aziot-tpmd-config = { path = "../tpm/aziot-tpmd-config" } - +http-common = { path = "../http-common", features = ["tokio02"] } +mini-sntp = { path = "../mini-sntp" } [dev-dependencies] bytes = "0.5" diff --git a/aziot/aziot-check-common/Cargo.toml b/aziot/aziot-check-common/Cargo.toml new file mode 100644 index 000000000..7b3f01555 --- /dev/null +++ b/aziot/aziot-check-common/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "aziot-check-common" +version = "0.1.0" +authors = ["Azure IoT Edge Devs"] +edition = "2018" + + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1.0.59" diff --git a/aziot/aziot-check-common/src/lib.rs b/aziot/aziot-check-common/src/lib.rs new file mode 100644 index 000000000..b764631f5 --- /dev/null +++ b/aziot/aziot-check-common/src/lib.rs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +#![deny(rust_2018_idioms)] +#![warn(clippy::all, clippy::pedantic)] + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct CheckResultsSerializable { + pub additional_info: serde_json::Value, + pub checks: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "result")] +#[serde(rename_all = "snake_case")] +pub enum CheckResultSerializable { + Ok, + Warning { details: Vec }, + Ignored, + Skipped, + Fatal { details: Vec }, + Error { details: Vec }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CheckOutputSerializable { + pub result: CheckResultSerializable, + pub additional_info: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind")] +#[serde(rename_all = "snake_case")] +pub enum CheckOutputSerializableStreaming { + AdditionalInfo(serde_json::Value), + Section { + name: String, + }, + Check { + #[serde(flatten)] + meta: CheckerMetaSerializable, + #[serde(flatten)] + output: CheckOutputSerializable, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CheckerMetaSerializable { + /// Unique human-readable identifier for the check. + pub id: String, + /// A brief description of what this check does. + pub description: String, +} + +/// Keys are section names +pub type CheckListOutput = BTreeMap>; diff --git a/aziot/src/check.rs b/aziot/src/check.rs new file mode 100644 index 000000000..4785a3a73 --- /dev/null +++ b/aziot/src/check.rs @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft. All rights reserved. + +use std::collections::BTreeMap; +use std::io::prelude::*; +use std::str::FromStr; + +use anyhow::Result; +use colored::Colorize; +use structopt::StructOpt; + +use aziot_check_common::{ + CheckOutputSerializable, CheckOutputSerializableStreaming, CheckResultSerializable, + CheckResultsSerializable, +}; + +use crate::internal::check::{ + AdditionalInfo, CheckResult, CheckerCache, CheckerCfg, CheckerShared, +}; + +#[derive(StructOpt)] +#[structopt(about = "Check for common config and deployment issues")] +pub struct CheckOptions { + /// Space-separated list of check IDs. The checks listed here will not be run. + /// See 'aziot check-list' for details of all checks. + #[structopt( + long, + value_name = "DONT_RUN", + value_delimiter = " ", + use_delimiter = true + )] + dont_run: Vec, + + /// Output format. One of "text" or "json". Note that JSON output contains + /// some additional information like OS name, OS version, disk space, etc. + #[structopt(short, long, value_name = "FORMAT", default_value = "text")] + output: OutputFormat, + + /// Increases verbosity of output. + #[structopt(short, long)] + verbose: bool, + + /// Treats warnings as errors. Thus 'aziot check' will exit with non-zero + /// code if it encounters warnings. + #[structopt(long)] + warnings_as_errors: bool, + + #[structopt(flatten)] + checker_cfg: CheckerCfg, +} + +#[derive(Clone, Copy, Debug)] +pub enum OutputFormat { + Text, + Json, + JsonStream, +} + +impl FromStr for OutputFormat { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + Ok(match s { + "text" => OutputFormat::Text, + "json" => OutputFormat::Json, + "json-stream" => OutputFormat::JsonStream, + _ => return Err("invalid output format"), + }) + } +} + +pub async fn check(mut cfg: CheckOptions) -> Result<()> { + // manually pass verbosity down to the checker-specific configuration + cfg.checker_cfg.verbose = cfg.verbose; + let cfg = cfg; // freeze cfg + + let checker_shared = CheckerShared::new(cfg.checker_cfg); + + let mut checks: BTreeMap = Default::default(); + let mut check_data = crate::internal::check::all_checks(); + let mut check_cache = CheckerCache::new(); + + let mut num_successful = 0_usize; + let mut num_warnings = 0_usize; + let mut num_skipped = 0_usize; + let mut num_fatal = 0_usize; + let mut num_errors = 0_usize; + + macro_rules! output { + ($color:ident, $($args:tt)*) => { + if matches!(cfg.output, OutputFormat::Text) { + print!("{}", format!($($args)*).$color()); + } + }; + } + + macro_rules! outputln { + () => { + if matches!(cfg.output, OutputFormat::Text) { + println!(); + } + }; + ($color:ident, $($args:tt)*) => { + if matches!(cfg.output, OutputFormat::Text) { + println!("{}", format!($($args)*).$color()); + } + }; + } + + macro_rules! outputlns { + ($color:ident, $first_line_indent:expr, $other_line_indent:expr, $lines:expr $(,)?) => { + for (i, line) in $lines.enumerate() { + outputln!( + $color, + "{}{}", + if i == 0 { + $first_line_indent + } else { + $other_line_indent + }, + line + ); + } + }; + } + + 'all_checks: for (section_name, section_checks) in &mut check_data { + outputln!(normal, "{}", section_name); + outputln!( + normal, + "{:->section_name_len$}", + "", + section_name_len = section_name.len() + ); + + if matches!(cfg.output, OutputFormat::JsonStream) { + serde_json::to_writer( + std::io::stdout(), + &CheckOutputSerializableStreaming::Section { + name: (*section_name).into(), + }, + )?; + std::io::stdout().flush()?; + } + + for check in section_checks { + let check_result = if cfg.dont_run.iter().any(|s| s == check.meta().id) { + CheckResult::Ignored + } else { + check.execute(&checker_shared, &mut check_cache).await + }; + let additional_info = serde_json::to_value(&check)?; + + let check_name = check.meta().description; + let check_result_serializable = match check_result { + CheckResult::Ok => { + num_successful += 1; + outputln!(green, "\u{221a} {} - OK", check_name); + + CheckResultSerializable::Ok + } + CheckResult::Warning(ref warning) if !cfg.warnings_as_errors => { + num_warnings += 1; + outputln!(yellow, "\u{203c} {} - Warning", check_name); + outputlns!(yellow, " ", " ", warning.to_string().lines()); + if cfg.verbose { + for cause in warning.chain().skip(1) { + outputlns!( + yellow, + " caused by: ", + " ", + cause.to_string().lines(), + ); + } + } + + CheckResultSerializable::Warning { + details: warning.chain().map(ToString::to_string).collect(), + } + } + CheckResult::Ignored => CheckResultSerializable::Ignored, + CheckResult::Skipped => { + num_skipped += 1; + if cfg.verbose { + outputln!(yellow, "\u{203c} {} - Warning", check_name); + outputln!(yellow, " skipping because of previous failures"); + } + + CheckResultSerializable::Skipped + } + CheckResult::Fatal(err) => { + num_fatal += 1; + outputln!(red, "\u{00d7} {} - Error", check_name); + outputlns!(red, " ", " ", err.to_string().lines()); + if cfg.verbose { + for cause in err.chain().skip(1) { + outputlns!( + red, + " caused by: ", + " ", + cause.to_string().lines(), + ); + } + } + + CheckResultSerializable::Fatal { + details: err.chain().map(ToString::to_string).collect(), + } + } + CheckResult::Failed(err) | CheckResult::Warning(err) => { + num_errors += 1; + outputln!(red, "\u{00d7} {} - Error", check_name); + outputlns!(red, " ", " ", err.to_string().lines()); + if cfg.verbose { + for cause in err.chain().skip(1) { + outputlns!( + red, + " caused by: ", + " ", + cause.to_string().lines(), + ); + } + } + + CheckResultSerializable::Error { + details: err.chain().map(ToString::to_string).collect(), + } + } + }; + + let output_serializable = CheckOutputSerializable { + result: check_result_serializable, + additional_info, + }; + + match cfg.output { + OutputFormat::Text => {} + OutputFormat::Json => { + checks.insert(check.meta().id.into(), output_serializable); + } + OutputFormat::JsonStream => { + serde_json::to_writer( + std::io::stdout(), + &CheckOutputSerializableStreaming::Check { + meta: check.meta().into(), + output: output_serializable, + }, + )?; + std::io::stdout().flush()?; + } + } + + if num_fatal > 0 { + break 'all_checks; + } + } + + outputln!(); + } + + outputln!(green, "{} check(s) succeeded.", num_successful); + + if num_warnings > 0 { + output!(yellow, "{} check(s) raised warnings.", num_warnings); + if cfg.verbose { + outputln!(); + } else { + outputln!(yellow, " Re-run with --verbose for more details."); + } + } + + if num_fatal + num_errors > 0 { + output!(red, "{} check(s) raised errors.", num_fatal + num_errors); + if cfg.verbose { + outputln!(); + } else { + outputln!(red, " Re-run with --verbose for more details."); + } + } + + if num_skipped > 0 { + output!( + yellow, + "{} check(s) were skipped due to errors from other checks.", + num_skipped, + ); + if cfg.verbose { + outputln!(); + } else { + outputln!(yellow, " Re-run with --verbose for more details."); + } + } + + let top_level_additional_info = { + let iothub_hostname = check_cache.cfg.identityd.and_then(|s| { + use aziot_identityd_config::ProvisioningType; + match s.provisioning.provisioning { + ProvisioningType::Manual { + iothub_hostname, .. + } => Some(iothub_hostname), + _ => None, + } + }); + + serde_json::to_value(&AdditionalInfo::new(iothub_hostname))? + }; + + match cfg.output { + OutputFormat::JsonStream => { + serde_json::to_writer( + std::io::stdout(), + &CheckOutputSerializableStreaming::AdditionalInfo(top_level_additional_info), + )?; + std::io::stdout().flush()?; + } + OutputFormat::Json => { + let check_results = CheckResultsSerializable { + additional_info: top_level_additional_info, + checks, + }; + + if let Err(err) = serde_json::to_writer(std::io::stdout(), &check_results) { + eprintln!("Could not write JSON output: {}", err,); + } + } + OutputFormat::Text => {} + } + + println!(); + + Ok(()) +} diff --git a/aziot/src/check_list.rs b/aziot/src/check_list.rs new file mode 100644 index 000000000..1cf49fa55 --- /dev/null +++ b/aziot/src/check_list.rs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::{Context, Result}; +use structopt::StructOpt; + +use aziot_check_common::CheckListOutput; + +#[derive(StructOpt, Copy, Clone)] +#[structopt(about = "List the checks that are run for 'aziot check'")] +pub struct CheckListOptions { + /// Output available checks as a JSON object. + #[structopt(short, long)] + json: bool, +} + +pub fn check_list(cfg: CheckListOptions) -> Result<()> { + let checks = crate::internal::check::all_checks(); + + if cfg.json { + let mut output = CheckListOutput::new(); + for (section_name, section_checks) in checks { + output.insert( + section_name.to_string(), + section_checks + .into_iter() + .map(|c| c.meta().into()) + .collect(), + ); + } + + serde_json::to_writer(std::io::stdout(), &output).context("could not output to stdout")?; + } else { + // All our text is ASCII, so we can measure text width in bytes rather than + // using unicode-segmentation to count graphemes. + let widest_section_name_len = checks + .iter() + .map(|(section_name, _)| section_name.len()) + .max() + .expect("Have at least one section"); + + let section_name_column_width = widest_section_name_len + 1; + let widest_check_id_len = checks + .iter() + .flat_map(|(_, section_checks)| section_checks) + .map(|check| check.meta().id.len()) + .max() + .expect("Have at least one check"); + let check_id_column_width = widest_check_id_len + 1; + + println!( + "{:section_name_column_width$}{:check_id_column_width$}DESCRIPTION", + "CATEGORY", + "ID", + section_name_column_width = section_name_column_width, + check_id_column_width = check_id_column_width, + ); + println!(); + + for (section_name, section_checks) in &checks { + for check in section_checks { + println!( + "{:section_name_column_width$}{:check_id_column_width$}{}", + section_name, + check.meta().id, + check.meta().description, + section_name_column_width = section_name_column_width, + check_id_column_width = check_id_column_width, + ); + } + + println!(); + } + } + + Ok(()) +} diff --git a/aziot/src/init.rs b/aziot/src/init.rs index 01b175e09..5098db571 100644 --- a/aziot/src/init.rs +++ b/aziot/src/init.rs @@ -14,6 +14,8 @@ //! - This implementation assumes that Microsoft's implementation of libaziot-keys is being used, in that it generates the keyd config //! with the `aziot_keys.homedir_path` property set, and with validation that the preloaded keys must be `file://` or `pkcs11:` URIs. +use anyhow::{anyhow, Context, Result}; + const DPS_GLOBAL_ENDPOINT: &str = "https://global.azure-devices-provisioning.net"; const AZIOT_KEYD_HOMEDIR_PATH: &str = "/var/lib/aziot/keyd"; @@ -35,7 +37,7 @@ const EST_BOOTSTRAP_ID: &str = "est-bootstrap-id"; /// The ID used for the CA cert that is used to validate the EST server's server cert. const EST_SERVER_CA_ID: &str = "est-server-ca"; -pub(crate) fn run() -> Result<(), crate::Error> { +pub(crate) fn run() -> Result<()> { // In production, running as root is the easiest way to guarantee the tool has write access to every service's config file. // But it's convenient to not do this for the sake of development because the the development machine doesn't necessarily // have the package installed and the users created, and it's easier to have the config files owned by the current user anyway. @@ -46,26 +48,26 @@ pub(crate) fn run() -> Result<(), crate::Error> { let (aziotks_user, aziotcs_user, aziotid_user, aziottpm_user) = if nix::unistd::Uid::current().is_root() { let aziotks_user = nix::unistd::User::from_name("aziotks") - .map_err(|err| format!("could not query aziotks user information: {}", err))? - .ok_or("could not query aziotks user information")?; + .context("could not query aziotks user information")? + .ok_or_else(|| anyhow!("could not query aziotks user information"))?; let aziotcs_user = nix::unistd::User::from_name("aziotcs") - .map_err(|err| format!("could not query aziotcs user information: {}", err))? - .ok_or("could not query aziotcs user information")?; + .context("could not query aziotcs user information")? + .ok_or_else(|| anyhow!("could not query aziotcs user information"))?; let aziotid_user = nix::unistd::User::from_name("aziotid") - .map_err(|err| format!("could not query aziotid user information: {}", err))? - .ok_or("could not query aziotid user information")?; + .context("could not query aziotid user information")? + .ok_or_else(|| anyhow!("could not query aziotid user information"))?; let aziottpm_user = nix::unistd::User::from_name("aziottpm") - .map_err(|err| format!("could not query aziottpm user information: {}", err))? - .ok_or("could not query aziottpm user information")?; + .context("could not query aziottpm user information")? + .ok_or_else(|| anyhow!("could not query aziottpm user information"))?; (aziotks_user, aziotcs_user, aziotid_user, aziottpm_user) } else if cfg!(debug_assertions) { let current_user = nix::unistd::User::from_uid(nix::unistd::Uid::current()) - .map_err(|err| format!("could not query current user information: {}", err))? - .ok_or("could not query current user information")?; + .context("could not query current user information")? + .ok_or_else(|| anyhow!("could not query current user information"))?; ( current_user.clone(), current_user.clone(), @@ -73,7 +75,7 @@ pub(crate) fn run() -> Result<(), crate::Error> { current_user, ) } else { - return Err("this command must be run as root".into()); + return Err(anyhow!("this command must be run as root")); }; for &f in &[ @@ -87,14 +89,13 @@ pub(crate) fn run() -> Result<(), crate::Error> { // It would be less racy to test this right before we're about to overwrite the files, but by then we'll have asked the user // all of the questions and it would be a waste to give up. if std::path::Path::new(f).exists() { - return Err(format!( - "\ - Cannot run because file {} already exists. \ - Delete this file (after taking a backup if necessary) before running this command.\ - ", + return Err(anyhow!( + "\ + Cannot run because file {} already exists. \ + Delete this file (after taking a backup if necessary) before running this command.\ + ", f - ) - .into()); + )); } } @@ -185,13 +186,13 @@ struct RunOutput { } /// Returns the KS/CS/IS configs, and optionally the contents of a new /var/secrets/aziot/keyd/device-id file to hold the device ID symmetric key. -fn run_inner(stdin: &mut impl Reader) -> Result { +fn run_inner(stdin: &mut impl Reader) -> Result { println!("Welcome to the configuration tool for aziot-identity-service."); println!(); println!("This command will set up the configurations for aziot-keyd, aziot-certd and aziot-identityd."); println!(); - let hostname = get_hostname()?; + let hostname = crate::internal::common::get_hostname()?; let (provisioning_type, preloaded_device_id_pk_bytes) = choose! { stdin, @@ -360,8 +361,13 @@ fn run_inner(stdin: &mut impl Reader) -> Result { .insert(DEVICE_ID_ID.to_owned(), device_id_pk_uri.to_string()); } else if matches!( provisioning_type, - aziot_identityd_config::ProvisioningType::Manual { authentication: aziot_identityd_config::ManualAuthMethod::X509 { .. }, .. } | - aziot_identityd_config::ProvisioningType::Dps { attestation: aziot_identityd_config::DpsAttestationMethod::X509 { .. }, .. } + aziot_identityd_config::ProvisioningType::Manual { + authentication: aziot_identityd_config::ManualAuthMethod::X509 { .. }, + .. + } | aziot_identityd_config::ProvisioningType::Dps { + attestation: aziot_identityd_config::DpsAttestationMethod::X509 { .. }, + .. + } ) { device_id_source = Some(choose! { stdin, @@ -634,10 +640,10 @@ fn run_inner(stdin: &mut impl Reader) -> Result { endpoints: Default::default(), }; - let keyd_config = toml::to_vec(&keyd_config) - .map_err(|err| format!("could not serialize aziot-keyd config: {}", err))?; - let certd_config = toml::to_vec(&certd_config) - .map_err(|err| format!("could not serialize aziot-certd config: {}", err))?; + let keyd_config = + toml::to_vec(&keyd_config).context("could not serialize aziot-keyd config")?; + let certd_config = + toml::to_vec(&certd_config).context("could not serialize aziot-certd config")?; let identityd_config = { aziot_identityd_config::Settings { @@ -652,11 +658,11 @@ fn run_inner(stdin: &mut impl Reader) -> Result { localid: None, } }; - let identityd_config = toml::to_vec(&identityd_config) - .map_err(|err| format!("could not serialize aziot-identityd config: {}", err))?; + let identityd_config = + toml::to_vec(&identityd_config).context("could not serialize aziot-identityd config")?; - let tpmd_config = toml::to_vec(&tpmd_config) - .map_err(|err| format!("could not serialize aziot-certd config: {}", err))?; + let tpmd_config = + toml::to_vec(&tpmd_config).context("could not serialize aziot-certd config")?; Ok(RunOutput { keyd_config, @@ -864,7 +870,7 @@ fn choose<'a, TChoice>( stdin: &mut impl Reader, question: &str, choices: &'a [TChoice], -) -> Result<&'a TChoice, crate::Error> +) -> Result<&'a TChoice> where TChoice: std::fmt::Display, { @@ -905,7 +911,7 @@ where } } -fn prompt(stdin: &mut impl Reader, question: &str) -> Result { +fn prompt(stdin: &mut impl Reader, question: &str) -> Result { println!("{}", question); let mut line = String::new(); @@ -922,7 +928,7 @@ fn prompt(stdin: &mut impl Reader, question: &str) -> Result Result { +fn prompt_secret(stdin: &mut impl Reader, question: &str) -> Result { println!("{}", question); let mut line = String::new(); @@ -1034,22 +1040,8 @@ fn parse_cert_location(value: &str) -> Option { } } -fn get_hostname() -> Result { - if cfg!(test) { - Ok("my-device".to_owned()) - } else { - let mut hostname = vec![0_u8; 256]; - let hostname = nix::unistd::gethostname(&mut hostname) - .map_err(|err| format!("could not get machine hostname: {}", err))?; - let hostname = hostname - .to_str() - .map_err(|err| format!("could not get machine hostname: {}", err))?; - Ok(hostname.to_owned()) - } -} - /// Prompts the user to enter an EST server URL, and fixes it to have a "/.well-known/est" component if it doesn't already. -fn read_est_url(stdin: &mut impl Reader, prompt_question: &str) -> Result { +fn read_est_url(stdin: &mut impl Reader, prompt_question: &str) -> Result { loop { let url = prompt(stdin, prompt_question)?; let url = if url.contains("/.well-known/est") { @@ -1070,23 +1062,19 @@ fn create_dir_all( path: &(impl AsRef + ?Sized), user: &nix::unistd::User, mode: u32, -) -> Result<(), crate::Error> { +) -> Result<()> { let path = path.as_ref(); let path_displayable = path.display(); let () = std::fs::create_dir_all(path) - .map_err(|err| format!("could not create {} directory: {}", path_displayable, err))?; - let () = nix::unistd::chown(path, Some(user.uid), Some(user.gid)).map_err(|err| { - format!( - "could not set ownership on {} directory: {}", - path_displayable, err - ) - })?; + .with_context(|| format!("could not create {} directory", path_displayable))?; + let () = nix::unistd::chown(path, Some(user.uid), Some(user.gid)) + .with_context(|| format!("could not set ownership on {} directory", path_displayable))?; let () = std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(mode)) - .map_err(|err| { + .with_context(|| { format!( - "could not set permissions on {} directory: {}", - path_displayable, err + "could not set permissions on {} directory", + path_displayable ) })?; Ok(()) @@ -1097,16 +1085,16 @@ fn write_file( content: &[u8], user: &nix::unistd::User, mode: u32, -) -> Result<(), crate::Error> { +) -> Result<()> { let path = path.as_ref(); let path_displayable = path.display(); let () = std::fs::write(path, content) - .map_err(|err| format!("could not create {}: {}", path_displayable, err))?; + .with_context(|| format!("could not create {}", path_displayable))?; let () = nix::unistd::chown(path, Some(user.uid), Some(user.gid)) - .map_err(|err| format!("could not set ownership on {}: {}", path_displayable, err))?; + .with_context(|| format!("could not set ownership on {}", path_displayable))?; let () = std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(mode)) - .map_err(|err| format!("could not set permissions on {}: {}", path_displayable, err))?; + .with_context(|| format!("could not set permissions on {}", path_displayable))?; Ok(()) } diff --git a/aziot/src/internal/check/additional_info.rs b/aziot/src/internal/check/additional_info.rs new file mode 100644 index 000000000..e8fb6dab9 --- /dev/null +++ b/aziot/src/internal/check/additional_info.rs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +use std::env::consts::ARCH; +use std::str; + +use byte_unit::{Byte, ByteUnit}; +use serde::Serialize; +use sysinfo::{DiskExt, SystemExt}; + +/// Additional info for the JSON output of `aziot check` +#[derive(Clone, Debug, Serialize)] +pub struct AdditionalInfo { + // TODO: update https://github.com/Azure/azure-iotedge to include aziot version + now: chrono::DateTime, + os: OsInfo, + system_info: SystemInfo, + + #[serde(skip_serializing_if = "Option::is_none")] + iothub_hostname: Option, +} + +impl AdditionalInfo { + pub fn new(iothub_hostname: Option) -> Self { + AdditionalInfo { + now: chrono::Utc::now(), + os: OsInfo::new(), + system_info: SystemInfo::new(), + + iothub_hostname, + } + } +} + +/// A subset of the fields from /etc/os-release. +/// +/// Examples: +/// +/// ```ignore +/// OS | id | version_id +/// ---------------------+---------------------+------------ +/// CentOS 7 | centos | 7 +/// Debian 9 | debian | 9 +/// openSUSE Tumbleweed | opensuse-tumbleweed | 20190325 +/// Ubuntu 18.04 | ubuntu | 18.04 +/// ``` +/// +/// Ref: +#[derive(Clone, Debug, Serialize)] +pub struct OsInfo { + id: Option, + version_id: Option, + arch: &'static str, + bitness: usize, +} + +impl OsInfo { + pub fn new() -> Self { + use std::fs::File; + use std::io::{BufRead, BufReader}; + + let mut result = OsInfo { + id: None, + version_id: None, + arch: ARCH, + // Technically wrong if someone runs an arm32 build on arm64, + // but we have dedicated arm64 builds so hopefully they don't. + bitness: std::mem::size_of::() * 8, + }; + + if let Ok(os_release) = File::open("/etc/os-release") { + let mut os_release = BufReader::new(os_release); + + let mut line = String::new(); + loop { + match os_release.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => { + if let Some((key, value)) = parse_os_release_line(&line) { + if key == "ID" { + result.id = Some(value.to_owned()); + } else if key == "VERSION_ID" { + result.version_id = Some(value.to_owned()); + } + } + + line.clear(); + } + } + } + } + + result + } +} + +fn parse_os_release_line(line: &str) -> Option<(&str, &str)> { + let line = line.trim(); + + let mut parts = line.split('='); + + let key = parts + .next() + .expect("split line will have at least one part"); + + let value = parts.next()?; + + // The value is essentially a shell string, so it can be quoted in single or + // double quotes, and can have escaped sequences using backslash. + // For simplicitly, just trim the quotes instead of implementing a full shell + // string grammar. + let value = if (value.starts_with('\'') && value.ends_with('\'')) + || (value.starts_with('"') && value.ends_with('"')) + { + &value[1..(value.len() - 1)] + } else { + value + }; + + Some((key, value)) +} + +#[derive(Clone, Debug, Default, Serialize)] +struct SystemInfo { + used_ram: String, + total_ram: String, + used_swap: String, + total_swap: String, + + disks: Vec, +} + +impl SystemInfo { + fn new() -> Self { + let mut system = sysinfo::System::new(); + system.refresh_all(); + SystemInfo { + total_ram: pretty_kbyte(system.get_total_memory()), + used_ram: pretty_kbyte(system.get_used_memory()), + total_swap: pretty_kbyte(system.get_total_swap()), + used_swap: pretty_kbyte(system.get_used_swap()), + + disks: system.get_disks().iter().map(DiskInfo::new).collect(), + } + } +} + +#[derive(Clone, Debug, Default, Serialize)] +struct DiskInfo { + name: String, + percent_free: String, + available_space: String, + total_space: String, + file_system: String, + file_type: String, +} + +impl DiskInfo { + fn new(disk: &T) -> Self + where + T: DiskExt, + { + let available_space = disk.get_available_space(); + let total_space = disk.get_total_space(); + #[allow(clippy::cast_precision_loss)] + let percent_free = format!( + "{:.1}%", + available_space as f64 / total_space as f64 * 100.0 + ); + + DiskInfo { + name: disk.get_name().to_string_lossy().into_owned(), + percent_free, + available_space: Byte::from_bytes(u128::from(available_space)) + .get_appropriate_unit(true) + .format(2), + total_space: Byte::from_bytes(u128::from(total_space)) + .get_appropriate_unit(true) + .format(2), + file_system: String::from_utf8_lossy(disk.get_file_system()).into_owned(), + file_type: format!("{:?}", disk.get_type()), + } + } +} + +fn pretty_kbyte(bytes: u64) -> String { + #[allow(clippy::cast_precision_loss)] + match Byte::from_unit(bytes as f64, ByteUnit::KiB) { + Ok(b) => b.get_appropriate_unit(true).format(2), + Err(err) => format!("could not parse bytes value: {:?}", err), + } +} diff --git a/aziot/src/internal/check/checks/cert_expiry.rs b/aziot/src/internal/check/checks/cert_expiry.rs new file mode 100644 index 000000000..e6addf2bc --- /dev/null +++ b/aziot/src/internal/check/checks/cert_expiry.rs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::{anyhow, Result}; +use serde::Serialize; + +use crate::internal::check::util::CertificateValidityExt; +use crate::internal::check::{CheckResult, Checker, CheckerCache, CheckerMeta, CheckerShared}; +use crate::internal::common::CertificateValidity; + +pub fn cert_expirations() -> impl Iterator> { + let mut v: Vec> = Vec::new(); + + v.push(Box::new(IdentityCert::default())); + v.push(Box::new(EstIdentityBootstrapCerts::default())); + v.push(Box::new(LocalCaCert::default())); + + v.into_iter() +} + +#[derive(Serialize, Default)] +struct IdentityCert { + provisioning_mode: Option<&'static str>, + certificate_info: Option, +} + +#[async_trait::async_trait] +impl Checker for IdentityCert { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "identity-certificate-expiry", + description: "production readiness: identity certificates expiry", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + self.inner_execute(shared, cache) + .await + .unwrap_or_else(CheckResult::Failed) + } +} + +impl IdentityCert { + async fn inner_execute( + &mut self, + _shared: &CheckerShared, + cache: &mut CheckerCache, + ) -> Result { + use aziot_identityd_config::{DpsAttestationMethod, ManualAuthMethod, ProvisioningType}; + + let provisioning = &unwrap_or_skip!(&cache.cfg.identityd) + .provisioning + .provisioning + .clone(); + + let mut cert = None; + match provisioning { + ProvisioningType::Dps { + attestation: DpsAttestationMethod::X509 { identity_cert, .. }, + .. + } => { + self.provisioning_mode = Some("dps-x509"); + cert = Some((identity_cert, "DPS identity")); + } + ProvisioningType::Manual { + authentication: ManualAuthMethod::X509 { identity_cert, .. }, + .. + } => { + self.provisioning_mode = Some("manual-x509"); + cert = Some((identity_cert, "Manual identity")); + } + ProvisioningType::Dps { .. } => self.provisioning_mode = Some("dps-other"), + ProvisioningType::Manual { .. } => self.provisioning_mode = Some("manual-other"), + ProvisioningType::None => self.provisioning_mode = Some("none"), + }; + + if let Some((identity_cert, identity_cert_name)) = cert { + let certd_config = unwrap_or_skip!(&cache.cfg.certd); + + let (res, cert_info) = + validate_cert(certd_config, identity_cert, identity_cert_name).await?; + self.certificate_info = cert_info; + Ok(res) + } else { + Ok(CheckResult::Ignored) + } + } +} + +#[derive(Serialize, Default)] +struct EstIdentityBootstrapCerts { + identity_certificate_info: Option, + bootstrap_certificate_info: Option, +} + +#[async_trait::async_trait] +impl Checker for EstIdentityBootstrapCerts { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "est-identity-and-bootstrap-certificate-expiry", + description: "production readiness: EST identity and bootstrap certificates expiry", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + self.inner_execute(shared, cache) + .await + .unwrap_or_else(CheckResult::Failed) + } +} + +impl EstIdentityBootstrapCerts { + async fn inner_execute( + &mut self, + _shared: &CheckerShared, + cache: &mut CheckerCache, + ) -> Result { + let certd_config = unwrap_or_skip!(&cache.cfg.certd); + + let certs = certd_config + .cert_issuance + .est + .as_ref() + .and_then(|est| est.auth.x509.as_ref()) + .map(|x509| { + ( + (&x509.identity.0, "x509 identity"), + x509.bootstrap_identity + .as_ref() + .map(|(cert, _)| (cert, "x509 bootstrap")), + ) + }); + + match certs { + Some((identity, bootstrap)) => { + let (identity_cert_id, identity_cert_name) = identity; + + let (identity_cert_res, identity_certificate_info) = + validate_cert(certd_config, identity_cert_id, identity_cert_name).await?; + self.identity_certificate_info = identity_certificate_info; + + // TODO: clean this up if a checks ever get the ability to return multiple results + if !matches!(identity_cert_res, CheckResult::Ok) { + return Ok(identity_cert_res); + } + + if let Some((bootstrap_cert_id, bootstrap_cert_name)) = bootstrap { + let (bootstrap_cert_res, bootstrap_certificate_info) = + validate_cert(certd_config, bootstrap_cert_id, bootstrap_cert_name).await?; + self.bootstrap_certificate_info = bootstrap_certificate_info; + + if !matches!(bootstrap_cert_res, CheckResult::Ok) { + return Ok(bootstrap_cert_res); + } + } + + Ok(CheckResult::Ok) + } + None => Ok(CheckResult::Ignored), + } + } +} + +#[derive(Serialize, Default)] +struct LocalCaCert { + certificate_info: Option, +} + +#[async_trait::async_trait] +impl Checker for LocalCaCert { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "local-ca-certificate-expiry", + description: "production readiness: Local CA certificates expiry", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + self.inner_execute(shared, cache) + .await + .unwrap_or_else(CheckResult::Failed) + } +} + +impl LocalCaCert { + async fn inner_execute( + &mut self, + _shared: &CheckerShared, + cache: &mut CheckerCache, + ) -> Result { + let certd_config = unwrap_or_skip!(&cache.cfg.certd); + + let cert_id = certd_config + .cert_issuance + .local_ca + .as_ref() + .map(|local_ca| &local_ca.cert); + let cert_id = match cert_id { + Some(id) => id, + None => return Ok(CheckResult::Ignored), + }; + + let (res, cert_info) = validate_cert(certd_config, cert_id, "Local CA").await?; + self.certificate_info = cert_info; + Ok(res) + } +} + +/// Validate the certificate is valid, returning `CheckResult::Ok` if the +/// certificate is configured to be dynamically issued, but hasn't been issued +/// yet (i.e: doesn't correspond to a file on-disk yet). +/// +/// `cert_name` is only used for friendly error messages. +async fn validate_cert( + certd_config: &aziot_certd_config::Config, + cert_id: &str, + cert_name: &str, +) -> anyhow::Result<(CheckResult, Option)> { + let path = aziot_certd_config::util::get_path( + &certd_config.homedir_path, + &certd_config.preloaded_certs, + cert_id, + false, + ) + .map_err(|e| anyhow!("{}", e))?; + + if path.exists() { + let cert_info = CertificateValidity::new(path, cert_name, cert_id).await?; + cert_info + .to_check_result() + .map(|res| (res, Some(cert_info))) + } else if !certd_config.preloaded_certs.contains_key(cert_id) + && !certd_config.cert_issuance.certs.contains_key(cert_id) + { + Err(anyhow!( + "{} certificate is neither preloaded nor configured to be dynamically issued, and thus cannot be used.", + cert_name + )) + } else { + Ok((CheckResult::Ok, None)) + } +} diff --git a/aziot/src/internal/check/checks/certs_preloaded.rs b/aziot/src/internal/check/checks/certs_preloaded.rs new file mode 100644 index 000000000..0e12280f9 --- /dev/null +++ b/aziot/src/internal/check/checks/certs_preloaded.rs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::{anyhow, Result}; +use serde::Serialize; + +use crate::internal::check::util::CertificateValidityExt; +use crate::internal::check::{CheckResult, Checker, CheckerCache, CheckerMeta, CheckerShared}; +use crate::internal::common::CertificateValidity; + +use aziot_certd_config::PreloadedCert; + +#[derive(Serialize, Default)] +pub struct CertsPreloaded {} + +#[async_trait::async_trait] +impl Checker for CertsPreloaded { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "certs-preloaded", + description: "preloaded certificates are valid", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + self.inner_execute(shared, cache) + .await + .unwrap_or_else(CheckResult::Failed) + } +} + +impl CertsPreloaded { + async fn inner_execute( + &mut self, + _shared: &CheckerShared, + cache: &mut CheckerCache, + ) -> Result { + let preloaded_certs = &unwrap_or_skip!(&cache.cfg.certd).preloaded_certs; + + // TODO?: support returning multiple check results from a single check + // this will require some non-trivial changes to the checker framework, as currently + // there isn't any way to return a _dynamic_ number of results from a single check. + for (id, cert) in preloaded_certs { + match cert { + PreloadedCert::Ids(ids) => { + // validate that the ids correspond to other preloaded certs + for inner_id in ids { + if preloaded_certs.get(inner_id).is_none() { + return Err(anyhow!( + "id '{}' in '{}' does not point to a valid cert", + inner_id, + id + )); + }; + } + } + PreloadedCert::Uri(uri) => { + if uri.scheme() != "file" { + return Err(anyhow!( + "only file:// schemes are supported for preloaded certs." + )); + } + + match CertificateValidity::new(uri.path(), "", &id) + .await? + .to_check_result()? + { + CheckResult::Ok => {} + res => return Ok(res), + } + } + }; + } + + Ok(CheckResult::Ok) + } +} diff --git a/aziot/src/internal/check/checks/daemons_running.rs b/aziot/src/internal/check/checks/daemons_running.rs new file mode 100644 index 000000000..06386b964 --- /dev/null +++ b/aziot/src/internal/check/checks/daemons_running.rs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::{anyhow, Context}; +use serde::Serialize; + +use crate::internal::check::{CheckResult, Checker, CheckerCache, CheckerMeta, CheckerShared}; + +pub fn daemons_running() -> impl Iterator> { + let mut v: Vec> = Vec::new(); + + v.push(Box::new(DaemonRunningKeyd {})); + v.push(Box::new(DaemonRunningCertd {})); + v.push(Box::new(DaemonRunningTpmd {})); + v.push(Box::new(DaemonRunningIdentityd {})); + + v.into_iter() +} + +#[derive(Serialize)] +struct DaemonRunningKeyd {} + +#[async_trait::async_trait] +impl Checker for DaemonRunningKeyd { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "keyd-running", + description: "keyd is running", + } + } + + async fn execute(&mut self, _shared: &CheckerShared, _cache: &mut CheckerCache) -> CheckResult { + use hyper::service::Service; + + let mut connector = aziot_identityd_config::Endpoints::default().aziot_keyd; + let res = connector + .call("foo".parse().unwrap()) + .await + .with_context(|| anyhow!("Could not connect to keyd on {}", connector)); + + match res { + Ok(_) => CheckResult::Ok, + Err(e) => CheckResult::Failed(e), + } + } +} + +#[derive(Serialize)] +struct DaemonRunningCertd {} + +#[async_trait::async_trait] +impl Checker for DaemonRunningCertd { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "certd-running", + description: "certd is running", + } + } + + async fn execute(&mut self, _shared: &CheckerShared, _cache: &mut CheckerCache) -> CheckResult { + use hyper::service::Service; + + let mut connector = aziot_identityd_config::Endpoints::default().aziot_certd; + let res = connector + .call("foo".parse().unwrap()) + .await + .with_context(|| anyhow!("Could not connect to certd on {}", connector)); + + match res { + Ok(_) => CheckResult::Ok, + Err(e) => CheckResult::Failed(e), + } + } +} + +#[derive(Serialize)] +struct DaemonRunningTpmd {} + +#[async_trait::async_trait] +impl Checker for DaemonRunningTpmd { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "tpmd-running", + description: "tpmd is running", + } + } + + async fn execute(&mut self, _shared: &CheckerShared, _cache: &mut CheckerCache) -> CheckResult { + use hyper::service::Service; + + let mut connector = aziot_identityd_config::Endpoints::default().aziot_tpmd; + let res = connector + .call("foo".parse().unwrap()) + .await + .with_context(|| anyhow!("Could not connect to tpmd on {}", connector)); + + match res { + Ok(_) => CheckResult::Ok, + Err(e) => CheckResult::Failed(e), + } + } +} + +#[derive(Serialize)] +struct DaemonRunningIdentityd {} + +#[async_trait::async_trait] +impl Checker for DaemonRunningIdentityd { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "identityd-running", + description: "identityd is running", + } + } + + async fn execute(&mut self, _shared: &CheckerShared, _cache: &mut CheckerCache) -> CheckResult { + use hyper::service::Service; + + let mut connector = aziot_identityd_config::Endpoints::default().aziot_identityd; + let res = connector + .call("foo".parse().unwrap()) + .await + .with_context(|| anyhow!("Could not connect to identityd on {}", connector)); + + match res { + Ok(_) => CheckResult::Ok, + Err(e) => CheckResult::Failed(e), + } + } +} diff --git a/aziot/src/internal/check/checks/host_connect_dps_endpoint.rs b/aziot/src/internal/check/checks/host_connect_dps_endpoint.rs new file mode 100644 index 000000000..455309ad3 --- /dev/null +++ b/aziot/src/internal/check/checks/host_connect_dps_endpoint.rs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::{anyhow, Context, Result}; +use serde::Serialize; + +use crate::internal::check::{CheckResult, Checker, CheckerCache, CheckerMeta, CheckerShared}; + +#[derive(Serialize, Default)] +pub struct HostConnectDpsEndpoint { + dps_endpoint: Option, + dps_hostname: Option, + proxy: Option, +} + +#[async_trait::async_trait] +impl Checker for HostConnectDpsEndpoint { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "host-connect-dps-endpoint", + description: "host can connect to and perform TLS handshake with DPS endpoint", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + self.inner_execute(shared, cache) + .await + .unwrap_or_else(CheckResult::Failed) + } +} + +impl HostConnectDpsEndpoint { + async fn inner_execute( + &mut self, + _shared: &CheckerShared, + cache: &mut CheckerCache, + ) -> Result { + use aziot_identityd_config::ProvisioningType; + + let dps_endpoint = match &unwrap_or_skip!(&cache.cfg.identityd) + .provisioning + .provisioning + { + ProvisioningType::Dps { + global_endpoint, .. + } => global_endpoint, + _ => return Ok(CheckResult::Ignored), + }; + + self.dps_endpoint = Some(dps_endpoint.clone()); + + let dps_endpoint = dps_endpoint + .parse::() + .context("Invalid URL specified in provisioning.global_endpoint")?; + + let dps_hostname = dps_endpoint + .host() + .ok_or_else(|| { + anyhow!("URL specified in provisioning.global_endpoint does not have a host") + })? + .to_owned(); + self.dps_hostname = Some(dps_hostname.clone()); + + // TODO: add proxy support once is supported in identityd + crate::internal::common::resolve_and_tls_handshake(dps_endpoint, &dps_hostname).await?; + + Ok(CheckResult::Ok) + } +} diff --git a/aziot/src/internal/check/checks/host_connect_iothub.rs b/aziot/src/internal/check/checks/host_connect_iothub.rs new file mode 100644 index 000000000..42fab9b79 --- /dev/null +++ b/aziot/src/internal/check/checks/host_connect_iothub.rs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::{Context, Result}; +use serde::Serialize; + +use crate::internal::check::{CheckResult, Checker, CheckerCache, CheckerMeta, CheckerShared}; + +pub fn host_connect_iothub_checks() -> impl Iterator> { + let mut v: Vec> = Vec::new(); + + v.push(Box::new(HostConnectIotHub::new( + "host-connect-iothub-amqp", + "host can connect to and perform TLS handshake with iothub AMQP port", + 5671, + ))); + v.push(Box::new(HostConnectIotHub::new( + "host-connect-iothub-https", + "host can connect to and perform TLS handshake with iothub HTTPS / WebSockets port", + 443, + ))); + v.push(Box::new(HostConnectIotHub::new( + "host-connect-iothub-mqtt", + "host can connect to and perform TLS handshake with iothub MQTT port", + 8883, + ))); + + v.into_iter() +} + +#[derive(Serialize)] +pub struct HostConnectIotHub { + port_number: u16, + iothub_hostname: Option, + proxy: Option, + + #[serde(skip)] + meta: CheckerMeta, +} + +impl HostConnectIotHub { + fn new(id: &'static str, description: &'static str, port_number: u16) -> HostConnectIotHub { + HostConnectIotHub { + port_number, + iothub_hostname: None, + proxy: None, + meta: CheckerMeta { id, description }, + } + } +} + +#[async_trait::async_trait] +impl Checker for HostConnectIotHub { + fn meta(&self) -> CheckerMeta { + self.meta + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + self.inner_execute(shared, cache) + .await + .unwrap_or_else(CheckResult::Failed) + } +} + +impl HostConnectIotHub { + async fn inner_execute( + &mut self, + _shared: &CheckerShared, + cache: &mut CheckerCache, + ) -> Result { + use aziot_identityd_config::ProvisioningType; + + let iothub_hostname = match &self.iothub_hostname { + Some(s) => s, + None => { + let iothub_hostname = match &unwrap_or_skip!(&cache.cfg.identityd) + .provisioning + .provisioning + { + ProvisioningType::Manual { + iothub_hostname, .. + } => iothub_hostname, + ProvisioningType::Dps { .. } => { + // It's fine if the prev config doesn't exist, so `unwrap_or_skip` isn't + // appropriate here + let backup_hostname = match &cache.cfg.identityd_prev { + None => None, + // check if the backup config includes the iothub_hostname + Some(cfg) => match &cfg.provisioning.provisioning { + ProvisioningType::Manual { + iothub_hostname, .. + } => Some(iothub_hostname), + _ => None, + }, + }; + + if let Some(backup_hostname) = backup_hostname { + backup_hostname + } else { + // the user never manually provisioned, nor have they passed + // the `iothub-hostname` flag. + let reason = "Could not retrieve iothub_hostname from provisioning file.\n\ + Please specify the backing IoT Hub name using --iothub-hostname switch if you have that information.\n\ + Since no hostname is provided, all hub connectivity tests will be skipped."; + return Err(anyhow::Error::msg(reason)); + } + } + _ => return Ok(CheckResult::Ignored), + }; + + self.iothub_hostname = Some(iothub_hostname.clone()); + iothub_hostname + } + }; + + let iothub_hostname_url = format!("https://{}:{}", iothub_hostname, self.port_number) + .parse::() + .context("Invalid URL specified in provisioning.iothub_hostname")?; + + // TODO: add proxy support once is supported in identityd + crate::internal::common::resolve_and_tls_handshake(iothub_hostname_url, &iothub_hostname) + .await?; + + Ok(CheckResult::Ok) + } +} diff --git a/aziot/src/internal/check/checks/host_local_time.rs b/aziot/src/internal/check/checks/host_local_time.rs new file mode 100644 index 000000000..8a7ae16a1 --- /dev/null +++ b/aziot/src/internal/check/checks/host_local_time.rs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::{anyhow, Context, Error, Result}; +use serde::Serialize; + +use crate::internal::check::{CheckResult, Checker, CheckerCache, CheckerMeta, CheckerShared}; + +#[derive(Serialize, Default)] +pub struct HostLocalTime { + offset: Option, +} + +#[async_trait::async_trait] +impl Checker for HostLocalTime { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "host-local-time", + description: "host time is close to reference time", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + self.execute_inner(shared, cache) + .await + .unwrap_or_else(CheckResult::Failed) + } +} + +impl HostLocalTime { + async fn execute_inner( + &mut self, + shared: &CheckerShared, + _cache: &mut CheckerCache, + ) -> Result { + fn is_server_unreachable_error(err: &mini_sntp::Error) -> bool { + match err { + mini_sntp::Error::ResolveNtpPoolHostname(_) => true, + mini_sntp::Error::SendClientRequest(err) + | mini_sntp::Error::ReceiveServerResponse(err) => { + err.kind() == std::io::ErrorKind::TimedOut || // Windows + err.kind() == std::io::ErrorKind::WouldBlock // Unix + } + _ => false, + } + } + + let mini_sntp::SntpTimeQueryResult { + local_clock_offset, .. + } = match mini_sntp::query(&shared.cfg.ntp_server) { + Ok(result) => result, + Err(err) => { + return if is_server_unreachable_error(&err) { + Ok(CheckResult::Warning( + Error::new(err).context("Could not query NTP server"), + )) + } else { + Err(err).context("Could not query NTP server") + } + } + }; + + let offset = local_clock_offset.num_seconds().abs(); + self.offset = Some(offset); + if offset >= 10 { + return Ok(CheckResult::Warning(anyhow!( + "Time on the device is out of sync with the NTP server. This may cause problems connecting to IoT Hub.\n\ + Please ensure time on device is accurate, for example by installing an NTP daemon.", + ))); + } + + Ok(CheckResult::Ok) + } +} diff --git a/aziot/src/internal/check/checks/hostname.rs b/aziot/src/internal/check/checks/hostname.rs new file mode 100644 index 000000000..79cd7669a --- /dev/null +++ b/aziot/src/internal/check/checks/hostname.rs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::{anyhow, Result}; +use serde::Serialize; + +use crate::internal::check::{CheckResult, Checker, CheckerCache, CheckerMeta, CheckerShared}; + +#[derive(Serialize, Default)] +pub struct Hostname { + config_hostname: Option, + machine_hostname: Option, +} + +#[async_trait::async_trait] +impl Checker for Hostname { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "hostname", + description: "identityd config toml file specifies a valid hostname", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + self.execute_inner(shared, cache) + .await + .unwrap_or_else(CheckResult::Failed) + } +} + +impl Hostname { + async fn execute_inner( + &mut self, + _shared: &CheckerShared, + cache: &mut CheckerCache, + ) -> Result { + let config_hostname = &unwrap_or_skip!(&cache.cfg.identityd).hostname; + self.config_hostname = Some(config_hostname.clone()); + + if config_hostname.parse::().is_ok() { + self.machine_hostname = self.config_hostname.clone(); + // We can only check that it is a valid IP + return Ok(CheckResult::Ok); + } + + let machine_hostname = crate::internal::common::get_hostname()?; + self.machine_hostname = Some(machine_hostname.clone()); + + // Technically the value of config_hostname doesn't matter as long as it resolves to this device. + // However determining that the value resolves to *this device* is not trivial. + // + // We could start a server and verify that we can connect to ourselves via that hostname, but starting a + // publicly-available server is not something to be done trivially. + // + // We could enumerate the network interfaces of the device and verify that the IP that the hostname resolves to + // belongs to one of them, but this requires non-trivial OS-specific code + // (`getifaddrs` on Linux). + // + // Instead, we punt on this check and assume that everything's fine if config_hostname is identical to the device hostname, + // or starts with it. + if config_hostname != &machine_hostname + && !config_hostname.starts_with(&format!("{}.", machine_hostname)) + { + return Err(anyhow!( + "config.yaml has hostname {} but device reports hostname {}.\n\ + Hostname in config.yaml must either be identical to the device hostname \ + or be a fully-qualified domain name that has the device hostname as the first component.", + config_hostname, machine_hostname, + )); + } + + // Some software like the IoT Hub SDKs for downstream clients require the device hostname to follow RFC 1035. + // For example, the IoT Hub C# SDK cannot connect to a hostname that contains an `_`. + if !is_rfc_1035_valid(config_hostname) { + return Ok(CheckResult::Warning(anyhow!( + "config.yaml has hostname {} which does not comply with RFC 1035.\n\ + \n\ + - Hostname must be between 1 and 255 octets inclusive.\n\ + - Each label in the hostname (component separated by \".\") must be between 1 and 63 octets inclusive.\n\ + - Each label must start with an ASCII alphabet character (a-z, A-Z), end with an ASCII alphanumeric character (a-z, A-Z, 0-9), \ + and must contain only ASCII alphanumeric characters or hyphens (a-z, A-Z, 0-9, \"-\").\n\ + \n\ + Not complying with RFC 1035 may cause errors during the TLS handshake with modules and downstream devices.", + config_hostname, + ))); + } + + if !check_length_for_local_issuer(config_hostname) { + return Ok(CheckResult::Warning(anyhow!( + "config.yaml hostname {} is too long to be used as a certificate issuer", + config_hostname, + ))); + } + + Ok(CheckResult::Ok) + } +} + +/// DEVNOTE: duplicated from `iotedge/src/check/hostname_checks_common` +fn is_rfc_1035_valid(name: &str) -> bool { + if name.is_empty() || name.len() > 255 { + return false; + } + + let mut labels = name.split('.'); + + let all_labels_valid = labels.all(|label| { + if label.len() > 63 { + return false; + } + + let first_char = match label.chars().next() { + Some(c) => c, + None => return false, + }; + if !first_char.is_ascii_alphabetic() { + return false; + } + + if label + .chars() + .any(|c| !c.is_ascii_alphanumeric() && c != '-') + { + return false; + } + + let last_char = label + .chars() + .last() + .expect("label has at least one character"); + if !last_char.is_ascii_alphanumeric() { + return false; + } + + true + }); + if !all_labels_valid { + return false; + } + + true +} + +/// DEVNOTE: duplicated from `iotedge/src/check/hostname_checks_common` +fn check_length_for_local_issuer(name: &str) -> bool { + if name.is_empty() || name.len() > 64 { + return false; + } + + true +} + +/// DEVNOTE: duplicated from `iotedge/src/check/hostname_checks_common` +#[cfg(test)] +mod tests { + use super::check_length_for_local_issuer; + use super::is_rfc_1035_valid; + + #[test] + fn test_check_length_for_local_issuer() { + let longest_valid_label = "a".repeat(64); + assert!(check_length_for_local_issuer(&longest_valid_label)); + + let invalid_label = "a".repeat(65); + assert!(!check_length_for_local_issuer(&invalid_label)); + } + + #[test] + fn test_is_rfc_1035_valid() { + let longest_valid_label = "a".repeat(63); + let longest_valid_name = format!( + "{label}.{label}.{label}.{label_rest}", + label = longest_valid_label, + label_rest = "a".repeat(255 - 63 * 3 - 3) + ); + assert_eq!(longest_valid_name.len(), 255); + + assert!(is_rfc_1035_valid("foobar")); + assert!(is_rfc_1035_valid("foobar.baz")); + assert!(is_rfc_1035_valid(&longest_valid_label)); + assert!(is_rfc_1035_valid(&format!( + "{label}.{label}.{label}", + label = longest_valid_label + ))); + assert!(is_rfc_1035_valid(&longest_valid_name)); + assert!(is_rfc_1035_valid("xn--v9ju72g90p.com")); + assert!(is_rfc_1035_valid("xn--a-kz6a.xn--b-kn6b.xn--c-ibu")); + + assert!(is_rfc_1035_valid("FOOBAR")); + assert!(is_rfc_1035_valid("FOOBAR.BAZ")); + assert!(is_rfc_1035_valid("FoObAr01.bAz")); + + assert!(!is_rfc_1035_valid(&format!("{}a", longest_valid_label))); + assert!(!is_rfc_1035_valid(&format!("{}a", longest_valid_name))); + assert!(!is_rfc_1035_valid("01.org")); + assert!(!is_rfc_1035_valid("\u{4eca}\u{65e5}\u{306f}")); + assert!(!is_rfc_1035_valid("\u{4eca}\u{65e5}\u{306f}.com")); + assert!(!is_rfc_1035_valid("a\u{4eca}.b\u{65e5}.c\u{306f}")); + assert!(!is_rfc_1035_valid("FoObAr01.bAz-")); + } +} diff --git a/aziot/src/internal/check/checks/mod.rs b/aziot/src/internal/check/checks/mod.rs new file mode 100644 index 000000000..e1b266f6d --- /dev/null +++ b/aziot/src/internal/check/checks/mod.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +use super::Checker; + +/// Tries to unwrap an option, early-returning with +/// `return Ok(CheckResult::Skipped)` if the option is None. +macro_rules! unwrap_or_skip { + ($opt:expr) => {{ + use crate::internal::check::CheckResult; + + match $opt { + Some(val) => val, + None => return Ok(CheckResult::Skipped), + } + }}; +} + +mod cert_expiry; +mod certs_preloaded; +mod daemons_running; +mod host_connect_dps_endpoint; +mod host_connect_iothub; +mod host_local_time; +mod hostname; +mod well_formed_configs; + +pub fn all_checks() -> Vec<(&'static str, Vec>)> { + // DEVNOTE: keep ordering consistent. Later tests may depend on earlier tests. + vec![ + ("Configuration checks", { + let mut v: Vec> = Vec::new(); + v.extend(well_formed_configs::well_formed_configs()); + v.push(Box::new(hostname::Hostname::default())); + // TODO: add aziot version info to https://github.com/Azure/azure-iotedge + // v.push(Box::new(aziot_version::AziotVersion::default())); + v.push(Box::new(host_local_time::HostLocalTime::default())); + v.extend(cert_expiry::cert_expirations()); + v.push(Box::new(certs_preloaded::CertsPreloaded::default())); + v + }), + ("Connectivity checks", { + let mut v: Vec> = Vec::new(); + v.extend(host_connect_iothub::host_connect_iothub_checks()); + v.extend(daemons_running::daemons_running()); + v.push(Box::new( + host_connect_dps_endpoint::HostConnectDpsEndpoint::default(), + )); + v + }), + ] +} diff --git a/aziot/src/internal/check/checks/well_formed_configs.rs b/aziot/src/internal/check/checks/well_formed_configs.rs new file mode 100644 index 000000000..af295fb06 --- /dev/null +++ b/aziot/src/internal/check/checks/well_formed_configs.rs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::{Context, Error, Result}; +use serde::Serialize; +use tokio::fs; +use tokio::io; +use tokio::io::AsyncReadExt; + +use crate::internal::check::{CheckResult, Checker, CheckerCache, CheckerMeta, CheckerShared}; + +use std::path::Path; + +pub fn well_formed_configs() -> impl Iterator> { + let mut v: Vec> = Vec::new(); + + v.push(Box::new(WellFormedKeydConfig {})); + v.push(Box::new(WellFormedCertdConfig {})); + v.push(Box::new(WellFormedTpmdConfig {})); + v.push(Box::new(WellFormedIdentitydConfig {})); + + v.into_iter() +} + +#[derive(Serialize)] +struct WellFormedKeydConfig {} + +#[async_trait::async_trait] +impl Checker for WellFormedKeydConfig { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "keyd-config-toml-well-formed", + description: "keyd config toml file is well-formed", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + let daemon_cfg = + match load_daemon_cfg("keyd", Path::new("/etc/aziot/keyd/config.toml"), shared).await { + Ok(DaemonCfg::Cfg(daemon_cfg)) => daemon_cfg, + Ok(DaemonCfg::PermissionDenied(e)) => return CheckResult::Fatal(e), + Err(e) => return CheckResult::Failed(e), + }; + + cache.cfg.keyd = Some(daemon_cfg); + CheckResult::Ok + } +} + +#[derive(Serialize)] +struct WellFormedCertdConfig {} + +#[async_trait::async_trait] +impl Checker for WellFormedCertdConfig { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "certd-config-toml-well-formed", + description: "certd config toml file is well-formed", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + let daemon_cfg = + match load_daemon_cfg("certd", Path::new("/etc/aziot/certd/config.toml"), shared).await + { + Ok(DaemonCfg::Cfg(daemon_cfg)) => daemon_cfg, + Ok(DaemonCfg::PermissionDenied(e)) => return CheckResult::Fatal(e), + Err(e) => return CheckResult::Failed(e), + }; + + cache.cfg.certd = Some(daemon_cfg); + CheckResult::Ok + } +} + +#[derive(Serialize)] +struct WellFormedTpmdConfig {} + +#[async_trait::async_trait] +impl Checker for WellFormedTpmdConfig { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "tpmd-config-toml-well-formed", + description: "tpmd config toml file is well-formed", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + let daemon_cfg = + match load_daemon_cfg("tpmd", Path::new("/etc/aziot/tpmd/config.toml"), shared).await { + Ok(DaemonCfg::Cfg(daemon_cfg)) => daemon_cfg, + Ok(DaemonCfg::PermissionDenied(e)) => return CheckResult::Fatal(e), + Err(e) => return CheckResult::Failed(e), + }; + + cache.cfg.tpmd = Some(daemon_cfg); + CheckResult::Ok + } +} + +// DEVNOTE: identityd requires additional post-deserialize validation via it's `.check` method +#[derive(Serialize)] +struct WellFormedIdentitydConfig {} + +#[async_trait::async_trait] +impl Checker for WellFormedIdentitydConfig { + fn meta(&self) -> CheckerMeta { + CheckerMeta { + id: "identityd-config-toml-well-formed", + description: "identityd config toml file is well-formed", + } + } + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult { + let daemon_cfg: aziot_identityd_config::Settings = match load_daemon_cfg( + "identityd", + Path::new("/etc/aziot/identityd/config.toml"), + shared, + ) + .await + { + Ok(DaemonCfg::Cfg(daemon_cfg)) => daemon_cfg, + Ok(DaemonCfg::PermissionDenied(e)) => return CheckResult::Fatal(e), + Err(e) => return CheckResult::Failed(e), + }; + + let daemon_cfg = match daemon_cfg.check() { + Ok(daemon_cfg) => daemon_cfg, + Err(e) => return CheckResult::Failed(e.into()), + }; + + cache.cfg.identityd = Some(daemon_cfg); + + // At the same time, try to load the backup identityd config. + // it's okay if it doesn't exist yet. + match load_daemon_cfg::( + "identityd_prev", + Path::new("/var/lib/aziot/identityd/prev_state"), + shared, + ) + .await + { + Ok(DaemonCfg::Cfg(daemon_cfg)) => { + if let Ok(daemon_cfg) = daemon_cfg.check() { + cache.cfg.identityd_prev = Some(daemon_cfg); + } + } + Ok(DaemonCfg::PermissionDenied(_)) | Err(_) => {} + }; + + CheckResult::Ok + } +} + +enum DaemonCfg { + Cfg(T), + PermissionDenied(Error), +} + +async fn load_daemon_cfg( + daemon: &str, + path: &Path, + shared: &CheckerShared, +) -> Result> { + let file_ctx = format!("error in file {}", path.display()); + + let mut file = match fs::File::open(path).await { + Ok(f) => f, + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + return Ok(DaemonCfg::PermissionDenied( + Error::from(e) + .context(file_ctx) + .context("Could not open file. You might need to run this command as root."), + )); + } + Err(e) => return Err(e).context(file_ctx).context("Could not open file."), + }; + + let mut data = Vec::new(); + if let Err(e) = file.read_to_end(&mut data).await { + return Err(e).context(file_ctx).context("Could not read file."); + } + + let daemon_cfg = match toml::from_slice(&data) { + Ok(daemon_cfg) => daemon_cfg, + Err(e) => { + let message = if shared.cfg.verbose { + format!( + "{}'s configuration file is not well-formed.\n\ + Note: In case of syntax errors, the error may not be exactly at the reported line number and position.", + daemon, + ) + } else { + format!("{}'s configuration file is not well-formed.", daemon) + }; + return Err(e).context(file_ctx).context(message); + } + }; + + Ok(DaemonCfg::Cfg(daemon_cfg)) +} diff --git a/aziot/src/internal/check/mod.rs b/aziot/src/internal/check/mod.rs new file mode 100644 index 000000000..62dfeb4b8 --- /dev/null +++ b/aziot/src/internal/check/mod.rs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +use serde::Serialize; +use structopt::StructOpt; + +mod additional_info; +mod checks; +mod util; + +pub(crate) use additional_info::AdditionalInfo; +pub(crate) use checks::all_checks; + +// NOTE: this struct gets `structopt(flatten)`ed as part of the `aziot check` subcommand. +#[derive(StructOpt)] +pub struct CheckerCfg { + // TODO: add aziot version info to https://github.com/Azure/azure-iotedge + // /// Sets the expected version of the iotedged binary. Defaults to the value + // /// contained in + // expected_iotedged_version: String, + // + /// Sets the NTP server to use when checking host local time. + #[structopt(long, value_name = "NTP_SERVER", default_value = "pool.ntp.org:123")] + pub ntp_server: String, + + // (Manually populated to match top-level CheckOptions value) + #[structopt(skip)] + pub verbose: bool, + + /// Sets the hostname of the Azure IoT Hub that this device would connect to. + /// If using manual provisioning, this does not need to be specified. + #[structopt(long, value_name = "IOTHUB_HOSTNAME")] + pub iothub_hostname: Option, +} + +pub struct CheckerShared { + cfg: CheckerCfg, +} + +impl CheckerShared { + pub fn new(cfg: CheckerCfg) -> CheckerShared { + CheckerShared { cfg } + } +} + +/// The various ways a check can resolve. +/// +/// Check functions return `Result` where `Err` represents the check failed. +#[derive(Debug)] +pub enum CheckResult { + /// Check succeeded. + Ok, + + /// Check failed with a warning. + Warning(anyhow::Error), + + /// Check is not applicable and was ignored. Should be treated as success. + Ignored, + + /// Check was skipped because of errors from some previous checks. Should be treated as an error. + Skipped, + + /// Check failed, and further checks should be performed. + Failed(anyhow::Error), + + /// Check failed, and further checks should not be performed. + Fatal(anyhow::Error), +} + +#[derive(Debug, Copy, Clone, Serialize)] +pub struct CheckerMeta { + /// Unique human-readable identifier for the check. + pub id: &'static str, + /// A brief description of what this check does. + pub description: &'static str, +} + +impl From for aziot_check_common::CheckerMetaSerializable { + fn from(meta: CheckerMeta) -> aziot_check_common::CheckerMetaSerializable { + aziot_check_common::CheckerMetaSerializable { + id: meta.id.into(), + description: meta.description.into(), + } + } +} + +#[async_trait::async_trait] +pub trait Checker: erased_serde::Serialize { + fn meta(&self) -> CheckerMeta; + + async fn execute(&mut self, shared: &CheckerShared, cache: &mut CheckerCache) -> CheckResult; +} + +erased_serde::serialize_trait_object!(Checker); + +/// Container for any cached data shared between different checks. +pub struct CheckerCache { + pub cfg: DaemonConfigs, +} + +impl CheckerCache { + pub fn new() -> CheckerCache { + CheckerCache { + cfg: DaemonConfigs::default(), + } + } +} + +// populated during the `well_formed_configs` checks +#[derive(Default)] +pub struct DaemonConfigs { + pub certd: Option, + pub keyd: Option, + pub tpmd: Option, + pub identityd: Option, + pub identityd_prev: Option, +} diff --git a/aziot/src/internal/check/util.rs b/aziot/src/internal/check/util.rs new file mode 100644 index 000000000..6b95f9372 --- /dev/null +++ b/aziot/src/internal/check/util.rs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +use anyhow::anyhow; + +use crate::internal::common::CertificateValidity; + +use super::CheckResult; + +pub trait CertificateValidityExt { + fn to_check_result(&self) -> anyhow::Result; +} + +impl CertificateValidityExt for CertificateValidity { + fn to_check_result(&self) -> anyhow::Result { + let now = chrono::Utc::now(); + if self.not_before > now { + Err(anyhow!( + "{} '{}' has not-before time {} which is in the future", + self.cert_name, + self.cert_id, + self.not_before, + )) + } else if self.not_after < now { + Err(anyhow!( + "{} '{}' expired at {}", + self.cert_name, + self.cert_id, + self.not_after, + )) + } else if self.not_after < now + chrono::Duration::days(7) { + Ok(CheckResult::Warning(anyhow!( + "{} '{}' will expire soon ({}, in {} days)", + self.cert_name, + self.cert_id, + self.not_after, + (self.not_after - now).num_days(), + ))) + } else { + Ok(CheckResult::Ok) + } + } +} diff --git a/aziot/src/internal/common.rs b/aziot/src/internal/common.rs new file mode 100644 index 000000000..710e8b445 --- /dev/null +++ b/aziot/src/internal/common.rs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft. All rights reserved. + +//! A grab-bag of misc. utilities shared across the various sub-commands. + +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use serde::Serialize; +use tokio::fs; +use tokio::prelude::*; + +pub fn get_hostname() -> Result { + if cfg!(test) { + Ok("my-device".to_owned()) + } else { + let mut hostname = vec![0_u8; 256]; + let hostname = + nix::unistd::gethostname(&mut hostname).context("could not get machine hostname")?; + let hostname = hostname + .to_str() + .context("could not get machine hostname")?; + Ok(hostname.to_owned()) + } +} + +#[derive(Debug, Serialize, Clone)] +pub struct CertificateValidity { + pub(crate) cert_name: String, + pub(crate) cert_id: String, + pub(crate) not_after: chrono::DateTime, + pub(crate) not_before: chrono::DateTime, +} + +impl CertificateValidity { + pub async fn new( + cert_path: impl AsRef, + cert_name: &str, + cert_id: &str, + ) -> Result { + fn parse_openssl_time( + time: &openssl::asn1::Asn1TimeRef, + ) -> chrono::ParseResult> { + // openssl::asn1::Asn1TimeRef does not expose any way to convert the ASN1_TIME to a Rust-friendly type + // + // Its Display impl uses ASN1_TIME_print, so we convert it into a String and parse it back + // into a chrono::DateTime + let time = time.to_string(); + let time = chrono::NaiveDateTime::parse_from_str(&time, "%b %e %H:%M:%S %Y GMT")?; + Ok(chrono::DateTime::::from_utc(time, chrono::Utc)) + } + + let cert_path = cert_path.as_ref(); + + let file_ctx = format!("operation on file {}", cert_path.display()); + + let mut file = match fs::File::open(cert_path).await { + Ok(f) => f, + Err(e) => { + return Err(e) + .context(file_ctx) + .context("Could not open cert file.") + } + }; + + let mut pem = Vec::new(); + if let Err(e) = file.read_to_end(&mut pem).await { + return Err(e) + .context(file_ctx) + .context("Could not read cert file."); + } + + let cert = openssl::x509::X509::stack_from_pem(&pem)?; + let cert = cert + .get(0) + .ok_or_else(|| anyhow!("could not parse {} as a valid .pem", cert_path.display()))?; + + let not_after = parse_openssl_time(cert.not_after())?; + let not_before = parse_openssl_time(cert.not_before())?; + + Ok(CertificateValidity { + cert_name: cert_name.to_string(), + cert_id: cert_id.to_string(), + not_after, + not_before, + }) + } +} + +pub async fn resolve_and_tls_handshake(endpoint: hyper::Uri, hostname_display: &str) -> Result<()> { + use hyper::service::Service; + + // we don't actually care about the stream that gets returned. All we care about + // is whether or not the TLS handshake was successful + let _ = hyper_openssl::HttpsConnector::new() + .with_context(|| { + anyhow!( + "Could not connect to {} : could not create TLS connector", + hostname_display, + ) + })? + .call(endpoint) + .await + .map_err(|e| anyhow!("{}", e)) + .with_context(|| { + anyhow!( + "Could not connect to {} : could not complete TLS handshake", + hostname_display, + ) + })?; + + Ok(()) +} diff --git a/aziot/src/internal/mod.rs b/aziot/src/internal/mod.rs new file mode 100644 index 000000000..52d3521b4 --- /dev/null +++ b/aziot/src/internal/mod.rs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft. All rights reserved. + +pub mod check; +pub mod common; diff --git a/aziot/src/main.rs b/aziot/src/main.rs index 9ebbb98c8..f0dc359bb 100644 --- a/aziot/src/main.rs +++ b/aziot/src/main.rs @@ -5,50 +5,45 @@ #![allow( clippy::default_trait_access, clippy::let_unit_value, + clippy::module_name_repetitions, clippy::similar_names, clippy::too_many_lines, clippy::type_complexity )] +use anyhow::Result; +use structopt::StructOpt; + +mod internal; + +mod check; +mod check_list; mod init; -fn main() -> Result<(), Error> { - let options = structopt::StructOpt::from_args(); +async fn try_main() -> Result<()> { + let options = StructOpt::from_args(); match options { Options::Init => init::run()?, + Options::Check(cfg) => check::check(cfg).await?, + Options::CheckList(cfg) => check_list::check_list(cfg)?, } Ok(()) } -#[derive(structopt::StructOpt)] -enum Options { - Init, -} - -struct Error(Box, backtrace::Backtrace); - -impl std::fmt::Debug for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "{}", self.0)?; - - let mut source = self.0.source(); - while let Some(err) = source { - writeln!(f, "caused by: {}", err)?; - source = err.source(); - } - - writeln!(f, "{:?}", self.1)?; - - Ok(()) +#[tokio::main] +async fn main() { + if let Err(err) = try_main().await { + eprintln!("{:?}", err); } } -impl From for Error -where - E: Into>, -{ - fn from(err: E) -> Self { - Error(err.into(), Default::default()) - } +#[derive(StructOpt)] +enum Options { + /// Interactive wizard to get 'aziot' up and running. + Init, + /// Check for common config and deployment issues. + Check(check::CheckOptions), + /// List the checks that are run for 'aziot check' + CheckList(check_list::CheckListOptions), } diff --git a/cert/aziot-certd-config/Cargo.toml b/cert/aziot-certd-config/Cargo.toml index 21d4247b0..d5f11bdfd 100644 --- a/cert/aziot-certd-config/Cargo.toml +++ b/cert/aziot-certd-config/Cargo.toml @@ -9,7 +9,8 @@ edition = "2018" http-common = { path = "../../http-common" } serde = { version = "1", features = ["derive"] } url = { version = "2", features = ["serde"] } - +openssl = "0.10" +hex = "0.4" [dev-dependencies] toml = "0.5" diff --git a/cert/aziot-certd-config/src/lib.rs b/cert/aziot-certd-config/src/lib.rs index e96c1d006..3eac0239f 100644 --- a/cert/aziot-certd-config/src/lib.rs +++ b/cert/aziot-certd-config/src/lib.rs @@ -2,7 +2,14 @@ #![deny(rust_2018_idioms)] #![warn(clippy::all, clippy::pedantic)] -#![allow(clippy::default_trait_access, clippy::too_many_lines)] +#![allow( + clippy::default_trait_access, + clippy::too_many_lines, + clippy::let_unit_value, + clippy::missing_errors_doc +)] + +pub mod util; #[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub struct Config { diff --git a/cert/aziot-certd-config/src/util.rs b/cert/aziot-certd-config/src/util.rs new file mode 100644 index 000000000..9754d9ceb --- /dev/null +++ b/cert/aziot-certd-config/src/util.rs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +use crate::PreloadedCert; + +/// `create_dir_if_not_exist` should only be set to true when this method is +/// called from `aziot-certd`, or else the directory may be created with the +/// incorrect permissions. +pub fn get_path( + homedir_path: &std::path::Path, + preloaded_certs: &std::collections::BTreeMap, + cert_id: &str, + create_dir_if_not_exist: bool, +) -> Result> { + if let Some(preloaded_cert) = preloaded_certs.get(cert_id) { + let path = get_preloaded_cert_path(preloaded_cert, cert_id)?; + return Ok(path); + } + + let mut path = homedir_path.to_owned(); + path.push("certs"); + + if !path.exists() && create_dir_if_not_exist { + let () = std::fs::create_dir_all(&path)?; + } + + let id_sanitized: String = cert_id + .chars() + .filter(char::is_ascii_alphanumeric) + .collect(); + + let hash = openssl::hash::hash(openssl::hash::MessageDigest::sha256(), cert_id.as_bytes())?; + let hash = hex::encode(hash); + path.push(format!("{}-{}.cer", id_sanitized, hash)); + + Ok(path) +} + +fn get_preloaded_cert_path( + preloaded_cert: &PreloadedCert, + cert_id: &str, +) -> Result> { + match preloaded_cert { + PreloadedCert::Uri(uri) => { + let scheme = uri.scheme(); + if scheme != "file" { + return Err(format!( + "preloaded cert {:?} does not have a valid URI: unrecognized scheme {:?}", + cert_id, scheme, + ) + .into()); + } + + let path = uri.to_file_path().map_err(|()| { + format!( + "preloaded cert {:?} does not have a valid URI: not a valid path", + cert_id, + ) + })?; + + Ok(path) + } + + PreloadedCert::Ids(_) => Err(format!( + "preloaded cert {:?} is a list of IDs, not a single URI", + cert_id, + ) + .into()), + } +} diff --git a/cert/aziot-certd/Cargo.toml b/cert/aziot-certd/Cargo.toml index c783f796b..465d6fe50 100644 --- a/cert/aziot-certd/Cargo.toml +++ b/cert/aziot-certd/Cargo.toml @@ -31,7 +31,7 @@ aziot-key-client = { path = "../../key/aziot-key-client" } aziot-key-common = { path = "../../key/aziot-key-common" } aziot-key-common-http = { path = "../../key/aziot-key-common-http" } aziot-key-openssl-engine = { path = "../../key/aziot-key-openssl-engine" } -http-common = { path = "../../http-common" } +http-common = { path = "../../http-common", features = ["tokio02"] } openssl2 = { path = "../../openssl2" } diff --git a/cert/aziot-certd/src/lib.rs b/cert/aziot-certd/src/lib.rs index bb1000b25..8671a3352 100644 --- a/cert/aziot-certd/src/lib.rs +++ b/cert/aziot-certd/src/lib.rs @@ -98,7 +98,9 @@ impl Api { } pub fn import_cert(&mut self, id: &str, pem: &[u8]) -> Result<(), Error> { - let path = get_path(&self.homedir_path, &self.preloaded_certs, id)?; + let path = + aziot_certd_config::util::get_path(&self.homedir_path, &self.preloaded_certs, id, true) + .map_err(|err| Error::Internal(InternalError::GetPath(err)))?; std::fs::write(path, pem) .map_err(|err| Error::Internal(InternalError::CreateCert(Box::new(err))))?; Ok(()) @@ -111,7 +113,9 @@ impl Api { } pub fn delete_cert(&mut self, id: &str) -> Result<(), Error> { - let path = get_path(&self.homedir_path, &self.preloaded_certs, id)?; + let path = + aziot_certd_config::util::get_path(&self.homedir_path, &self.preloaded_certs, id, true) + .map_err(|err| Error::Internal(InternalError::GetPath(err)))?; match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(ref err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -120,77 +124,6 @@ impl Api { } } -fn get_path( - homedir_path: &std::path::Path, - preloaded_certs: &std::collections::BTreeMap, - cert_id: &str, -) -> Result { - if let Some(preloaded_cert) = preloaded_certs.get(cert_id) { - let path = get_preloaded_cert_path(preloaded_cert, cert_id)?; - return Ok(path); - } - - let mut path = homedir_path.to_owned(); - path.push("certs"); - - if !path.exists() { - let () = std::fs::create_dir_all(&path) - .map_err(|err| Error::Internal(InternalError::GetPath(Box::new(err))))?; - } - - let id_sanitized: String = cert_id - .chars() - .filter(char::is_ascii_alphanumeric) - .collect(); - - let hash = openssl::hash::hash(openssl::hash::MessageDigest::sha256(), cert_id.as_bytes()) - .map_err(|err| Error::Internal(InternalError::GetPath(Box::new(err))))?; - let hash = hex::encode(hash); - path.push(format!("{}-{}.cer", id_sanitized, hash)); - - Ok(path) -} - -fn get_preloaded_cert_path( - preloaded_cert: &PreloadedCert, - cert_id: &str, -) -> Result { - match preloaded_cert { - PreloadedCert::Uri(uri) => { - let scheme = uri.scheme(); - if scheme != "file" { - return Err(Error::Internal(InternalError::GetPath( - format!( - "preloaded cert {:?} does not have a valid URI: unrecognized scheme {:?}", - cert_id, scheme, - ) - .into(), - ))); - } - - let path = uri.to_file_path().map_err(|()| { - Error::Internal(InternalError::GetPath( - format!( - "preloaded cert {:?} does not have a valid URI: not a valid path", - cert_id, - ) - .into(), - )) - })?; - - Ok(path) - } - - PreloadedCert::Ids(_) => Err(Error::Internal(InternalError::GetPath( - format!( - "preloaded cert {:?} is a list of IDs, not a single URI", - cert_id, - ) - .into(), - ))), - } -} - fn load_inner(path: &std::path::Path) -> Result>, Error> { match std::fs::read(path) { Ok(cert_bytes) => Ok(Some(cert_bytes)), @@ -313,7 +246,13 @@ fn create_cert<'a>( } else { // Load the issuer and use it to sign the CSR. - let issuer_path = get_path(&api.homedir_path, &api.preloaded_certs, issuer_id)?; + let issuer_path = aziot_certd_config::util::get_path( + &api.homedir_path, + &api.preloaded_certs, + issuer_id, + true, + ) + .map_err(|err| Error::Internal(InternalError::GetPath(err)))?; let issuer_x509_pem = load_inner(&issuer_path) .map_err(|err| Error::Internal(InternalError::CreateCert(Box::new(err))))? .ok_or_else(|| Error::invalid_parameter("issuer.certId", "not found"))?; @@ -339,7 +278,13 @@ fn create_cert<'a>( x509 }; - let path = get_path(&api.homedir_path, &api.preloaded_certs, id)?; + let path = aziot_certd_config::util::get_path( + &api.homedir_path, + &api.preloaded_certs, + id, + true, + ) + .map_err(|err| Error::Internal(InternalError::GetPath(err)))?; std::fs::write(path, &x509) .map_err(|err| Error::Internal(InternalError::CreateCert(Box::new(err))))?; @@ -461,7 +406,13 @@ fn create_cert<'a>( ) .await?; - let path = get_path(&api.homedir_path, &api.preloaded_certs, id)?; + let path = aziot_certd_config::util::get_path( + &api.homedir_path, + &api.preloaded_certs, + id, + true, + ) + .map_err(|err| Error::Internal(InternalError::GetPath(err)))?; std::fs::write(path, &x509).map_err(|err| { Error::Internal(InternalError::CreateCert(Box::new(err))) })?; @@ -680,11 +631,15 @@ fn create_cert<'a>( ) .await?; - let path = get_path( + let path = aziot_certd_config::util::get_path( &api.homedir_path, &api.preloaded_certs, identity_cert, - )?; + true, + ) + .map_err(|err| { + Error::Internal(InternalError::GetPath(err)) + })?; std::fs::write(path, &x509).map_err(|err| { Error::Internal(InternalError::CreateCert(Box::new( err, @@ -722,7 +677,13 @@ fn create_cert<'a>( ) .await?; - let path = get_path(&api.homedir_path, &api.preloaded_certs, id)?; + let path = aziot_certd_config::util::get_path( + &api.homedir_path, + &api.preloaded_certs, + id, + true, + ) + .map_err(|err| Error::Internal(InternalError::GetPath(err)))?; std::fs::write(path, &x509).map_err(|err| { Error::Internal(InternalError::CreateCert(Box::new(err))) })?; @@ -786,7 +747,8 @@ fn get_cert_inner( preloaded_certs: &std::collections::BTreeMap, id: &str, ) -> Result>, Error> { - let path = get_path(homedir_path, preloaded_certs, id)?; + let path = aziot_certd_config::util::get_path(homedir_path, preloaded_certs, id, true) + .map_err(|err| Error::Internal(InternalError::GetPath(err)))?; let bytes = load_inner(&path)?; Ok(bytes) } diff --git a/contrib/third-party-notices.sh b/contrib/third-party-notices.sh index 28b456032..45eebd9c3 100755 --- a/contrib/third-party-notices.sh +++ b/contrib/third-party-notices.sh @@ -147,6 +147,8 @@ cargo metadata --format-version 1 | "BSD-3-Clause" elif $license == "Zlib" then "Zlib" + elif $license == "MPL-2.0" then + "MPL-2.0" else error("crate \($name) has unknown license \($license)") end @@ -179,6 +181,10 @@ cargo metadata --format-version 1 | license_file_suffix='ZLIB' ;; + 'MPL-2.0') + license_file_suffix='MPL2' + ;; + *) echo "$name:$version at $manifest_path has unsupported license $license" >&2 exit 1 diff --git a/http-common/Cargo.toml b/http-common/Cargo.toml index 57f45471f..facdc2da6 100644 --- a/http-common/Cargo.toml +++ b/http-common/Cargo.toml @@ -10,7 +10,6 @@ base64 = "0.12" futures-util = "0.3" http = "0.2" hyper = { version = "0.13", optional = true } -lazy_static = "1" libc = "0.2" log = "0.4" nix = "0.18" diff --git a/identity/aziot-dps-client-async/Cargo.toml b/identity/aziot-dps-client-async/Cargo.toml index 62fc9aee1..3deeec459 100644 --- a/identity/aziot-dps-client-async/Cargo.toml +++ b/identity/aziot-dps-client-async/Cargo.toml @@ -24,10 +24,8 @@ url = "2" aziot-cert-client-async = { path = "../../cert/aziot-cert-client-async" } aziot-cloud-client-async-common = { path = "../aziot-cloud-client-async-common" } -aziot-key-client = { path = "../../key/aziot-key-client" } aziot-key-client-async = { path = "../../key/aziot-key-client-async" } aziot-key-common = { path = "../../key/aziot-key-common" } -aziot-key-openssl-engine = { path = "../../key/aziot-key-openssl-engine" } aziot-tpm-client-async = { path = "../../tpm/aziot-tpm-client-async" } aziot-tpm-common = { path = "../../tpm/aziot-tpm-common" } http-common = { path = "../../http-common" } diff --git a/identity/aziot-dps-client-async/src/lib.rs b/identity/aziot-dps-client-async/src/lib.rs index 517387511..e35761438 100644 --- a/identity/aziot-dps-client-async/src/lib.rs +++ b/identity/aziot-dps-client-async/src/lib.rs @@ -158,7 +158,7 @@ impl Client { .await; }; - if matches!(auth_kind, DpsAuthKind::TpmWithAuth {..}) { + if matches!(auth_kind, DpsAuthKind::TpmWithAuth { .. }) { // import the returned authentication key into the TPM let auth_key = res .registration_state @@ -226,7 +226,7 @@ impl Client { DpsAuthKind::SymmetricKey { sas_key: key } | DpsAuthKind::TpmWithAuth { auth_key: key } => { let audience = format!("{}/registrations/{}", self.scope_id, registration_id); - let (connector, token) = if matches!(auth_kind, DpsAuthKind::SymmetricKey {..}) { + let (connector, token) = if matches!(auth_kind, DpsAuthKind::SymmetricKey { .. }) { get_sas_connector(&audience, &key, &*self.key_client, false).await? } else { get_sas_connector( diff --git a/identity/aziot-hub-client-async/Cargo.toml b/identity/aziot-hub-client-async/Cargo.toml index b15b54f9c..960521afd 100644 --- a/identity/aziot-hub-client-async/Cargo.toml +++ b/identity/aziot-hub-client-async/Cargo.toml @@ -24,10 +24,8 @@ aziot-cert-client-async = { path = "../../cert/aziot-cert-client-async" } aziot-cert-common-http = { path = "../../cert/aziot-cert-common-http" } aziot-cloud-client-async-common = { path = "../aziot-cloud-client-async-common" } aziot-identity-common = { path = "../aziot-identity-common" } -aziot-key-client = { path = "../../key/aziot-key-client" } aziot-key-client-async = { path = "../../key/aziot-key-client-async" } aziot-key-common = { path = "../../key/aziot-key-common" } -aziot-key-openssl-engine = { path = "../../key/aziot-key-openssl-engine" } aziot-tpm-client-async = { path = "../../tpm/aziot-tpm-client-async" } aziot-tpm-common = { path = "../../tpm/aziot-tpm-common" } http-common = { path = "../../http-common" } diff --git a/identity/aziot-identity-common-http/Cargo.toml b/identity/aziot-identity-common-http/Cargo.toml index acaf37c55..be34b88c6 100644 --- a/identity/aziot-identity-common-http/Cargo.toml +++ b/identity/aziot-identity-common-http/Cargo.toml @@ -13,5 +13,4 @@ serde_json = "1" aziot-cert-common-http = { path = "../../cert/aziot-cert-common-http" } aziot-identity-common = { path = "../aziot-identity-common" } aziot-key-common = { path = "../../key/aziot-key-common" } -aziot-key-common-http = { path = "../../key/aziot-key-common-http" } http-common = { path = "../../http-common" } diff --git a/identity/aziot-identityd-config/src/check.rs b/identity/aziot-identityd-config/src/check.rs new file mode 100644 index 000000000..4362411d8 --- /dev/null +++ b/identity/aziot-identityd-config/src/check.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +use super::{ProvisioningType, Settings}; + +impl Settings { + pub fn check(self) -> Result { + let mut existing_names: std::collections::BTreeSet = + std::collections::BTreeSet::default(); + + for p in &self.principal { + if !existing_names.insert(p.name.clone()) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("duplicate module name: {}", p.name.0), + )); + } + + if let Some(t) = &p.id_type { + if t.contains(&aziot_identity_common::IdType::Local) { + // Require localid in config if any principal has local id_type. + if self.localid.is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "invalid config for {}: local id type requires localid config", + p.name.0 + ), + )); + } + } else { + // Reject principals that specify local identity options without the "local" type. + if p.localid.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid config for {}: local identity options specified for non-local identity", p.name.0) + )); + } + } + + // Require provisioning if any module or device identities are present. + let provisioning_valid = match self.provisioning.provisioning { + ProvisioningType::None => { + !t.contains(&aziot_identity_common::IdType::Module) + && !t.contains(&aziot_identity_common::IdType::Device) + } + _ => true, + }; + + if !provisioning_valid { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid config for {}: module or device identity requires provisioning with IoT Hub", p.name.0) + ) + ); + } + } + } + + Ok(self) + } +} diff --git a/identity/aziot-identityd-config/src/lib.rs b/identity/aziot-identityd-config/src/lib.rs index 740c09bd7..6e9e335d7 100644 --- a/identity/aziot-identityd-config/src/lib.rs +++ b/identity/aziot-identityd-config/src/lib.rs @@ -2,6 +2,9 @@ #![deny(rust_2018_idioms)] #![warn(clippy::all, clippy::pedantic)] +#![allow(clippy::missing_errors_doc)] + +mod check; #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] pub struct Settings { @@ -164,37 +167,30 @@ mod tests { assert_eq!(s.provisioning.dynamic_reprovisioning, false); - match s.provisioning.provisioning { + if !matches!( + s.provisioning.provisioning, ProvisioningType::Manual { - iothub_hostname: _, - device_id: _, - authentication, - } => match authentication { - ManualAuthMethod::SharedPrivateKey { device_id_pk: _ } => {} - _ => panic!("incorrect provisioning type selected"), - }, - _ => panic!("incorrect provisioning type selected"), - }; + authentication: ManualAuthMethod::SharedPrivateKey { .. }, + .. + } + ) { + panic!("incorrect provisioning type selected"); + } } #[test] fn manual_dps_provisioning_settings_succeeds() { let s = load_settings("test/good_dps_config.toml").unwrap(); - match s.provisioning.provisioning { + if !matches!( + s.provisioning.provisioning, ProvisioningType::Dps { - global_endpoint: _, - scope_id: _, - attestation, - } => match attestation { - DpsAttestationMethod::SymmetricKey { - registration_id: _, - symmetric_key: _, - } => (), - _ => panic!("incorrect provisioning type selected"), - }, - _ => panic!("incorrect provisioning type selected"), - }; + attestation: DpsAttestationMethod::SymmetricKey { .. }, + .. + } + ) { + panic!("incorrect provisioning type selected"); + } } #[test] diff --git a/identity/aziot-identityd/src/configext.rs b/identity/aziot-identityd/src/configext.rs index 20c68d94e..b3ab0b6e9 100644 --- a/identity/aziot-identityd/src/configext.rs +++ b/identity/aziot-identityd/src/configext.rs @@ -11,63 +11,7 @@ pub fn load_file(filename: &Path) -> Result { let settings: config::Settings = toml::from_str(&settings).map_err(InternalError::ParseSettings)?; - check(settings) -} - -pub fn check(settings: config::Settings) -> Result { - let mut existing_names: std::collections::BTreeSet = - std::collections::BTreeSet::default(); - - for p in &settings.principal { - if !existing_names.insert(p.name.clone()) { - return Err(InternalError::BadSettings(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("duplicate module name: {}", p.name.0), - ))); - } - - if let Some(t) = &p.id_type { - if t.contains(&aziot_identity_common::IdType::Local) { - // Require localid in config if any principal has local id_type. - if settings.localid.is_none() { - return Err(InternalError::BadSettings(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "invalid config for {}: local id type requires localid config", - p.name.0 - ), - ))); - } - } else { - // Reject principals that specify local identity options without the "local" type. - if p.localid.is_some() { - return Err(InternalError::BadSettings(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("invalid config for {}: local identity options specified for non-local identity", p.name.0) - ))); - } - } - - // Require provisioning if any module or device identities are present. - let provisioning_valid = match settings.provisioning.provisioning { - config::ProvisioningType::None => { - !t.contains(&aziot_identity_common::IdType::Module) - && !t.contains(&aziot_identity_common::IdType::Device) - } - _ => true, - }; - - if !provisioning_valid { - return Err(InternalError::BadSettings(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("invalid config for {}: module or device identity requires provisioning with IoT Hub", p.name.0) - )) - ); - } - } - } - - Ok(settings) + settings.check().map_err(InternalError::BadSettings) } #[derive(Debug, Eq, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)] diff --git a/identity/aziot-identityd/src/lib.rs b/identity/aziot-identityd/src/lib.rs index 7bdc6839d..e75edfbdf 100644 --- a/identity/aziot-identityd/src/lib.rs +++ b/identity/aziot-identityd/src/lib.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use aziot_identityd_config as config; pub mod auth; -mod configext; +pub mod configext; pub mod error; mod http; pub mod identity; @@ -51,7 +51,7 @@ pub async fn main( settings: config::Settings, ) -> Result<(http_common::Connector, http::Service), Box> { // Written to prev_settings_path if provisioning is successful. - let settings = configext::check(settings)?; + let settings = settings.check().map_err(InternalError::BadSettings)?; let settings_serialized = toml::to_vec(&settings).expect("serializing settings cannot fail"); let homedir_path = &settings.homedir; diff --git a/mini-sntp/Cargo.toml b/mini-sntp/Cargo.toml new file mode 100644 index 000000000..2ef30ecf3 --- /dev/null +++ b/mini-sntp/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "mini-sntp" +version = "0.1.0" +authors = ["Azure IoT Edge Devs"] +edition = "2018" +publish = false + +[dependencies] +chrono = "0.4" diff --git a/mini-sntp/src/error.rs b/mini-sntp/src/error.rs new file mode 100644 index 000000000..28f481842 --- /dev/null +++ b/mini-sntp/src/error.rs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +#[derive(Debug)] +pub enum Error { + BadServerResponse(BadServerResponseReason), + BindLocalSocket(std::io::Error), + ReceiveServerResponse(std::io::Error), + ResolveNtpPoolHostname(Option), + SendClientRequest(std::io::Error), + SetReadTimeoutOnSocket(std::io::Error), + SetWriteTimeoutOnSocket(std::io::Error), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::BadServerResponse(reason) => { + write!(f, "could not parse NTP server response: {}", reason) + } + Error::BindLocalSocket(_) => write!(f, "could not bind local UDP socket"), + Error::ReceiveServerResponse(err) => { + write!(f, "could not receive NTP server response: {}", err) + } + Error::ResolveNtpPoolHostname(Some(err)) => { + write!(f, "could not resolve NTP pool hostname: {}", err) + } + Error::ResolveNtpPoolHostname(None) => { + write!(f, "could not resolve NTP pool hostname: no addresses found") + } + Error::SendClientRequest(err) => { + write!(f, "could not send SNTP client request: {}", err) + } + Error::SetReadTimeoutOnSocket(_) => { + write!(f, "could not set read timeout on local UDP socket") + } + Error::SetWriteTimeoutOnSocket(_) => { + write!(f, "could not set write timeout on local UDP socket") + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + #[allow(clippy::match_same_arms)] + match self { + Error::BadServerResponse(_) => None, + Error::BindLocalSocket(err) => Some(err), + Error::ReceiveServerResponse(err) => Some(err), + Error::ResolveNtpPoolHostname(Some(err)) => Some(err), + Error::ResolveNtpPoolHostname(None) => None, + Error::SendClientRequest(err) => Some(err), + Error::SetReadTimeoutOnSocket(err) => Some(err), + Error::SetWriteTimeoutOnSocket(err) => Some(err), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub enum BadServerResponseReason { + LeapIndicator(u8), + OriginateTimestamp { + expected: chrono::DateTime, + actual: chrono::DateTime, + }, + Mode(u8), + VersionNumber(u8), +} + +impl std::fmt::Display for BadServerResponseReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BadServerResponseReason::LeapIndicator(leap_indicator) => { + write!(f, "invalid value of leap indicator {}", leap_indicator) + } + BadServerResponseReason::OriginateTimestamp { expected, actual } => write!( + f, + "expected originate timestamp to be {} but it was {}", + expected, actual + ), + BadServerResponseReason::Mode(mode) => { + write!(f, "expected mode to be 4 but it was {}", mode) + } + BadServerResponseReason::VersionNumber(version_number) => write!( + f, + "expected version number to be 3 but it was {}", + version_number + ), + } + } +} diff --git a/mini-sntp/src/lib.rs b/mini-sntp/src/lib.rs new file mode 100644 index 000000000..573695517 --- /dev/null +++ b/mini-sntp/src/lib.rs @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft. All rights reserved. + +#![deny(rust_2018_idioms)] +#![warn(clippy::all, clippy::pedantic)] +#![allow( + clippy::doc_markdown, + clippy::missing_errors_doc, + clippy::module_name_repetitions, + clippy::must_use_candidate, + clippy::too_many_lines, + clippy::use_self, + clippy::unusual_byte_groupings +)] + +use std::net::{SocketAddr, ToSocketAddrs, UdpSocket}; + +mod error; +pub use error::{BadServerResponseReason, Error}; + +/// The result of [`query`] +#[derive(Debug)] +pub struct SntpTimeQueryResult { + pub local_clock_offset: chrono::Duration, + pub round_trip_delay: chrono::Duration, +} + +/// Executes an SNTP query against the NTPv3 server at the given address. +/// +/// Ref: +pub fn query(addr: &A) -> Result +where + A: ToSocketAddrs, +{ + let addr = addr + .to_socket_addrs() + .map_err(|err| Error::ResolveNtpPoolHostname(Some(err)))? + .next() + .ok_or(Error::ResolveNtpPoolHostname(None))?; + + let socket = UdpSocket::bind("0.0.0.0:0").map_err(Error::BindLocalSocket)?; + socket + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .map_err(Error::SetReadTimeoutOnSocket)?; + socket + .set_write_timeout(Some(std::time::Duration::from_secs(10))) + .map_err(Error::SetWriteTimeoutOnSocket)?; + + let mut num_retries_remaining = 3; + loop { + match query_inner(&socket, addr) { + Ok(result) => return Ok(result), + Err(err) => { + let is_retriable = match &err { + Error::SendClientRequest(err) | Error::ReceiveServerResponse(err) => { + err.kind() == std::io::ErrorKind::TimedOut + || err.kind() == std::io::ErrorKind::WouldBlock + } + + _ => false, + }; + if is_retriable { + num_retries_remaining -= 1; + if num_retries_remaining == 0 { + return Err(err); + } + } else { + return Err(err); + } + } + } + } +} + +fn query_inner(socket: &UdpSocket, addr: SocketAddr) -> Result { + let request_transmit_timestamp = { + let (buf, request_transmit_timestamp) = create_client_request(); + + #[cfg(test)] + std::thread::sleep(std::time::Duration::from_secs(5)); // simulate network delay + + let mut buf = &buf[..]; + while !buf.is_empty() { + let sent = socket + .send_to(buf, addr) + .map_err(Error::SendClientRequest)?; + buf = &buf[sent..]; + } + + request_transmit_timestamp + }; + + let result = { + let mut buf = [0_u8; 48]; + + { + let mut buf = &mut buf[..]; + while !buf.is_empty() { + let (received, received_from) = socket + .recv_from(buf) + .map_err(Error::ReceiveServerResponse)?; + if received_from == addr { + buf = &mut buf[received..]; + } + } + } + + #[cfg(test)] + std::thread::sleep(std::time::Duration::from_secs(5)); // simulate network delay + + parse_server_response(buf, request_transmit_timestamp)? + }; + + Ok(result) +} + +fn create_client_request() -> ([u8; 48], chrono::DateTime) { + let sntp_epoch = sntp_epoch(); + + let mut buf = [0_u8; 48]; + buf[0] = 0b00_011_011; // version_number: 3, mode: 3 (client) + + let transmit_timestamp = chrono::Utc::now(); + + #[cfg(test)] + let transmit_timestamp = transmit_timestamp - chrono::Duration::seconds(30); // simulate unsynced local clock + + let mut duration_since_sntp_epoch = transmit_timestamp - sntp_epoch; + + let integral_part = duration_since_sntp_epoch.num_seconds(); + duration_since_sntp_epoch = + duration_since_sntp_epoch - chrono::Duration::seconds(integral_part); + + assert!(integral_part >= 0 && integral_part < i64::from(u32::max_value())); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let integral_part = (integral_part as u32).to_be_bytes(); + buf[40..44].copy_from_slice(&integral_part[..]); + + let fractional_part = duration_since_sntp_epoch + .num_nanoseconds() + .expect("can't overflow nanoseconds"); + let fractional_part = (fractional_part << 32) / 1_000_000_000; + assert!(fractional_part >= 0 && fractional_part < i64::from(u32::max_value())); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let fractional_part = (fractional_part as u32).to_be_bytes(); + buf[44..48].copy_from_slice(&fractional_part[..]); + + let packet = Packet::parse(buf, sntp_epoch); + #[cfg(test)] + let packet = dbg!(packet); + + // Re-extract transmit timestamp from the packet. This may not be the same as the original `transmit_timestamp` + // that was serialized into the packet due to rounding. Specifically, it's usually off by 1ns. + let transmit_timestamp = packet.transmit_timestamp; + + (buf, transmit_timestamp) +} + +fn parse_server_response( + buf: [u8; 48], + request_transmit_timestamp: chrono::DateTime, +) -> Result { + let sntp_epoch = sntp_epoch(); + + let destination_timestamp = chrono::Utc::now(); + + #[cfg(test)] + let destination_timestamp = destination_timestamp - chrono::Duration::seconds(30); // simulate unsynced local clock + + let packet = Packet::parse(buf, sntp_epoch); + #[cfg(test)] + let packet = dbg!(packet); + + match packet.leap_indicator { + 0..=2 => (), + leap_indicator => { + return Err(Error::BadServerResponse( + BadServerResponseReason::LeapIndicator(leap_indicator), + )); + } + }; + + // RFC 2030 says: + // + // >Version 4 servers are required to + // >reply in the same version as the request, so the VN field of the + // >request also specifies the version of the reply. + // + // But at least one pool.ntp.org server does not respect this and responds with VN=4 + // even though our client requests have VN=3. + // + // So allow both VN=3 and VN=4 in the server response. The response body format is identical for both anyway. + if packet.version_number != 3 && packet.version_number != 4 { + return Err(Error::BadServerResponse( + BadServerResponseReason::VersionNumber(packet.version_number), + )); + } + + if packet.mode != 4 { + return Err(Error::BadServerResponse(BadServerResponseReason::Mode( + packet.mode, + ))); + } + + if packet.originate_timestamp != request_transmit_timestamp { + return Err(Error::BadServerResponse( + BadServerResponseReason::OriginateTimestamp { + expected: request_transmit_timestamp, + actual: packet.originate_timestamp, + }, + )); + } + + Ok(SntpTimeQueryResult { + local_clock_offset: ((packet.receive_timestamp - request_transmit_timestamp) + + (packet.transmit_timestamp - destination_timestamp)) + / 2, + + round_trip_delay: (destination_timestamp - request_transmit_timestamp) + - (packet.receive_timestamp - packet.transmit_timestamp), + }) +} + +fn sntp_epoch() -> chrono::DateTime { + chrono::DateTime::::from_utc( + chrono::NaiveDate::from_ymd(1900, 1, 1).and_time(chrono::NaiveTime::from_hms(0, 0, 0)), + chrono::Utc, + ) +} + +#[derive(Debug)] +struct Packet { + leap_indicator: u8, + version_number: u8, + mode: u8, + stratum: u8, + poll_interval: u8, + precision: u8, + root_delay: u32, + root_dispersion: u32, + reference_identifier: u32, + reference_timestamp: chrono::DateTime, + originate_timestamp: chrono::DateTime, + receive_timestamp: chrono::DateTime, + transmit_timestamp: chrono::DateTime, +} + +impl Packet { + fn parse(buf: [u8; 48], sntp_epoch: chrono::DateTime) -> Self { + let leap_indicator = (buf[0] & 0b11_000_000) >> 6; + let version_number = (buf[0] & 0b00_111_000) >> 3; + let mode = buf[0] & 0b00_000_111; + let stratum = buf[1]; + let poll_interval = buf[2]; + let precision = buf[3]; + let root_delay = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]); + let root_dispersion = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]); + let reference_identifier = u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]); + let reference_timestamp = deserialize_timestamp( + [ + buf[16], buf[17], buf[18], buf[19], buf[20], buf[21], buf[22], buf[23], + ], + sntp_epoch, + ); + let originate_timestamp = deserialize_timestamp( + [ + buf[24], buf[25], buf[26], buf[27], buf[28], buf[29], buf[30], buf[31], + ], + sntp_epoch, + ); + let receive_timestamp = deserialize_timestamp( + [ + buf[32], buf[33], buf[34], buf[35], buf[36], buf[37], buf[38], buf[39], + ], + sntp_epoch, + ); + let transmit_timestamp = deserialize_timestamp( + [ + buf[40], buf[41], buf[42], buf[43], buf[44], buf[45], buf[46], buf[47], + ], + sntp_epoch, + ); + + Packet { + leap_indicator, + version_number, + mode, + stratum, + poll_interval, + precision, + root_delay, + root_dispersion, + reference_identifier, + reference_timestamp, + originate_timestamp, + receive_timestamp, + transmit_timestamp, + } + } +} + +fn deserialize_timestamp( + raw: [u8; 8], + sntp_epoch: chrono::DateTime, +) -> chrono::DateTime { + let integral_part = i64::from(u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]])); + let fractional_part = i64::from(u32::from_be_bytes([raw[4], raw[5], raw[6], raw[7]])); + let duration_since_sntp_epoch = chrono::Duration::nanoseconds( + integral_part * 1_000_000_000 + ((fractional_part * 1_000_000_000) >> 32), + ); + + sntp_epoch + duration_since_sntp_epoch +} + +#[cfg(test)] +mod tests { + use super::{query, Error, SntpTimeQueryResult}; + + #[test] + fn it_works() -> Result<(), Error> { + let SntpTimeQueryResult { + local_clock_offset, + round_trip_delay, + } = query(&("pool.ntp.org", 123))?; + + println!("local clock offset: {}", local_clock_offset); + println!("round-trip delay: {}", round_trip_delay); + + assert!( + (local_clock_offset - chrono::Duration::seconds(30)) + .num_seconds() + .abs() + < 1 + ); + assert!( + (round_trip_delay - chrono::Duration::seconds(10)) + .num_seconds() + .abs() + < 1 + ); + + Ok(()) + } +}