diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53256947..c75ec25f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,6 +253,14 @@ jobs: toolchain: ${{ matrix.rust }} cache: false + # Install the `set_listen_pid` helper. + - name: Install `set_listen_pid` + run: cargo install --path crates/tests --bin set_listen_pid + + # Install `dnst` for `dnst keyset`. + - name: Install `dnst` + run: cargo install --locked --bin dnst --git https://github.com/nlnetlabs/dnst dnst + # TODO: Restore a cache of dependencies and 'target'. # Build and run the test suite. diff --git a/Cargo.lock b/Cargo.lock index 50e5ff2a..754b4b7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -264,6 +264,24 @@ dependencies = [ "toml", ] +[[package]] +name = "cascade-tests" +version = "0.1.0-beta6-dev" +dependencies = [ + "camino", + "cascade-api", + "cascade-cfg", + "cascade-policy-file", + "command-fds", + "nix", + "reqwest", + "serde", + "tempfile", + "toml", + "tracing", + "tracing-subscriber", +] + [[package]] name = "cascade-zonedata" version = "0.1.0-beta7-dev" @@ -284,6 +302,7 @@ dependencies = [ "cascade-api", "cascade-cfg", "cascade-policy-file", + "cascade-tests", "cascade-zonedata", "clap", "daemonbase", @@ -402,6 +421,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "command-fds" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b60b5124979fccd9addd89d8b97a1d6eebb4950694520c75ddd722535ea443f" +dependencies = [ + "nix", + "thiserror", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -701,6 +730,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -709,6 +739,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.34" @@ -739,8 +775,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1532,7 +1571,9 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", diff --git a/Cargo.toml b/Cargo.toml index 87ad1644..3bc0138d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ members = [ "crates/cfg", "crates/policy-file", "crates/zonedata", + + "crates/tests", ] default-members = [".", "crates/cli"] @@ -258,6 +260,7 @@ workspace = true [dev-dependencies] assert-json-diff = "2.0" +cascade-tests = { path = "crates/tests" } # --- Packaging ---------------------------------------------------------------- diff --git a/crates/api/src/dep.rs b/crates/api/src/dep.rs index 2ef3046f..7820d621 100644 --- a/crates/api/src/dep.rs +++ b/crates/api/src/dep.rs @@ -1,3 +1,4 @@ //! Re-export dependencies for API consumers +pub use domain; pub use serde; diff --git a/crates/cfg/Cargo.toml b/crates/cfg/Cargo.toml index c125f83b..b8e9ee03 100644 --- a/crates/cfg/Cargo.toml +++ b/crates/cfg/Cargo.toml @@ -15,6 +15,11 @@ edition.workspace = true readme = "README.md" +[features] +default = ["args"] +args = ["dep:clap"] + + # --- Dependencies ------------------------------------------------------------- [dependencies] @@ -28,6 +33,7 @@ features = ["serde1"] # 'clap' is used for defining and parsing command-line arguments. [dependencies.clap] +optional = true workspace = true default-features = false diff --git a/crates/cfg/src/file/mod.rs b/crates/cfg/src/file/mod.rs index 6e0821f9..7d27f9ef 100644 --- a/crates/cfg/src/file/mod.rs +++ b/crates/cfg/src/file/mod.rs @@ -1,9 +1,9 @@ //! The configuration file. -use std::{fmt, sync::Arc}; +use std::{fmt, io, sync::Arc}; use camino::Utf8Path; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use super::Config; @@ -12,7 +12,7 @@ pub mod v1; //----------- Spec ------------------------------------------------------------- /// A configuration file. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", tag = "version")] pub enum Spec { /// The version 1 format. @@ -36,6 +36,11 @@ impl Spec { let text = std::fs::read_to_string(path)?; Ok(toml::from_str(&text)?) } + + /// Save the configuration file, formatting in a compact notation. + pub fn save_compact(&self, path: &Utf8Path) -> io::Result<()> { + std::fs::write(path, toml::to_string(self).unwrap()) + } } //----------- FileError -------------------------------------------------------- diff --git a/crates/cfg/src/file/v1.rs b/crates/cfg/src/file/v1.rs index b9afd7a9..b310ebc2 100644 --- a/crates/cfg/src/file/v1.rs +++ b/crates/cfg/src/file/v1.rs @@ -3,7 +3,7 @@ use std::{fmt, net::SocketAddr, num::IntErrorKind, str::FromStr}; use camino::Utf8Path; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::{ Config, DaemonConfig, GroupId, KeyManagerConfig, LoaderConfig, LogLevel, LogTarget, @@ -13,7 +13,7 @@ use crate::{ //----------- Spec ------------------------------------------------------------- /// A configuration file. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, default)] pub struct Spec { /// The directory storing policy files. @@ -135,7 +135,7 @@ impl Spec { //----------- RemoteControlSpec ---------------------------------------------- /// Remote control configuration for Cascade. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, default)] pub struct RemoteControlSpec { /// Where to serve our HTTP API from, e.g. for the Cascade client. @@ -175,7 +175,7 @@ impl RemoteControlSpec { //----------- DaemonSpec ------------------------------------------------------- /// Configuring the Cascade daemon. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, default)] pub struct DaemonSpec { /// The minimum severity of messages to log. @@ -210,7 +210,7 @@ impl DaemonSpec { //----------- LogLevelSpec ----------------------------------------------------- /// A severity level for logging. -#[derive(Copy, Clone, Debug, Deserialize)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum LogLevelSpec { /// A function or variable was interacted with, for debugging. @@ -251,7 +251,7 @@ impl LogLevelSpec { //----------- LogTargetSpec ---------------------------------------------------- /// A logging target. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, tag = "type")] pub enum LogTargetSpec { /// Append logs to a file. @@ -324,6 +324,23 @@ impl<'de> Deserialize<'de> for IdentitySpec { } } +//--- Serialization + +impl fmt::Display for IdentitySpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.user, self.group) + } +} + +impl Serialize for IdentitySpec { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + //--- Conversion impl IdentitySpec { @@ -363,6 +380,17 @@ impl FromStr for UserIdSpec { } } +//--- Serialization + +impl fmt::Display for UserIdSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Numeric(v) => write!(f, "{v}"), + Self::Named(v) => write!(f, "{v}"), + } + } +} + //--- Conversion impl UserIdSpec { @@ -405,6 +433,17 @@ impl FromStr for GroupIdSpec { } } +//--- Serialization + +impl fmt::Display for GroupIdSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Numeric(v) => write!(f, "{v}"), + Self::Named(v) => write!(f, "{v}"), + } + } +} + //--- Conversion impl GroupIdSpec { @@ -420,7 +459,7 @@ impl GroupIdSpec { //----------- LoaderSpec ------------------------------------------------------- /// Configuring how zones are loaded. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, default)] pub struct LoaderSpec { /// Configuring whether and how loaded zones are reviewed. @@ -439,7 +478,7 @@ impl LoaderSpec { //----------- SignerSpec ------------------------------------------------------- /// Configuring the zone signer. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, default)] pub struct SignerSpec { /// Configuring whether and how signed zones are reviewed. @@ -458,7 +497,7 @@ impl SignerSpec { //----------- ReviewSpec ------------------------------------------------------- /// Configuring whether and how zones are reviewed. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, default)] pub struct ReviewSpec { /// Where to serve zones for review. @@ -480,7 +519,7 @@ impl ReviewSpec { //----------- KeyManagerSpec --------------------------------------------------- /// Configuring DNSSEC key management. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, default)] pub struct KeyManagerSpec {} @@ -496,7 +535,7 @@ impl KeyManagerSpec { //----------- ServerSpec ------------------------------------------------------- /// Configuring how zones are published. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, default)] pub struct ServerSpec { /// Where to serve zones. @@ -518,7 +557,7 @@ impl ServerSpec { //----------- SocketSpec ------------------------------------------------------- /// Configuration for serving / listening on a network socket. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged, expecting = "a URI string or an inline table")] pub enum SocketSpec { /// A simple socket specification. @@ -553,7 +592,7 @@ pub enum SimpleSocketSpec { } /// A complex [`SocketSpec`] as a table. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, tag = "type")] pub enum ComplexSocketSpec { /// Listen exclusively over UDP. @@ -612,6 +651,27 @@ impl<'de> Deserialize<'de> for SimpleSocketSpec { } } +//--- Serialization + +impl fmt::Display for SimpleSocketSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SimpleSocketSpec::UDP { addr } => write!(f, "udp://{addr}"), + SimpleSocketSpec::TCP { addr } => write!(f, "tcp://{addr}"), + SimpleSocketSpec::TCPUDP { addr } => write!(f, "{addr}"), + } + } +} + +impl Serialize for SimpleSocketSpec { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + //--- Conversion impl SocketSpec { diff --git a/crates/cfg/src/lib.rs b/crates/cfg/src/lib.rs index 78f57f0f..8e17f162 100644 --- a/crates/cfg/src/lib.rs +++ b/crates/cfg/src/lib.rs @@ -12,6 +12,7 @@ use std::{ use camino::Utf8Path; +#[cfg(feature = "args")] pub mod args; pub mod env; pub mod file; @@ -87,11 +88,13 @@ impl Default for Config { impl Config { /// Set up a [`clap::Command`] with config-related arguments. + #[cfg(feature = "args")] pub fn setup_cli(cmd: clap::Command) -> clap::Command { args::ArgsSpec::setup(cmd) } /// Initialize Cascade's configuration. + #[cfg(feature = "args")] pub fn init(cli_matches: &clap::ArgMatches) -> Result { // Process environment variables and command-line arguments. let env = env::EnvSpec::process()?; diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml new file mode 100644 index 00000000..4f401c07 --- /dev/null +++ b/crates/tests/Cargo.toml @@ -0,0 +1,84 @@ +[package] +name = "cascade-tests" +description = "Test infrastructure for Cascade." + +authors.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true + +version.workspace = true +rust-version.workspace = true +edition.workspace = true + +readme = "README.md" + + +# --- Dependencies ------------------------------------------------------------- + +[dependencies] + +[dependencies.camino] +workspace = true + +[dependencies.cascade-api] +path = "../api" + +[dependencies.cascade-cfg] +path = "../cfg" +default-features = false +features = [] + +[dependencies.cascade-policy-file] +path = "../policy-file" + +# When spawning the daemon for testing, a temporary directory is needed to hold +# configuration and state files (including those of the HSM). +# +# - 'tempfile' is a Rust crate providing scoped temporary files and directories. +# +# 2025-01-19: It is maintained by (Steven +# Allen). While their source of income/funding is unclear, they appear to be +# sponsoring several other developers on GitHub. The crate is incredibly +# popular and ecosystem dependence means that it should be maintained for +# the forseeable future. +[target.'cfg(unix)'.dependencies.tempfile] +version = "3.24.0" + +# When spawning the daemon for testing, it is more reliable to pass in a KMIP +# server socket directly (following systemd socket activation) than to select +# a port in the configuration file. +# +# - 'std' would be an ideal candidate for this, but it does not currently +# provide such functionality. +# +# - 'command-fds' is a Rust crate allowing file descriptors to be passed in to +# spawned processes. +# +# 2025-01-19: It is maintained by (Andrew +# Walbran), and is part of the Android Open Source Project (AOSP, maintained +# by Google). It does not see much activity, but it is a small crate with a +# stable implementation, that would be easy to vendor if needed. +[target.'cfg(unix)'.dependencies.command-fds] +version = "0.3.2" + +[dependencies.reqwest] +version = "0.13.3" +default-features = false +features = ["http2", "blocking", "json"] + +[dependencies.nix] +version = "0.31" +default-features = false +features = ["process"] + +[dependencies.serde] +workspace = true +[dependencies.toml] +workspace = true + +[dependencies.tracing] +workspace = true +[dependencies.tracing-subscriber] +workspace = true diff --git a/crates/tests/src/bin/set_listen_pid.rs b/crates/tests/src/bin/set_listen_pid.rs new file mode 100644 index 00000000..f9a31d81 --- /dev/null +++ b/crates/tests/src/bin/set_listen_pid.rs @@ -0,0 +1,25 @@ +//! A simple binary to set the `LISTEN_PID` environment variable. +//! +//! Usage: `set_listen_pid `. +//! +//! Sets `LISTEN_PID` to the current PID and then `exec`'s ``. +//! +//! While it would be nice to spawn `` directly and set `LISTEN_PID` for +//! it, this is surprisingly difficult to implement safely. An intermediate +//! process is the simplest way. + +use std::os::unix::process::CommandExt; + +fn main() { + let pid = nix::unistd::getpid(); + + let mut args = std::env::args_os(); + let _ = args.next(); // argv[0], path to self + let cmd = args.next().unwrap(); + + let err = std::process::Command::new(cmd) + .args(args) + .env("LISTEN_PID", pid.to_string()) + .exec(); + panic!("`exec()` failed: {err}"); +} diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs new file mode 100644 index 00000000..b3ac77b0 --- /dev/null +++ b/crates/tests/src/lib.rs @@ -0,0 +1,3 @@ +//! Infrastructure for testing Cascade. + +pub mod process; diff --git a/crates/tests/src/process/client.rs b/crates/tests/src/process/client.rs new file mode 100644 index 00000000..6196f178 --- /dev/null +++ b/crates/tests/src/process/client.rs @@ -0,0 +1,247 @@ +use std::fmt::{self, Debug}; +use std::time::Duration; + +use api::dep::domain; +use cascade_api as api; +use domain::base::Serial; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::process::DaemonSockets; + +/// An HTTP client for controlling a [`Daemon`]. +/// +/// [`Daemon`]: super::Daemon +pub struct DaemonClient { + /// The underlying HTTP client. + inner: reqwest::blocking::Client, + + /// The base URL for all requests. + base: reqwest::Url, +} + +//--- Initialization and configuration + +impl DaemonClient { + /// The user agent. + const USER_AGENT: &str = concat!( + env!("CARGO_PKG_NAME"), + "-testing/", + env!("CARGO_PKG_VERSION"), + ); + + /// The maximum time an HTTP request is expected to take. + const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + + /// Construct a new [`DaemonClient`]. + #[tracing::instrument(level = "debug", skip_all, fields(%base))] + pub fn new(base: reqwest::Url) -> Self { + let inner = reqwest::blocking::ClientBuilder::new() + .user_agent(Self::USER_AGENT) + .timeout(Self::REQUEST_TIMEOUT) + .build() + .unwrap(); + + Self { inner, base } + } + + /// Construct a new [`DaemonClient`]. + pub fn for_sockets(sockets: &DaemonSockets) -> Self { + let addr = sockets.remote_control.local_addr().unwrap(); + let base = reqwest::Url::parse(&format!("http://{addr}/")).unwrap(); + Self::new(base) + } +} + +//--- Generic request methods + +impl DaemonClient { + /// Decode a JSON response. + /// + /// ## Panics + /// + /// Panics if the response cannot be deserialized. + fn decode_json(response: reqwest::blocking::Response) -> T { + response + .error_for_status() + .unwrap_or_else(|err| { + panic!( + "HTTP request failed with status code {}", + err.status().unwrap() + ) + }) + .json() + .unwrap_or_else(|err| panic!("Could not decode JSON: {err}")) + } + + /// Make a GET request, receiving JSON data. + #[tracing::instrument(level = "trace", skip_all)] + pub fn get_json(&self, url: &str) -> T + where + T: Debug + DeserializeOwned, + { + let url = self.base.join(url).unwrap(); + let response = self.inner.get(url.clone()).send().unwrap(); + let result = Self::decode_json(response); + tracing::trace!("GET {url:?} -> {result:?}"); + result + } + + /// Make a POST request, sending and receiving JSON data. + #[tracing::instrument(level = "trace", skip_all)] + pub fn post_json(&self, url: &str, payload: P) -> T + where + T: Debug + DeserializeOwned, + P: Debug + Serialize, + { + let url = self.base.join(url).unwrap(); + let response = self.inner.post(url.clone()).json(&payload).send().unwrap(); + let result = Self::decode_json(response); + tracing::trace!("POST {url:?} with {payload:?} -> {result:?}"); + result + } + + /// Make a POST request, receiving JSON data. + #[tracing::instrument(level = "trace", skip_all)] + pub fn post_recv_json(&self, url: &str) -> T + where + T: Debug + DeserializeOwned, + { + let url = self.base.join(url).unwrap(); + let response = self.inner.post(url.clone()).send().unwrap(); + let result = Self::decode_json(response); + tracing::trace!("POST {url:?} -> {result:?}"); + result + } +} + +//--- Concrete client functionality + +/// # Policies +impl DaemonClient { + /// The names of all known policies. + #[tracing::instrument(level = "debug", ret)] + pub fn policy_names(&self) -> Vec { + self.get_json::("policy/").policies + } + + /// Information about a policy. + #[tracing::instrument(level = "debug", ret)] + pub fn policy_info(&self, name: &str) -> api::PolicyInfo { + self.get_json::(&format!("policy/{name}")) + } + + /// Reload all policies. + #[tracing::instrument(level = "debug", ret)] + #[expect(clippy::result_large_err)] + pub fn reload_policies(&self) -> Result { + self.post_recv_json("policy/reload") + } +} + +/// # Zones +impl DaemonClient { + /// The names of all known zones. + #[tracing::instrument(level = "debug", ret)] + pub fn zone_names(&self) -> Vec { + self.get_json::("zone/").zones + } + + /// The status of a zone. + #[tracing::instrument(level = "debug", ret)] + pub fn zone_status(&self, name: &str) -> api::ZoneStatus { + self.get_json(&format!("zone/{name}/status")) + } + + /// The history of important events for a zone. + #[tracing::instrument(level = "debug", ret)] + pub fn zone_history(&self, name: &str) -> api::ZoneHistory { + self.get_json(&format!("zone/{name}/history")) + } + + /// Add a new zone. + #[tracing::instrument(level = "debug", ret)] + pub fn add_zone(&self, cmd: api::ZoneAdd) -> Result { + self.post_json("zone/add", cmd) + } + + /// Remove a zone. + #[tracing::instrument(level = "debug", ret)] + pub fn remove_zone(&self, name: &str) -> Result { + self.post_recv_json(&format!("zone/{name}/remove")) + } + + /// Reload a zone. + #[tracing::instrument(level = "debug", ret)] + pub fn reload_zone(&self, name: &str) -> Result { + self.post_recv_json(&format!("zone/{name}/reload")) + } + + /// Start moving a zone to maintenance mode. + #[tracing::instrument(level = "debug", ret)] + pub fn start_maintenance_for_zone(&self, name: &str) -> api::ZoneMaintenanceModeResult { + self.post_recv_json(&format!("zone/{name}/maintenance/enable")) + } + + /// Restore a zone from (moving to) maintenance mode. + #[tracing::instrument(level = "debug", ret)] + pub fn stop_maintenance_for_zone(&self, name: &str) -> api::ZoneMaintenanceModeResult { + self.post_recv_json(&format!("zone/{name}/maintenance/disable")) + } + + /// Reset the pipeline for a zone. + #[tracing::instrument(level = "debug", ret)] + pub fn reset_pipeline(&self, name: &str) -> api::ZoneResetResult { + self.post_recv_json(&format!("zone/{name}/reset")) + } + + /// Override a unsigned hard-halt for a zone. + #[tracing::instrument(level = "debug", ret)] + pub fn override_unsigned_hard_halt(&self, name: &str) -> api::ZoneOverrideResult { + self.post_recv_json(&format!("zone/{name}/unsigned/override")) + } + + /// Override a signed hard-halt for a zone. + #[tracing::instrument(level = "debug", ret)] + pub fn override_signed_hard_halt(&self, name: &str) -> api::ZoneOverrideResult { + self.post_recv_json(&format!("zone/{name}/signed/override")) + } + + /// Manually approve an unsigned zone instance pending review. + #[tracing::instrument(level = "debug", ret)] + pub fn approve_unsigned(&self, name: &str, serial: Serial) -> api::ZoneReviewResult { + self.post_recv_json(&format!("zone/{name}/unsigned/{serial}/approve")) + } + + /// Manually approve a signed zone instance pending review. + #[tracing::instrument(level = "debug", ret)] + pub fn approve_signed(&self, name: &str, serial: Serial) -> api::ZoneReviewResult { + self.post_recv_json(&format!("zone/{name}/signed/{serial}/approve")) + } + + /// Manually reject an unsigned zone instance pending review. + #[tracing::instrument(level = "debug", ret)] + pub fn reject_unsigned(&self, name: &str, serial: Serial) -> api::ZoneReviewResult { + self.post_recv_json(&format!("zone/{name}/unsigned/{serial}/reject")) + } + + /// Manually reject a signed zone instance pending review. + #[tracing::instrument(level = "debug", ret)] + pub fn reject_signed(&self, name: &str, serial: Serial) -> api::ZoneReviewResult { + self.post_recv_json(&format!("zone/{name}/signed/{serial}/reject")) + } +} + +//--- Debugging + +impl Debug for DaemonClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + // Ignore: debug format is just `Client`. + inner: _, + // Ignore: full URLs get printed at `trace` level anyway. + base: _, + } = self; + + f.write_str("DaemonClient") + } +} diff --git a/crates/tests/src/process/mod.rs b/crates/tests/src/process/mod.rs new file mode 100644 index 00000000..4481445f --- /dev/null +++ b/crates/tests/src/process/mod.rs @@ -0,0 +1,363 @@ +//! Process tests. +//! +//! This module provides infrastructure for tests that launch Cascade as a +//! process and talk to it. They are more limited than integration tests; they +//! don't test Cascade's key management, because it uses the system DNS. They +//! are faster and easier to write. + +// Relies on Unix system functionality. +#![cfg(unix)] + +use std::{ + net::{Ipv6Addr, TcpListener, UdpSocket}, + os::fd::OwnedFd, + process::Command, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use command_fds::{CommandFdExt, FdMapping}; + +pub mod client; +pub use client::DaemonClient; + +//----------- Daemon ----------------------------------------------------------- + +/// A running Cascade daemon. +#[derive(Debug)] +pub struct Daemon { + /// The daemon process. + pub process: std::process::Child, + + /// A client for controlling the daemon. + pub client: DaemonClient, + + /// The configuration used by the daemon. + pub config: cascade_cfg::file::Spec, + + /// The filesystem used by the daemon. + pub filesystem: DaemonFilesystem, +} + +impl Daemon { + /// Launch the daemon. + #[tracing::instrument(level = "debug", skip_all)] + pub fn launch(mut builder: DaemonBuilder) -> Self { + tracing::trace!("Filesystem: {:?}", builder.filesystem); + + // Prepare the configuration file. + let config = builder + .config + .take() + .unwrap_or_else(|| builder.default_config()); + tracing::trace!("Configuration: {config:?}"); + config.save_compact(&builder.filesystem.config).unwrap(); + + let client = DaemonClient::for_sockets(&builder.sockets); + + // Launch the daemon. + let fds = builder + .sockets + .unspool() + .into_iter() + .zip(3..) + .map(|(parent_fd, child_fd)| FdMapping { + parent_fd, + child_fd, + }) + .collect::>(); + let mut cmd = Command::new("set_listen_pid"); + cmd.arg(&*builder.path) + .arg("--config") + .arg(&*builder.filesystem.config) + .arg("--state") + .arg(&*builder.filesystem.state) + .env("LISTEN_FDS", fds.len().to_string()) + .fd_mappings(fds) + .unwrap() + .current_dir(&*builder.filesystem.root); + + tracing::debug!("Spawning Cascade"); + tracing::trace!("Command: {cmd:?}"); + + let process = cmd.spawn().unwrap(); + + Self { + process, + client, + config, + filesystem: builder.filesystem, + } + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + // Stop the daemon. + if let Ok(Some(status)) = self.process.try_wait() { + tracing::error!("Daemon already exited: {status:?}"); + } + // TODO: Use SIGTERM instead of SIGKILL? + let _ = self.process.kill(); + + if std::thread::panicking() + && let Ok(text) = std::fs::read_to_string(&*self.filesystem.log) + { + tracing::info!("Daemon logs:\n{text}\n"); + } + } +} + +//----------- DaemonBuilder ---------------------------------------------------- + +/// A builder for a new [`Daemon`]. +pub struct DaemonBuilder { + /// The path to the daemon executable. + pub path: Box, + + /// The filesystem that will be used. + pub filesystem: DaemonFilesystem, + + /// The sockets that will be used. + pub sockets: DaemonSockets, + + /// The configuration used by the daemon. + /// + /// If [`None`], a default configuration will be prepared that uses + /// [`Self::filesystem`] and [`Self::sockets`]. + pub config: Option, +} + +impl DaemonBuilder { + /// Initialize a new [`DaemonBuilder`]. + pub fn new() -> Self { + let cwd = Utf8PathBuf::try_from(std::env::current_dir().unwrap()).unwrap(); + Self { + path: cwd.join("target/debug/cascaded").into_boxed_path(), + filesystem: DaemonFilesystem::new(), + sockets: DaemonSockets::new(), + config: None, + } + } + + /// Launch the daemon. + pub fn build(self) -> Daemon { + Daemon::launch(self) + } + + /// Build the default configuration. + fn default_config(&self) -> cascade_cfg::file::Spec { + use cascade_cfg::file::v1::*; + + let mut spec = Spec { + policy_dir: self.filesystem.policies.clone(), + zone_state_dir: self.filesystem.zone_state.clone(), + tsig_store_path: self.filesystem.tsig_store.clone(), + keys_dir: self.filesystem.keys.clone(), + // TODO: Warn the user if they don't have 'dnst' installed. + dnst_binary_path: "dnst".into(), + kmip_credentials_store_path: self.filesystem.kmip_creds_store.clone(), + kmip_server_state_dir: self.filesystem.kmip_server_state.clone(), + + ..Default::default() + }; + + spec.remote_control.servers = vec![self.sockets.remote_control.local_addr().unwrap()]; + + spec.daemon.log_level = Some(LogLevelSpec::Trace); + spec.daemon.log_target = Some(LogTargetSpec::File { + path: self.filesystem.log.clone(), + }); + spec.daemon.daemonize = Some(false); + + spec.loader.review.servers = vec![SocketSpec::Simple(SimpleSocketSpec::TCPUDP { + addr: self.sockets.loader_review.0.local_addr().unwrap(), + })]; + spec.signer.review.servers = vec![SocketSpec::Simple(SimpleSocketSpec::TCPUDP { + addr: self.sockets.signer_review.0.local_addr().unwrap(), + })]; + spec.server.servers = vec![SocketSpec::Simple(SimpleSocketSpec::TCPUDP { + addr: self.sockets.publication.0.local_addr().unwrap(), + })]; + + cascade_cfg::file::Spec::V1(spec) + } +} + +impl Default for DaemonBuilder { + fn default() -> Self { + Self::new() + } +} + +//----------- DaemonFilesystem ------------------------------------------------- + +/// The filesystem used by a Cascade daemon. +#[derive(Debug)] +pub struct DaemonFilesystem { + /// The temporary directory containing everything else. + #[allow(dead_code)] + pub tempdir: tempfile::TempDir, + + /// The path to the root of `tempdir`. + pub root: Box, + + /// The configuration file. + pub config: Box, + + /// The global state file. + pub state: Box, + + /// The directory storing zone policies. + pub policies: Box, + + /// The directory storing per-zone state files. + pub zone_state: Box, + + /// The TSIG key store. + pub tsig_store: Box, + + /// The KMIP credential store. + pub kmip_creds_store: Box, + + /// The directory storing keyset state and on-disk cryptographic keys. + pub keys: Box, + + /// The directory storing KMIP server state. + pub kmip_server_state: Box, + + /// The log file. + pub log: Box, +} + +impl DaemonFilesystem { + /// Build a new [`DaemonFilesystem`]. + /// + /// ## Panics + /// + /// Panics if the filesystem cannot be set up. + pub fn new() -> Self { + let tempdir = tempfile::tempdir().unwrap(); + let root = Utf8Path::from_path(tempdir.path()).unwrap(); + + // Specify a file or directory, and create empty directories. + let entry = |p: &str| { + let path = root.join(p).into_boxed_path(); + if p.ends_with('/') { + std::fs::create_dir_all(&*path).unwrap(); + } else if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + path + }; + + Self { + config: entry("config.toml"), + state: entry("state.db"), + policies: entry("policies/"), + zone_state: entry("zone-state/"), + tsig_store: entry("tsig-store.db"), + kmip_creds_store: entry("kmip/creds.db"), + keys: entry("keys/"), + kmip_server_state: entry("kmip/"), + log: entry("cascaded.log"), + + root: root.into(), + tempdir, + } + } +} + +impl Default for DaemonFilesystem { + fn default() -> Self { + Self::new() + } +} + +impl Drop for DaemonFilesystem { + fn drop(&mut self) { + const KEEP_DIR_VAR: &str = "CASCADE_TESTS_KEEP_DIR"; + let Some(_) = std::env::var_os(KEEP_DIR_VAR) else { + if std::thread::panicking() { + tracing::info!("Set `{KEEP_DIR_VAR}` to keep temporary state"); + } + + return; + }; + + tracing::info!("Temporary state retained at {}", self.root); + self.tempdir.disable_cleanup(true); + } +} + +//----------- DaemonSockets ---------------------------------------------------- + +/// The sockets used by a Cascade daemon. +pub struct DaemonSockets { + /// The HTTP API server. + pub remote_control: TcpListener, + + /// The loader review server. + pub loader_review: (UdpSocket, TcpListener), + + /// The signer review server. + pub signer_review: (UdpSocket, TcpListener), + + /// The publication server. + pub publication: (UdpSocket, TcpListener), +} + +impl DaemonSockets { + /// Build a new [`DaemonSockets`]. + /// + /// ## Panics + /// + /// Panics if sockets cannot be obtained. + pub fn new() -> Self { + const LOCAL_ANY: (Ipv6Addr, u16) = (Ipv6Addr::LOCALHOST, 0); + + /// Try to obtain a TCP-UDP socket pair at the same address. + fn obtain_tcp_udp_pair() -> (UdpSocket, TcpListener) { + const NUM_TRIES: usize = 5; + + for _ in 0..NUM_TRIES { + let tcp = TcpListener::bind(LOCAL_ANY).unwrap(); + let addr = tcp.local_addr().unwrap(); + let Ok(udp) = UdpSocket::bind(addr) else { + continue; + }; + + tracing::trace!(?udp, ?tcp, "Bound UDP+TCP sockets at {addr:?}"); + + return (udp, tcp); + } + + panic!("failed to bind a UDP-TCP socket pair after {NUM_TRIES} tries") + } + + Self { + remote_control: TcpListener::bind(LOCAL_ANY).unwrap(), + loader_review: obtain_tcp_udp_pair(), + signer_review: obtain_tcp_udp_pair(), + publication: obtain_tcp_udp_pair(), + } + } + + /// Unspool the sockets into raw file descriptors. + pub fn unspool(self) -> impl IntoIterator { + [ + self.remote_control.into(), + self.loader_review.0.into(), + self.loader_review.1.into(), + self.signer_review.0.into(), + self.signer_review.1.into(), + self.publication.0.into(), + self.publication.1.into(), + ] + } +} + +impl Default for DaemonSockets { + fn default() -> Self { + Self::new() + } +} diff --git a/tests/launch.rs b/tests/launch.rs new file mode 100644 index 00000000..2516ca96 --- /dev/null +++ b/tests/launch.rs @@ -0,0 +1,24 @@ +//! Launch Cascade. + +// Only available on Unix machines. +#![cfg(unix)] + +use cascade_tests::process; +use tracing::info; + +#[test] +fn launch() { + let _ = tracing_subscriber::fmt::try_init(); + let daemon = process::DaemonBuilder::new().build(); + + // Set up a simple policy. + let policy = cascade_policy_file::v1::Spec::default(); + let policy = cascade_policy_file::VersionedSpec::V1(policy); + let policy = toml::to_string(&policy).unwrap(); + let path = daemon.filesystem.policies.join("simple.toml"); + std::fs::write(path, policy).unwrap(); + info!("reload policies: {:?}", daemon.client.reload_policies()); + + info!("zone names: {:?}", daemon.client.zone_names()); + info!("policy names: {:?}", daemon.client.policy_names()); +}