From 447ae1e03ea4161d7eabe0a1f76fef2e7f36b7c9 Mon Sep 17 00:00:00 2001 From: Sean Klein Date: Tue, 8 Jul 2025 12:45:22 -0700 Subject: [PATCH] Create a new NTP-admin service --- Cargo.lock | 83 +++++ Cargo.toml | 12 + clients/ntp-admin-client/Cargo.toml | 17 + clients/ntp-admin-client/src/lib.rs | 22 ++ common/src/address.rs | 4 + dev-tools/ls-apis/api-manifest.toml | 15 + dev-tools/ls-apis/tests/api_dependencies.out | 2 + dev-tools/openapi-manager/Cargo.toml | 1 + dev-tools/openapi-manager/src/omicron.rs | 10 + nexus/inventory/src/collector.rs | 6 + ntp-admin/Cargo.toml | 51 +++ ntp-admin/api/Cargo.toml | 18 ++ ntp-admin/api/src/lib.rs | 19 ++ ntp-admin/build.rs | 10 + ntp-admin/src/bin/ntp-admin.rs | 57 ++++ ntp-admin/src/config.rs | 43 +++ ntp-admin/src/context.rs | 19 ++ ntp-admin/src/http_entrypoints.rs | 316 +++++++++++++++++++ ntp-admin/src/lib.rs | 63 ++++ ntp-admin/types/Cargo.toml | 20 ++ ntp-admin/types/src/lib.rs | 30 ++ openapi/ntp-admin.json | 117 +++++++ package-manifest.toml | 15 + sled-agent/src/services.rs | 43 ++- sled-agent/types/src/time_sync.rs | 1 + smf/ntp-admin/config.toml | 10 + smf/ntp-admin/manifest.xml | 49 +++ smf/ntp-admin/method_script.sh | 17 + 28 files changed, 1069 insertions(+), 1 deletion(-) create mode 100644 clients/ntp-admin-client/Cargo.toml create mode 100644 clients/ntp-admin-client/src/lib.rs create mode 100644 ntp-admin/Cargo.toml create mode 100644 ntp-admin/api/Cargo.toml create mode 100644 ntp-admin/api/src/lib.rs create mode 100644 ntp-admin/build.rs create mode 100644 ntp-admin/src/bin/ntp-admin.rs create mode 100644 ntp-admin/src/config.rs create mode 100644 ntp-admin/src/context.rs create mode 100644 ntp-admin/src/http_entrypoints.rs create mode 100644 ntp-admin/src/lib.rs create mode 100644 ntp-admin/types/Cargo.toml create mode 100644 ntp-admin/types/src/lib.rs create mode 100644 openapi/ntp-admin.json create mode 100644 smf/ntp-admin/config.toml create mode 100644 smf/ntp-admin/manifest.xml create mode 100755 smf/ntp-admin/method_script.sh diff --git a/Cargo.lock b/Cargo.lock index 0a28ac69b4f..d108275e279 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7024,6 +7024,47 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "ntp-admin-api" +version = "0.1.0" +dependencies = [ + "dropshot", + "http", + "ntp-admin-types", + "omicron-common", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "schemars", + "serde", +] + +[[package]] +name = "ntp-admin-client" +version = "0.1.0" +dependencies = [ + "chrono", + "omicron-workspace-hack", + "progenitor 0.10.0", + "reqwest", + "schemars", + "serde", + "slog", +] + +[[package]] +name = "ntp-admin-types" +version = "0.1.0" +dependencies = [ + "chrono", + "omicron-common", + "omicron-workspace-hack", + "proptest", + "schemars", + "serde", + "test-strategy", + "thiserror 2.0.12", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -7744,6 +7785,47 @@ dependencies = [ "zip 4.2.0", ] +[[package]] +name = "omicron-ntp-admin" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "chrono", + "clap", + "dropshot", + "expectorate", + "http", + "nexus-test-utils", + "ntp-admin-api", + "ntp-admin-types", + "omicron-common", + "omicron-rpaths", + "omicron-test-utils", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "openapi-lint", + "openapiv3", + "oxide-tokio-rt", + "pq-sys", + "proptest", + "reqwest", + "schemars", + "serde", + "serde_json", + "slog", + "slog-async", + "slog-dtrace", + "slog-error-chain", + "subprocess", + "test-strategy", + "thiserror 2.0.12", + "tokio", + "tokio-postgres", + "toml 0.8.23", + "url", +] + [[package]] name = "omicron-omdb" version = "0.1.0" @@ -8422,6 +8504,7 @@ dependencies = [ "newtype_derive", "nexus-external-api", "nexus-internal-api", + "ntp-admin-api", "omicron-workspace-hack", "openapi-lint", "openapi-manager-types", diff --git a/Cargo.toml b/Cargo.toml index 61f5edb4b10..5a3c3605228 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "clients/gateway-client", "clients/installinator-client", "clients/nexus-client", + "clients/ntp-admin-client", "clients/oxide-client", "clients/oximeter-client", "clients/repo-depot-client", @@ -104,6 +105,9 @@ members = [ "nexus/test-utils-macros", "nexus/test-utils", "nexus/types", + "ntp-admin", + "ntp-admin/api", + "ntp-admin/types", "oximeter/api", "oximeter/collector", "oximeter/db", @@ -168,6 +172,7 @@ default-members = [ "clients/gateway-client", "clients/installinator-client", "clients/nexus-client", + "clients/ntp-admin-client", "clients/oxide-client", "clients/oximeter-client", "clients/repo-depot-client", @@ -258,6 +263,9 @@ default-members = [ "nexus/test-utils-macros", "nexus/test-utils", "nexus/types", + "ntp-admin", + "ntp-admin/api", + "ntp-admin/types", "oximeter/api", "oximeter/collector", "oximeter/db", @@ -511,6 +519,9 @@ lldp_protocol = { git = "https://github.com/oxidecomputer/lldp", package = "prot macaddr = { version = "1.0.1", features = ["serde_std"] } maplit = "1.0.2" newtype_derive = "0.1.6" +ntp-admin-api = { path = "ntp-admin/api" } +ntp-admin-client = { path = "clients/ntp-admin-client" } +ntp-admin-types = { path = "ntp-admin/types" } mg-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "fa5f15cdcd5864161a929e2ec01534f70dfba216" } ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "fa5f15cdcd5864161a929e2ec01534f70dfba216" } multimap = "0.10.1" @@ -556,6 +567,7 @@ omicron-common = { path = "common" } omicron-dev-lib = { path = "dev-tools/omicron-dev-lib" } omicron-gateway = { path = "gateway" } omicron-nexus = { path = "nexus" } +omicron-ntp-admin = { path = "ntp-admin" } omicron-omdb = { path = "dev-tools/omdb" } omicron-package = { path = "package" } omicron-passwords = { path = "passwords" } diff --git a/clients/ntp-admin-client/Cargo.toml b/clients/ntp-admin-client/Cargo.toml new file mode 100644 index 00000000000..9c2ebe1e21c --- /dev/null +++ b/clients/ntp-admin-client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ntp-admin-client" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +progenitor.workspace = true +reqwest = { workspace = true, features = [ "json", "rustls-tls", "stream" ] } +schemars.workspace = true +serde.workspace = true +slog.workspace = true +omicron-workspace-hack.workspace = true diff --git a/clients/ntp-admin-client/src/lib.rs b/clients/ntp-admin-client/src/lib.rs new file mode 100644 index 00000000000..9a2c028c514 --- /dev/null +++ b/clients/ntp-admin-client/src/lib.rs @@ -0,0 +1,22 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Interface for making API requests to an Omicron NTP admin server + +progenitor::generate_api!( + spec = "../../openapi/ntp-admin.json", + interface = Positional, + inner_type = slog::Logger, + pre_hook = (|log: &slog::Logger, request: &reqwest::Request| { + slog::debug!(log, "client request"; + "method" => %request.method(), + "uri" => %request.url(), + "body" => ?&request.body(), + ); + }), + post_hook = (|log: &slog::Logger, result: &Result<_, _>| { + slog::debug!(log, "client response"; "result" => ?result); + }), + derives = [schemars::JsonSchema], +); diff --git a/common/src/address.rs b/common/src/address.rs index 9a9623fe27d..92863c44a42 100644 --- a/common/src/address.rs +++ b/common/src/address.rs @@ -64,8 +64,12 @@ pub const NEXUS_TECHPORT_EXTERNAL_PORT: u16 = 12228; /// interface(s). pub const WICKETD_NEXUS_PROXY_PORT: u16 = 12229; +/// The port on which NTP runs pub const NTP_PORT: u16 = 123; +/// The port on which the NTP admin service exposes an HTTP interface +pub const NTP_ADMIN_PORT: u16 = 10123; + /// The length for all VPC IPv6 prefixes pub const VPC_IPV6_PREFIX_LENGTH: u8 = 48; diff --git a/dev-tools/ls-apis/api-manifest.toml b/dev-tools/ls-apis/api-manifest.toml index a0ce4a8455b..13a986bfda8 100644 --- a/dev-tools/ls-apis/api-manifest.toml +++ b/dev-tools/ls-apis/api-manifest.toml @@ -114,6 +114,10 @@ packages = [ "dns-server" ] label = "Nexus" packages = [ "omicron-nexus" ] +[[deployment_units]] +label = "NTP" +packages = [ "omicron-ntp-admin" ] + [[deployment_units]] label = "Oximeter" packages = [ "oximeter-collector" ] @@ -212,6 +216,17 @@ This is the server running inside CockroachDB zones that performs \ configuration and monitoring that requires the `cockroach` CLI. """ +[[apis]] +client_package_name = "ntp-admin-client" +label = "NTP Admin" +server_package_name = "ntp-admin-api" +versioned_how = "server" +notes = """ +This is the server running inside NTP zones that performs \ +monitoring on 'chrony'. +""" + + [[apis]] client_package_name = "crucible-agent-client" label = "Crucible Agent" diff --git a/dev-tools/ls-apis/tests/api_dependencies.out b/dev-tools/ls-apis/tests/api_dependencies.out index 4fcc59bb423..999aedff621 100644 --- a/dev-tools/ls-apis/tests/api_dependencies.out +++ b/dev-tools/ls-apis/tests/api_dependencies.out @@ -70,6 +70,8 @@ Nexus Internal API (client: nexus-client) consumed by: oximeter-collector (omicron/oximeter/collector) via 1 path consumed by: propolis-server (propolis/bin/propolis-server) via 3 paths +NTP Admin (client: ntp-admin-client) + External API (client: oxide-client) Oximeter (client: oximeter-client) diff --git a/dev-tools/openapi-manager/Cargo.toml b/dev-tools/openapi-manager/Cargo.toml index fc9048412fc..b7b6cf51f1f 100644 --- a/dev-tools/openapi-manager/Cargo.toml +++ b/dev-tools/openapi-manager/Cargo.toml @@ -27,6 +27,7 @@ itertools.workspace = true nexus-external-api.workspace = true nexus-internal-api.workspace = true newtype_derive.workspace = true +ntp-admin-api.workspace = true omicron-workspace-hack.workspace = true openapi-lint.workspace = true openapi-manager-types.workspace = true diff --git a/dev-tools/openapi-manager/src/omicron.rs b/dev-tools/openapi-manager/src/omicron.rs index 35e832b41a9..8b446099b71 100644 --- a/dev-tools/openapi-manager/src/omicron.rs +++ b/dev-tools/openapi-manager/src/omicron.rs @@ -13,6 +13,7 @@ use gateway_api::gateway_api_mod; use installinator_api::installinator_api_mod; use nexus_external_api::nexus_external_api_mod; use nexus_internal_api::nexus_internal_api_mod; +use ntp_admin_api::ntp_admin_api_mod; use oximeter_api::oximeter_api_mod; use repo_depot_api::repo_depot_api_mod; use sled_agent_api::sled_agent_api_mod; @@ -127,6 +128,15 @@ pub fn all_apis() -> Vec { ident: "nexus-internal", extra_validation: None, }, + ManagedApiConfig { + title: "NTP Admin API", + versions: Versions::new_lockstep(semver::Version::new(0, 0, 1)), + description: "API for interacting with NTP", + boundary: ApiBoundary::Internal, + api_description: ntp_admin_api_mod::stub_api_description, + ident: "ntp-admin", + extra_validation: None, + }, ManagedApiConfig { title: "Oxide Oximeter API", versions: Versions::new_lockstep(semver::Version::new(0, 0, 1)), diff --git a/nexus/inventory/src/collector.rs b/nexus/inventory/src/collector.rs index 9c3d4e1fcaa..eac99c6d042 100644 --- a/nexus/inventory/src/collector.rs +++ b/nexus/inventory/src/collector.rs @@ -76,6 +76,12 @@ impl<'a> Collector<'a> { self.collect_all_keepers().await; self.collect_all_cockroach().await; + // TODO(https://github.com/oxidecomputer/omicron/issues/8546): Collect + // NTP timesync statuses + + // TODO(https://github.com/oxidecomputer/omicron/issues/8544): Collect + // DNS generations + debug!(&self.log, "finished collection"); Ok(self.in_progress.build()) diff --git a/ntp-admin/Cargo.toml b/ntp-admin/Cargo.toml new file mode 100644 index 00000000000..5a6df680781 --- /dev/null +++ b/ntp-admin/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "omicron-ntp-admin" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[build-dependencies] +omicron-rpaths.workspace = true + +[dependencies] +anyhow.workspace = true +camino.workspace = true +chrono.workspace = true +clap.workspace = true +dropshot.workspace = true +http.workspace = true +ntp-admin-api.workspace = true +ntp-admin-types.workspace = true +omicron-common.workspace = true +omicron-uuid-kinds.workspace = true +oxide-tokio-rt.workspace = true +# See omicron-rpaths for more about the "pq-sys" dependency. +pq-sys = "*" +reqwest.workspace = true +schemars.workspace = true +slog.workspace = true +slog-async.workspace = true +slog-dtrace.workspace = true +slog-error-chain.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-postgres.workspace = true +toml.workspace = true + +omicron-workspace-hack.workspace = true + +[dev-dependencies] +expectorate.workspace = true +nexus-test-utils.workspace = true +omicron-test-utils.workspace = true +openapi-lint.workspace = true +openapiv3.workspace = true +proptest.workspace = true +serde_json.workspace = true +subprocess.workspace = true +test-strategy.workspace = true +url.workspace = true + +[lints] +workspace = true diff --git a/ntp-admin/api/Cargo.toml b/ntp-admin/api/Cargo.toml new file mode 100644 index 00000000000..3ef5615c641 --- /dev/null +++ b/ntp-admin/api/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ntp-admin-api" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[lints] +workspace = true + +[dependencies] +dropshot.workspace = true +http.workspace = true +ntp-admin-types.workspace = true +omicron-common.workspace = true +omicron-uuid-kinds.workspace = true +omicron-workspace-hack.workspace = true +schemars.workspace = true +serde.workspace = true diff --git a/ntp-admin/api/src/lib.rs b/ntp-admin/api/src/lib.rs new file mode 100644 index 00000000000..07c5867339d --- /dev/null +++ b/ntp-admin/api/src/lib.rs @@ -0,0 +1,19 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use dropshot::{HttpError, HttpResponseOk, RequestContext}; + +#[dropshot::api_description] +pub trait NtpAdminApi { + type Context; + + /// Query for the state of time synchronization + #[endpoint { + method = GET, + path = "/timesync", + }] + async fn timesync( + rqctx: RequestContext, + ) -> Result, HttpError>; +} diff --git a/ntp-admin/build.rs b/ntp-admin/build.rs new file mode 100644 index 00000000000..1ba9acd41c9 --- /dev/null +++ b/ntp-admin/build.rs @@ -0,0 +1,10 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// See omicron-rpaths for documentation. +// NOTE: This file MUST be kept in sync with the other build.rs files in this +// repository. +fn main() { + omicron_rpaths::configure_default_omicron_rpaths(); +} diff --git a/ntp-admin/src/bin/ntp-admin.rs b/ntp-admin/src/bin/ntp-admin.rs new file mode 100644 index 00000000000..193202e9142 --- /dev/null +++ b/ntp-admin/src/bin/ntp-admin.rs @@ -0,0 +1,57 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Executable program to run the Omicron NTP admin interface + +use anyhow::anyhow; +use camino::Utf8PathBuf; +use clap::Parser; +use omicron_common::cmd::CmdError; +use omicron_common::cmd::fatal; +use omicron_ntp_admin::Config; +use std::net::SocketAddr; +use std::net::SocketAddrV6; + +#[derive(Debug, Parser)] +#[clap(name = "ntp-admin", about = "Omicron NTP admin server")] +enum Args { + /// Start the NTP admin server + Run { + /// Address on which this server should run + #[clap(long, action)] + address: SocketAddrV6, + + /// Path to the server config file + #[clap(long, action)] + config_file_path: Utf8PathBuf, + }, +} + +fn main() { + let mut builder = oxide_tokio_rt::Builder::new_multi_thread(); + builder.worker_threads(8); + if let Err(err) = oxide_tokio_rt::run_builder(&mut builder, main_impl()) { + fatal(err); + } +} + +async fn main_impl() -> Result<(), CmdError> { + let args = Args::parse(); + + match args { + Args::Run { address, config_file_path } => { + let mut config = Config::from_file(&config_file_path) + .map_err(|err| CmdError::Failure(anyhow!(err)))?; + config.dropshot.bind_address = SocketAddr::V6(address); + let server = omicron_ntp_admin::start_server(config) + .await + .map_err(|err| CmdError::Failure(anyhow!(err)))?; + server.await.map_err(|err| { + CmdError::Failure(anyhow!( + "server failed after starting: {err}" + )) + }) + } + } +} diff --git a/ntp-admin/src/config.rs b/ntp-admin/src/config.rs new file mode 100644 index 00000000000..77a624835c5 --- /dev/null +++ b/ntp-admin/src/config.rs @@ -0,0 +1,43 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use camino::Utf8Path; +use camino::Utf8PathBuf; +use dropshot::ConfigDropshot; +use dropshot::ConfigLogging; +use serde::Deserialize; +use serde::Serialize; +use slog_error_chain::SlogInlineError; +use std::io; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Config { + pub dropshot: ConfigDropshot, + pub log: ConfigLogging, +} +impl Config { + /// Load a `Config` from the given TOML file + pub fn from_file(path: &Utf8Path) -> Result { + let contents = std::fs::read_to_string(path) + .map_err(|err| LoadError::Read { path: path.to_owned(), err })?; + toml::de::from_str(&contents) + .map_err(|err| LoadError::Parse { path: path.to_owned(), err }) + } +} + +#[derive(Debug, thiserror::Error, SlogInlineError)] +pub enum LoadError { + #[error("failed to read {path}")] + Read { + path: Utf8PathBuf, + #[source] + err: io::Error, + }, + #[error("failed to parse {path} as TOML")] + Parse { + path: Utf8PathBuf, + #[source] + err: toml::de::Error, + }, +} diff --git a/ntp-admin/src/context.rs b/ntp-admin/src/context.rs new file mode 100644 index 00000000000..83a3aefd434 --- /dev/null +++ b/ntp-admin/src/context.rs @@ -0,0 +1,19 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use slog::Logger; + +pub struct ServerContext { + log: Logger, +} + +impl ServerContext { + pub fn new(log: Logger) -> Self { + Self { log } + } + + pub fn log(&self) -> &Logger { + &self.log + } +} diff --git a/ntp-admin/src/http_entrypoints.rs b/ntp-admin/src/http_entrypoints.rs new file mode 100644 index 00000000000..2add00f3161 --- /dev/null +++ b/ntp-admin/src/http_entrypoints.rs @@ -0,0 +1,316 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use crate::context::ServerContext; +use dropshot::HttpError; +use dropshot::HttpResponseOk; +use dropshot::RequestContext; +use ntp_admin_api::*; +use ntp_admin_types::TimeSync; +use slog::error; +use slog::info; +use slog_error_chain::InlineErrorChain; +use std::net::IpAddr; +use std::net::Ipv6Addr; +use std::str::FromStr; +use std::sync::Arc; + +type NtpApiDescription = dropshot::ApiDescription>; + +pub fn api() -> NtpApiDescription { + ntp_admin_api_mod::api_description::() + .expect("registered entrypoints") +} + +#[derive(Debug, thiserror::Error)] +pub enum TimeSyncError { + #[error("failed to execute chronyc within NTP zone")] + ExecuteChronyc(#[source] std::io::Error), + #[error( + "failed to parse chronyc tracking output: {reason} (stdout: {stdout:?})" + )] + FailedToParse { reason: &'static str, stdout: String }, +} + +impl From for HttpError { + fn from(err: TimeSyncError) -> Self { + // All errors are currently treated as 500s + HttpError::for_internal_error(InlineErrorChain::new(&err).to_string()) + } +} + +const TIMESYNC_STRATUM_MAX: u8 = 9; +const TIMESYNC_REFTIME_MIN: f64 = 1234567890.0; +const TIMESYNC_CORRECTION_MAX: f64 = 0.05; +const TIMESYNC_REF_IDS_NO_PEER_SYNC: [u32; 2] = [0, 0x7f7f0101]; + +fn parse_timesync_result(stdout: &str) -> Result { + let v: Vec<&str> = stdout.split(',').collect(); + + if v.len() < 10 { + return Err(TimeSyncError::FailedToParse { + reason: "too few fields", + stdout: stdout.to_string(), + }); + } + + let Ok(ref_id) = u32::from_str_radix(v[0], 16) else { + return Err(TimeSyncError::FailedToParse { + reason: "bad ref_id", + stdout: stdout.to_string(), + }); + }; + let ip_addr = + IpAddr::from_str(v[1]).unwrap_or(Ipv6Addr::UNSPECIFIED.into()); + let Ok(stratum) = u8::from_str(v[2]) else { + return Err(TimeSyncError::FailedToParse { + reason: "bad stratum", + stdout: stdout.to_string(), + }); + }; + let Ok(ref_time) = f64::from_str(v[3]) else { + return Err(TimeSyncError::FailedToParse { + reason: "bad ref_time", + stdout: stdout.to_string(), + }); + }; + let Ok(correction) = f64::from_str(v[4]) else { + return Err(TimeSyncError::FailedToParse { + reason: "bad correction", + stdout: stdout.to_string(), + }); + }; + + // Per `chronyc waitsync`'s implementation, if either the + // reference IP address is not unspecified or the reference + // ID is not 0 or 0x7f7f0101, we are synchronized to a peer. + let peer_sync = !ip_addr.is_unspecified() + || !TIMESYNC_REF_IDS_NO_PEER_SYNC.contains(&ref_id); + + let sync = stratum <= TIMESYNC_STRATUM_MAX + && TIMESYNC_REFTIME_MIN < ref_time + && peer_sync + && correction.abs() <= TIMESYNC_CORRECTION_MAX; + + Ok(TimeSync { sync, ref_id, ip_addr, stratum, ref_time, correction }) +} + +enum NtpAdminImpl {} + +impl NtpAdminImpl { + async fn timesync_get( + ctx: &ServerContext, + ) -> Result { + let log = ctx.log(); + info!(log, "querying chronyc"); + + let output = tokio::process::Command::new("/usr/bin/chronyc") + .args(["-c", "tracking"]) + .output() + .await + .map_err(TimeSyncError::ExecuteChronyc)?; + let stdout = String::from_utf8_lossy(&output.stdout); + let result = parse_timesync_result(&stdout); + info!(log, "parse_timesync_result"; "result" => ?result); + result + } +} + +impl NtpAdminApi for NtpAdminImpl { + type Context = Arc; + + async fn timesync( + rqctx: RequestContext, + ) -> Result, HttpError> { + let ctx = rqctx.context(); + let response = Self::timesync_get(ctx).await?; + Ok(HttpResponseOk(response)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn test_parse_timesync_result_success() { + let input = "C0A80001,192.168.0.1,2,1234567891.123456,0.001,x,x,x,x,x"; + + let result = parse_timesync_result(input).unwrap(); + assert_eq!(result.ref_id, 0xC0A80001); + assert_eq!(result.ip_addr, IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))); + assert_eq!(result.stratum, 2); + assert_eq!(result.ref_time, 1234567891.123456); + assert_eq!(result.correction, 0.001); + assert!(result.sync); + } + + #[test] + fn test_parse_timesync_result_not_synced_high_stratum() { + let stratum = TIMESYNC_STRATUM_MAX + 1; + let input = &format!( + "C0A80001,192.168.0.1,{stratum},1234567891.123456,0.001,x,x,x,x,x" + ); + let result = parse_timesync_result(input).unwrap(); + assert!(!result.sync); + } + + #[test] + fn test_parse_timesync_result_not_synced_old_ref_time() { + let ref_time = TIMESYNC_REFTIME_MIN - 0.01; + let input = &format!( + "C0A80001,192.168.0.1,2,{ref_time},0.001,0.000001,x,x,x,x,x" + ); + + let result = parse_timesync_result(input).unwrap(); + assert!(!result.sync); + } + + #[test] + fn test_parse_timesync_result_boundary_correction() { + let input = "C0A80001,192.168.0.1,2,1234567891.123456,0.05,x,x,x,x,x"; + + let result = parse_timesync_result(input).unwrap(); + assert_eq!(result.correction, 0.05); + assert!(result.sync); + } + + #[test] + fn test_parse_timesync_result_not_synced_high_correction() { + let correction = TIMESYNC_CORRECTION_MAX + 0.01; + let input = &format!( + "C0A80001,192.168.0.1,2,1234567891.123456,{correction},x,x,x,x,x" + ); + + let result = parse_timesync_result(input).unwrap(); + assert!(!result.sync); + } + + #[test] + fn test_parse_timesync_result_negative_correction() { + let input = "C0A80001,192.168.0.1,2,1234567891.123456,-0.01,x,x,x,x,x"; + + let result = parse_timesync_result(input).unwrap(); + assert_eq!(result.correction, -0.01); + assert!(result.sync); + } + + #[test] + fn test_parse_timesync_result_not_synced_no_peer() { + let input = "0,::/0,2,1234567891.123456,0.001,x,x,x,x,x"; + + let result = parse_timesync_result(input).unwrap(); + assert!(!result.sync); + } + + #[test] + fn test_parse_timesync_result_special_ref_id() { + let input = "0,::/0,2,1234567891.123456,0.001,x,x,x,x,x"; + let result = parse_timesync_result(input).unwrap(); + assert_eq!(result.ref_id, TIMESYNC_REF_IDS_NO_PEER_SYNC[0]); + assert!(!result.sync); + + let input = "7f7f0101,::/0,2,1234567891.123456,0.001,x,x,x,x,x"; + let result = parse_timesync_result(input).unwrap(); + assert_eq!(result.ref_id, TIMESYNC_REF_IDS_NO_PEER_SYNC[1]); + assert!(!result.sync); + + let input = "7f7f0102,192.168.0.1,2,1234567891.123456,0.001,x,x,x,x,x"; + let result = parse_timesync_result(input).unwrap(); + assert_eq!(result.ref_id, 0x7f7f0102); + assert!(result.sync); + } + + #[test] + fn test_parse_timesync_result_ipv6_address() { + let input = "C0A80001,2001:db8::1,2,1234567891.123456,0.001,x,x,x,x,x"; + + let result = parse_timesync_result(input).unwrap(); + assert_eq!(result.ip_addr, "2001:db8::1".parse::().unwrap()); + assert!(result.sync); + } + + #[test] + fn test_parse_timesync_result_invalid_ip_fallback() { + let input = "C0A80001,invalid_ip,2,1234567891.123456,0.001,x,x,x,x,x"; + + let result = parse_timesync_result(input).unwrap(); + assert_eq!(result.ip_addr, IpAddr::V6(Ipv6Addr::UNSPECIFIED)); + // Still syncs if ref_id is not in TIMESYNC_REF_IDS_NO_PEER_SYNC + assert!(result.sync); + } + + #[test] + fn test_parse_timesync_result_too_few_fields() { + let input = "C0A80001,192.168.0.1,2,1234567891.123456"; + + let result = parse_timesync_result(input); + assert!(result.is_err()); + match result.unwrap_err() { + TimeSyncError::FailedToParse { reason, .. } => { + assert_eq!(reason, "too few fields"); + } + _ => panic!("Expected FailedToParse error"), + } + } + + #[test] + fn test_parse_timesync_result_invalid_ref_id() { + let input = "INVALID,192.168.0.1,2,1234567891.123456,0.001,x,x,x,x,x"; + + let result = parse_timesync_result(input); + assert!(result.is_err()); + match result.unwrap_err() { + TimeSyncError::FailedToParse { reason, .. } => { + assert_eq!(reason, "bad ref_id"); + } + _ => panic!("Expected FailedToParse error"), + } + } + + #[test] + fn test_parse_timesync_result_invalid_stratum() { + let input = + "C0A80001,192.168.0.1,invalid,1234567891.123456,0.001,x,x,x,x,x"; + + let result = parse_timesync_result(input); + assert!(result.is_err()); + match result.unwrap_err() { + TimeSyncError::FailedToParse { reason, .. } => { + assert_eq!(reason, "bad stratum"); + } + _ => panic!("Expected FailedToParse error"), + } + } + + #[test] + fn test_parse_timesync_result_invalid_ref_time() { + let input = "C0A80001,192.168.0.1,2,invalid,0.001,x,x,x,x,x"; + + let result = parse_timesync_result(input); + assert!(result.is_err()); + match result.unwrap_err() { + TimeSyncError::FailedToParse { reason, .. } => { + assert_eq!(reason, "bad ref_time"); + } + _ => panic!("Expected FailedToParse error"), + } + } + + #[test] + fn test_parse_timesync_result_invalid_correction() { + let input = + "C0A80001,192.168.0.1,2,1234567891.123456,invalid,x,x,x,x,x"; + + let result = parse_timesync_result(input); + assert!(result.is_err()); + match result.unwrap_err() { + TimeSyncError::FailedToParse { reason, .. } => { + assert_eq!(reason, "bad correction"); + } + _ => panic!("Expected FailedToParse error"), + } + } +} diff --git a/ntp-admin/src/lib.rs b/ntp-admin/src/lib.rs new file mode 100644 index 00000000000..5d79987bf6a --- /dev/null +++ b/ntp-admin/src/lib.rs @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use context::ServerContext; +use omicron_common::FileKv; +use slog::Drain; +use slog::debug; +use slog::error; +use slog_dtrace::ProbeRegistration; +use slog_error_chain::SlogInlineError; +use std::io; +use std::sync::Arc; + +mod config; +mod context; +mod http_entrypoints; + +pub use config::Config; + +#[derive(Debug, thiserror::Error, SlogInlineError)] +pub enum StartError { + #[error("failed to initialize logger")] + InitializeLogger(#[source] io::Error), + #[error("failed to register dtrace probes: {0}")] + RegisterDtraceProbes(String), + #[error("failed to initialize HTTP server")] + InitializeHttpServer(#[source] dropshot::BuildError), +} + +pub type Server = dropshot::HttpServer>; + +/// Start the dropshot server +pub async fn start_server(server_config: Config) -> Result { + let (drain, registration) = slog_dtrace::with_drain( + server_config + .log + .to_logger("ntp-admin") + .map_err(StartError::InitializeLogger)?, + ); + let log = slog::Logger::root(drain.fuse(), slog::o!(FileKv)); + match registration { + ProbeRegistration::Success => { + debug!(log, "registered DTrace probes"); + } + ProbeRegistration::Failed(err) => { + let err = StartError::RegisterDtraceProbes(err); + error!(log, "failed to register DTrace probes"; &err); + return Err(err); + } + } + + let context = + ServerContext::new(log.new(slog::o!("component" => "ServerContext"))); + dropshot::ServerBuilder::new( + http_entrypoints::api(), + Arc::new(context), + log.new(slog::o!("component" => "dropshot")), + ) + .config(server_config.dropshot) + .start() + .map_err(StartError::InitializeHttpServer) +} diff --git a/ntp-admin/types/Cargo.toml b/ntp-admin/types/Cargo.toml new file mode 100644 index 00000000000..c60677e5107 --- /dev/null +++ b/ntp-admin/types/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ntp-admin-types" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +omicron-common.workspace = true +omicron-workspace-hack.workspace = true +schemars.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true +test-strategy.workspace = true diff --git a/ntp-admin/types/src/lib.rs b/ntp-admin/types/src/lib.rs new file mode 100644 index 00000000000..7ac9ded6361 --- /dev/null +++ b/ntp-admin/types/src/lib.rs @@ -0,0 +1,30 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::net::IpAddr; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct TimeSync { + /// The synchronization state of the sled, true when the system clock + /// and the NTP clock are in sync (to within a small window). + pub sync: bool, + /// The NTP reference ID. + pub ref_id: u32, + /// The NTP reference IP address. + pub ip_addr: IpAddr, + /// The NTP stratum (our upstream's stratum plus one). + pub stratum: u8, + /// The NTP reference time (i.e. what chrony thinks the current time is, not + /// necessarily the current system time). + pub ref_time: f64, + // This could be f32, but there is a problem with progenitor/typify + // where, although the f32 correctly becomes "float" (and not "double") in + // the API spec, that "float" gets converted back to f64 when generating + // the client. + /// The current offset between the NTP clock and system clock. + pub correction: f64, +} diff --git a/openapi/ntp-admin.json b/openapi/ntp-admin.json new file mode 100644 index 00000000000..eae66021e91 --- /dev/null +++ b/openapi/ntp-admin.json @@ -0,0 +1,117 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "NTP Admin API", + "description": "API for interacting with NTP", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "0.0.1" + }, + "paths": { + "/timesync": { + "get": { + "summary": "Query for the state of time synchronization", + "operationId": "timesync", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimeSync" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "TimeSync": { + "type": "object", + "properties": { + "correction": { + "description": "The current offset between the NTP clock and system clock.", + "type": "number", + "format": "double" + }, + "ip_addr": { + "description": "The NTP reference IP address.", + "type": "string", + "format": "ip" + }, + "ref_id": { + "description": "The NTP reference ID.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "ref_time": { + "description": "The NTP reference time (i.e. what chrony thinks the current time is, not necessarily the current system time).", + "type": "number", + "format": "double" + }, + "stratum": { + "description": "The NTP stratum (our upstream's stratum plus one).", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "sync": { + "description": "The synchronization state of the sled, true when the system clock and the NTP clock are in sync (to within a small window).", + "type": "boolean" + } + }, + "required": [ + "correction", + "ip_addr", + "ref_id", + "ref_time", + "stratum", + "sync" + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/package-manifest.toml b/package-manifest.toml index c9fe15ddad0..d45e0c599ee 100644 --- a/package-manifest.toml +++ b/package-manifest.toml @@ -371,6 +371,7 @@ source.type = "composite" source.packages = [ "chrony-setup.tar.gz", "ntp-svc.tar.gz", + "omicron-ntp-admin.tar.gz", "opte-interface-setup.tar.gz", "zone-setup.tar.gz", "zone-network-install.tar.gz" @@ -387,6 +388,20 @@ source.paths = [ output.intermediate_only = true output.type = "zone" +[package.omicron-ntp-admin] +service_name = "ntp-admin" +only_for_targets.image = "standard" +source.type = "local" +source.rust.binary_names = ["ntp-admin"] +source.rust.release = true +source.paths = [ + { from = "smf/ntp-admin/manifest.xml", to = "/var/svc/manifest/site/ntp-admin/manifest.xml" }, + { from = "smf/ntp-admin/config.toml", to = "/opt/oxide/lib/svc/ntp-admin/config.toml" }, + { from = "smf/ntp-admin/method_script.sh", to = "/opt/oxide/lib/svc/manifest/ntp-admin.sh" }, +] +output.type = "zone" +output.intermediate_only = true + [package.chrony-setup] service_name = "chrony-setup" only_for_targets.image = "standard" diff --git a/sled-agent/src/services.rs b/sled-agent/src/services.rs index a721c9e07f0..f1d458d88de 100644 --- a/sled-agent/src/services.rs +++ b/sled-agent/src/services.rs @@ -70,6 +70,7 @@ use omicron_common::address::AZ_PREFIX; use omicron_common::address::DENDRITE_PORT; use omicron_common::address::LLDP_PORT; use omicron_common::address::MGS_PORT; +use omicron_common::address::NTP_ADMIN_PORT; use omicron_common::address::RACK_PREFIX; use omicron_common::address::SLED_PREFIX; use omicron_common::address::TFPORTD_PORT; @@ -2009,6 +2010,25 @@ impl ServiceManager { let ntp_service = ServiceBuilder::new("oxide/ntp") .add_instance(ServiceInstanceBuilder::new("default")); + // We shouldn't need to hardcode a port here: + // https://github.com/oxidecomputer/omicron/issues/6796 + let ntp_admin_address = { + let mut address = *address; + address.set_port(NTP_ADMIN_PORT); + address + }; + let ntp_admin_config = PropertyGroupBuilder::new("config") + .add_property( + "address", + "astring", + ntp_admin_address.to_string(), + ); + let ntp_admin_service = ServiceBuilder::new("oxide/ntp-admin") + .add_instance( + ServiceInstanceBuilder::new("default") + .add_property_group(ntp_admin_config), + ); + let chrony_setup_service = ServiceBuilder::new("oxide/chrony-setup").add_instance( ServiceInstanceBuilder::new("default") @@ -2025,6 +2045,7 @@ impl ServiceManager { .add_service(dns_install_service) .add_service(dns_client_service) .add_service(ntp_service) + .add_service(ntp_admin_service) .add_service(opte_interface_setup); profile @@ -2070,6 +2091,25 @@ impl ServiceManager { let ntp_service = ServiceBuilder::new("oxide/ntp") .add_instance(ServiceInstanceBuilder::new("default")); + // We shouldn't need to hardcode a port here: + // https://github.com/oxidecomputer/omicron/issues/6796 + let ntp_admin_address = { + let mut address = *address; + address.set_port(NTP_ADMIN_PORT); + address + }; + let ntp_admin_config = PropertyGroupBuilder::new("config") + .add_property( + "address", + "astring", + ntp_admin_address.to_string(), + ); + let ntp_admin_service = ServiceBuilder::new("oxide/ntp-admin") + .add_instance( + ServiceInstanceBuilder::new("default") + .add_property_group(ntp_admin_config), + ); + let chrony_setup_service = ServiceBuilder::new("oxide/chrony-setup").add_instance( ServiceInstanceBuilder::new("default") @@ -2082,7 +2122,8 @@ impl ServiceManager { .add_service(disabled_ssh_service) .add_service(dns_install_service) .add_service(enabled_dns_client_service) - .add_service(ntp_service); + .add_service(ntp_service) + .add_service(ntp_admin_service); profile .add_to_zone(&self.inner.log, &installed_zone) diff --git a/sled-agent/types/src/time_sync.rs b/sled-agent/types/src/time_sync.rs index 7ac9ded6361..1a26d63e6c9 100644 --- a/sled-agent/types/src/time_sync.rs +++ b/sled-agent/types/src/time_sync.rs @@ -7,6 +7,7 @@ use std::net::IpAddr; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +// TODO: Remove me, in favor of ntp-admin/types/src/lib.rs #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] pub struct TimeSync { /// The synchronization state of the sled, true when the system clock diff --git a/smf/ntp-admin/config.toml b/smf/ntp-admin/config.toml new file mode 100644 index 00000000000..7076f1edaa5 --- /dev/null +++ b/smf/ntp-admin/config.toml @@ -0,0 +1,10 @@ +[dropshot] +# 1 MiB; we don't expect any requests of more than nominal size. +default_request_body_max_bytes = 1048576 + +[log] +# Show log messages of this level and more severe +level = "info" +mode = "file" +path = "/dev/stdout" +if_exists = "append" diff --git a/smf/ntp-admin/manifest.xml b/smf/ntp-admin/manifest.xml new file mode 100644 index 00000000000..fdb97923e10 --- /dev/null +++ b/smf/ntp-admin/manifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/smf/ntp-admin/method_script.sh b/smf/ntp-admin/method_script.sh new file mode 100755 index 00000000000..b8c1aed685c --- /dev/null +++ b/smf/ntp-admin/method_script.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -x +set -o errexit +set -o pipefail + +. /lib/svc/share/smf_include.sh + +ADDR="$(svcprop -c -p config/address "${SMF_FMRI}")" + +args=( + 'run' + '--config-file-path' "/opt/oxide/lib/svc/ntp-admin/config.toml" + '--address' "$ADDR" +) + +exec /opt/oxide/ntp-admin/bin/ntp-admin "${args[@]}" &