From d268733b90ff305000adda822ac14ce42abd36e9 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 11 Jul 2025 13:43:09 -0400 Subject: [PATCH 01/27] add sshdcmdargs struct and method to retrieve defaults --- sshdconfig/Cargo.lock | 60 +++++++++++++++++++++++- sshdconfig/Cargo.toml | 1 + sshdconfig/locales/en-us.toml | 2 + sshdconfig/src/args.rs | 2 + sshdconfig/src/error.rs | 2 + sshdconfig/src/export.rs | 12 ++--- sshdconfig/src/get.rs | 31 +++++++++++- sshdconfig/src/main.rs | 17 +++++-- sshdconfig/src/util.rs | 51 ++++++++++++++++++-- sshdconfig/sshd_config.dsc.resource.json | 24 ++++++++++ 10 files changed, 184 insertions(+), 18 deletions(-) diff --git a/sshdconfig/Cargo.lock b/sshdconfig/Cargo.lock index 44f4788e..57b5975b 100644 --- a/sshdconfig/Cargo.lock +++ b/sshdconfig/Cargo.lock @@ -328,12 +328,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + [[package]] name = "glob" version = "0.3.2" @@ -574,9 +592,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.18.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" @@ -637,6 +655,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "redox_syscall" version = "0.5.13" @@ -998,6 +1022,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "tempfile", "thiserror 2.0.12", "tracing", "tracing-subscriber", @@ -1041,6 +1066,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1336,6 +1374,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasm-bindgen" version = "0.2.87" @@ -1641,3 +1688,12 @@ checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" dependencies = [ "memchr", ] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.1", +] diff --git a/sshdconfig/Cargo.toml b/sshdconfig/Cargo.toml index 258367fb..e38e5400 100644 --- a/sshdconfig/Cargo.toml +++ b/sshdconfig/Cargo.toml @@ -22,6 +22,7 @@ rust-i18n = { version = "3.1" } schemars = "0.9" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } +tempfile = "3.8" thiserror = { version = "2.0" } tracing = "0.1.37" tracing-subscriber = "0.3.17" diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index aa80e79f..df6f81eb 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -1,11 +1,13 @@ _version = 1 [args] +getInput = "input to get from sshd_config" setInput = "input to set in sshd_config" [error] command = "Command" invalidInput = "Invalid Input" +io = "IO" json = "JSON" language = "Language" notImplemented = "Not Implemented" diff --git a/sshdconfig/src/args.rs b/sshdconfig/src/args.rs index d0bbb884..f38e8a4a 100644 --- a/sshdconfig/src/args.rs +++ b/sshdconfig/src/args.rs @@ -16,6 +16,8 @@ pub struct Args { pub enum Command { /// Get default shell, eventually to be used for `sshd_config` and repeatable keywords Get { + #[clap(short = 'i', long, help = t!("args.getInput").to_string())] + input: Option, #[clap(short = 's', long, hide = true)] setting: Setting, }, diff --git a/sshdconfig/src/error.rs b/sshdconfig/src/error.rs index 53206ace..ecc7ce89 100644 --- a/sshdconfig/src/error.rs +++ b/sshdconfig/src/error.rs @@ -10,6 +10,8 @@ pub enum SshdConfigError { CommandError(String), #[error("{t}: {0}", t = t!("error.invalidInput"))] InvalidInput(String), + #[error("{t}: {0}", t = t!("error.io"))] + IOError(#[from] std::io::Error), #[error("{t}: {0}", t = t!("error.json"))] Json(#[from] serde_json::Error), #[error("{t}: {0}", t = t!("error.language"))] diff --git a/sshdconfig/src/export.rs b/sshdconfig/src/export.rs index bc720c3a..290adc74 100644 --- a/sshdconfig/src/export.rs +++ b/sshdconfig/src/export.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use serde_json::{Map, Value}; + use crate::error::SshdConfigError; use crate::parser::parse_text_to_map; use crate::util::invoke_sshd_config_validation; @@ -10,10 +12,8 @@ use crate::util::invoke_sshd_config_validation; /// # Errors /// /// This function will return an error if the command cannot invoke sshd -T, parse the return, or convert it to json. -pub fn invoke_export() -> Result<(), SshdConfigError> { - let sshd_config_text = invoke_sshd_config_validation()?; - let sshd_config: serde_json::Map = parse_text_to_map(&sshd_config_text)?; - let json = serde_json::to_string(&sshd_config)?; - println!("{json}"); - Ok(()) +pub fn invoke_export() -> Result, SshdConfigError> { + let sshd_config_text = invoke_sshd_config_validation(None)?; + let sshd_config: Map = parse_text_to_map(&sshd_config_text)?; + Ok(sshd_config) } diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index 7a7f1075..880d0fc2 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -9,20 +9,22 @@ use { }; use rust_i18n::t; +use serde_json::{Map, Value}; use tracing::debug; use crate::args::Setting; use crate::error::SshdConfigError; +use crate::export::invoke_export; /// Invoke the get command. /// /// # Errors /// /// This function will return an error if the desired settings cannot be retrieved. -pub fn invoke_get(setting: &Setting) -> Result<(), SshdConfigError> { +pub fn invoke_get(input: Option<&String>, setting: &Setting) -> Result<(), SshdConfigError> { debug!("Get setting: {:?}", setting); match *setting { - Setting::SshdConfig => Err(SshdConfigError::NotImplemented(t!("get.notImplemented").to_string())), + Setting::SshdConfig => get_sshd_settings(input), Setting::WindowsGlobal => get_default_shell() } } @@ -82,3 +84,28 @@ fn get_default_shell() -> Result<(), SshdConfigError> { fn get_default_shell() -> Result<(), SshdConfigError> { Err(SshdConfigError::InvalidInput(t!("get.windowsOnly").to_string())) } + +fn get_sshd_settings(input: Option<&String>) -> Result<(), SshdConfigError> { + let result = invoke_export()?; + match input { + Some(config) => { + // Filter result based on the keys provided in the input JSON. + // If a provided key is not found in the result, its value is null. + let input_config: Map = serde_json::from_str(config)?; + let filtered_config: Map = input_config + .keys() + .map(|key| { + let value = result.get(key) + .cloned() + .unwrap_or(Value::Null); + (key.clone(), value) + }) + .collect(); + println!("{}", serde_json::to_string(&filtered_config)?); + }, + None => { + println!("{}", serde_json::to_string(&result)?); + } + } + Ok(()) +} diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index 534b2a56..6ada7c9f 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -12,7 +12,7 @@ use export::invoke_export; use get::invoke_get; use parser::SshdConfigParser; use set::invoke_set; -use util::enable_tracing; +use util::{enable_tracing, extract_sshd_defaults}; mod args; mod error; @@ -33,14 +33,23 @@ fn main() { let args = Args::parse(); + let test = extract_sshd_defaults(); + println!("Extracted defaults: {:?}", test); + let result = match &args.command { Command::Export => { debug!("Export command"); - invoke_export() + match invoke_export() { + Ok(output) => { + println!("{:?}", serde_json::to_string(&output)); + Ok(()) + }, + Err(e) => Err(e), + } }, - Command::Get { setting } => { + Command::Get { input, setting } => { debug!("Get command: setting={:?}", setting); - invoke_get(setting) + invoke_get(input.as_ref(), setting) }, Command::Schema { setting } => { debug!("Schema command: setting={:?}", setting); diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index bff2a5da..cc8b2f05 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -2,11 +2,23 @@ // Licensed under the MIT License. use rust_i18n::t; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use std::process::Command; +use tracing::debug; use tracing_subscriber::{EnvFilter, filter::LevelFilter, Layer, prelude::__tracing_subscriber_SubscriberExt}; use crate::error::SshdConfigError; +use crate::parser::parse_text_to_map; +// create a struct for sshdconfig arguments +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SshdCmdArgs { + #[serde(skip_serializing_if = "Option::is_none")] + filepath: Option, + #[serde(rename = "additionalArgs", skip_serializing_if = "Option::is_none")] + additionalArgs: Option>, +} /// Enable tracing. /// @@ -36,16 +48,26 @@ pub fn enable_tracing() { /// # Errors /// /// This function will return an error if sshd -T fails to validate `sshd_config`. -pub fn invoke_sshd_config_validation() -> Result { +pub fn invoke_sshd_config_validation(args: Option) -> Result { let sshd_command = if cfg!(target_os = "windows") { "sshd.exe" } else { "sshd" }; - let output = Command::new(sshd_command) - .arg("-T") - .output() + let mut command = Command::new(sshd_command); + command.arg("-T"); + + if let Some(args) = args { + if let Some(filepath) = args.filepath { + command.arg("-f").arg(filepath); + } + if let Some(additional_args) = args.additionalArgs { + command.args(additional_args); + } + } + + let output = command.output() .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; if output.status.success() { @@ -63,3 +85,24 @@ pub fn invoke_sshd_config_validation() -> Result { Err(SshdConfigError::CommandError(stderr)) } } + +/// Extract SSH server defaults by running sshd -T with an empty configuration file. +/// +/// # Errors +/// +/// This function will return an error if it fails to extract the defaults from sshd. +pub fn extract_sshd_defaults() -> Result, SshdConfigError> { + // note temp_file is automatically deleted when it goes out of scope + let temp_file = tempfile::NamedTempFile::new()?; + let temp_path = temp_file.path().to_string_lossy().into_owned(); + debug!("temporary file created at: {}", temp_path); + let args = Some( + SshdCmdArgs { + filepath: Some(temp_path.clone()), + additionalArgs: None, + } + ); + let output = invoke_sshd_config_validation(args)?; + let sshd_config: Map = parse_text_to_map(&output)?; + Ok(sshd_config) +} diff --git a/sshdconfig/sshd_config.dsc.resource.json b/sshdconfig/sshd_config.dsc.resource.json index c18dd7d9..ea47622e 100644 --- a/sshdconfig/sshd_config.dsc.resource.json +++ b/sshdconfig/sshd_config.dsc.resource.json @@ -3,6 +3,30 @@ "type": "Microsoft.OpenSSH.SSHD/sshd_config", "description": "Manage SSH Server Configuration", "version": "0.1.0", + "get": { + "executable": "sshdconfig", + "args": [ + "get", + "-s", + "sshd-config", + { + "jsonInputArg": "--input", + "mandatory": false + } + ] + }, + "set": { + "executable": "sshdconfig", + "args": [ + "set", + "-s", + "sshd-config", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, "export": { "executable": "sshdconfig", "args": [ From ebe3308ad79f0b6a338a9fb26f528a80e33a2417 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 11 Jul 2025 14:44:26 -0400 Subject: [PATCH 02/27] add default option to get --- sshdconfig/locales/en-us.toml | 2 ++ sshdconfig/src/args.rs | 2 ++ sshdconfig/src/error.rs | 5 +++-- sshdconfig/src/get.rs | 21 +++++++++++++++++---- sshdconfig/src/main.rs | 9 +++------ sshdconfig/src/util.rs | 27 +++++++++++++++++++++------ 6 files changed, 48 insertions(+), 18 deletions(-) diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index df6f81eb..82f77837 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -1,6 +1,7 @@ _version = 1 [args] +getDefaults = "exclude defaults from sshd_config" getInput = "input to get from sshd_config" setInput = "input to set in sshd_config" @@ -13,6 +14,7 @@ language = "Language" notImplemented = "Not Implemented" parser = "Parser" parseInt = "Parse Integer" +persist = "Persist" registry = "Registry" [get] diff --git a/sshdconfig/src/args.rs b/sshdconfig/src/args.rs index f38e8a4a..af47398e 100644 --- a/sshdconfig/src/args.rs +++ b/sshdconfig/src/args.rs @@ -16,6 +16,8 @@ pub struct Args { pub enum Command { /// Get default shell, eventually to be used for `sshd_config` and repeatable keywords Get { + #[clap(short = 'e', long, help = t!("args.getDefaults").to_string())] + exclude_defaults: bool, #[clap(short = 'i', long, help = t!("args.getInput").to_string())] input: Option, #[clap(short = 's', long, hide = true)] diff --git a/sshdconfig/src/error.rs b/sshdconfig/src/error.rs index ecc7ce89..71c33018 100644 --- a/sshdconfig/src/error.rs +++ b/sshdconfig/src/error.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use rust_i18n::t; +use tempfile::PersistError; use thiserror::Error; #[derive(Debug, Error)] @@ -16,12 +17,12 @@ pub enum SshdConfigError { Json(#[from] serde_json::Error), #[error("{t}: {0}", t = t!("error.language"))] LanguageError(#[from] tree_sitter::LanguageError), - #[error("{t}: {0}", t = t!("error.notImplemented"))] - NotImplemented(String), #[error("{t}: {0}", t = t!("error.parser"))] ParserError(String), #[error("{t}: {0}", t = t!("error.parseInt"))] ParseIntError(#[from] std::num::ParseIntError), + #[error("{t}: {0}", t = t!("error.persist"))] + PersistError(#[from] PersistError), #[cfg(windows)] #[error("{t}: {0}", t = t!("error.registry"))] RegistryError(#[from] registry_lib::error::RegistryError), diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index 880d0fc2..63ee5731 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -15,16 +15,17 @@ use tracing::debug; use crate::args::Setting; use crate::error::SshdConfigError; use crate::export::invoke_export; +use crate::util::extract_sshd_defaults; /// Invoke the get command. /// /// # Errors /// /// This function will return an error if the desired settings cannot be retrieved. -pub fn invoke_get(input: Option<&String>, setting: &Setting) -> Result<(), SshdConfigError> { +pub fn invoke_get(exclude_defaults: bool, input: Option<&String>, setting: &Setting) -> Result<(), SshdConfigError> { debug!("Get setting: {:?}", setting); match *setting { - Setting::SshdConfig => get_sshd_settings(input), + Setting::SshdConfig => get_sshd_settings(exclude_defaults, input), Setting::WindowsGlobal => get_default_shell() } } @@ -85,8 +86,20 @@ fn get_default_shell() -> Result<(), SshdConfigError> { Err(SshdConfigError::InvalidInput(t!("get.windowsOnly").to_string())) } -fn get_sshd_settings(input: Option<&String>) -> Result<(), SshdConfigError> { - let result = invoke_export()?; +fn get_sshd_settings(exclude_defaults: bool, input: Option<&String>) -> Result<(), SshdConfigError> { + let mut result = invoke_export()?; + if exclude_defaults { + let defaults = extract_sshd_defaults()?; + result = result.into_iter() + .filter(|(key, value)| { + if let Some(default) = defaults.get(key) { + default != value + } else { + true + } + }) + .collect(); + } match input { Some(config) => { // Filter result based on the keys provided in the input JSON. diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index 6ada7c9f..44d548b9 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -12,7 +12,7 @@ use export::invoke_export; use get::invoke_get; use parser::SshdConfigParser; use set::invoke_set; -use util::{enable_tracing, extract_sshd_defaults}; +use util::enable_tracing; mod args; mod error; @@ -33,9 +33,6 @@ fn main() { let args = Args::parse(); - let test = extract_sshd_defaults(); - println!("Extracted defaults: {:?}", test); - let result = match &args.command { Command::Export => { debug!("Export command"); @@ -47,9 +44,9 @@ fn main() { Err(e) => Err(e), } }, - Command::Get { input, setting } => { + Command::Get { exclude_defaults, input, setting } => { debug!("Get command: setting={:?}", setting); - invoke_get(input.as_ref(), setting) + invoke_get(*exclude_defaults, input.as_ref(), setting) }, Command::Schema { setting } => { debug!("Schema command: setting={:?}", setting); diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index cc8b2f05..c842a2b4 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -17,7 +17,7 @@ pub struct SshdCmdArgs { #[serde(skip_serializing_if = "Option::is_none")] filepath: Option, #[serde(rename = "additionalArgs", skip_serializing_if = "Option::is_none")] - additionalArgs: Option>, + additional_args: Option>, } /// Enable tracing. @@ -62,7 +62,7 @@ pub fn invoke_sshd_config_validation(args: Option) -> Result) -> Result Result, SshdConfigError> { - // note temp_file is automatically deleted when it goes out of scope - let temp_file = tempfile::NamedTempFile::new()?; + let temp_file = tempfile::Builder::new() + .prefix("sshd_config_empty_") + .suffix(".tmp") + .tempfile()?; + let temp_path = temp_file.path().to_string_lossy().into_owned(); + let (file, path) = temp_file.keep()?; + + // close file so another process (sshd) can read it + drop(file); + debug!("temporary file created at: {}", temp_path); let args = Some( SshdCmdArgs { filepath: Some(temp_path.clone()), - additionalArgs: None, + additional_args: None, } ); - let output = invoke_sshd_config_validation(args)?; + + let output = invoke_sshd_config_validation(args); + + if let Err(e) = std::fs::remove_file(&path) { + debug!("Failed to clean up temporary file {}: {}", path.display(), e); + } + + let output = output?; let sshd_config: Map = parse_text_to_map(&output)?; Ok(sshd_config) } From f169871ffb81bc1c9a06eb71af0be8bf1fe616f2 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 11 Jul 2025 15:09:01 -0400 Subject: [PATCH 03/27] cleanup get --- sshdconfig/src/get.rs | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index 63ee5731..6783f0d5 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -88,6 +88,7 @@ fn get_default_shell() -> Result<(), SshdConfigError> { fn get_sshd_settings(exclude_defaults: bool, input: Option<&String>) -> Result<(), SshdConfigError> { let mut result = invoke_export()?; + if exclude_defaults { let defaults = extract_sshd_defaults()?; result = result.into_iter() @@ -100,25 +101,22 @@ fn get_sshd_settings(exclude_defaults: bool, input: Option<&String>) -> Result<( }) .collect(); } - match input { - Some(config) => { - // Filter result based on the keys provided in the input JSON. - // If a provided key is not found in the result, its value is null. - let input_config: Map = serde_json::from_str(config)?; - let filtered_config: Map = input_config - .keys() - .map(|key| { - let value = result.get(key) - .cloned() - .unwrap_or(Value::Null); - (key.clone(), value) - }) - .collect(); - println!("{}", serde_json::to_string(&filtered_config)?); - }, - None => { - println!("{}", serde_json::to_string(&result)?); - } + + if let Some(config) = input { + // Filter result based on the keys provided in the input JSON. + // If a provided key is not found in the result, its value is null. + let input_config: Map = serde_json::from_str(config)?; + result = input_config + .keys() + .map(|key| { + let value = result.get(key) + .cloned() + .unwrap_or(Value::Null); + (key.clone(), value) + }) + .collect(); } + + println!("{}", serde_json::to_string(&result)?); Ok(()) } From 0d65d5da206233a2c4351707e2d79382fe9a80ef Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 18 Jul 2025 17:32:33 -0400 Subject: [PATCH 04/27] add e2e sshdconfig tests for get/export and update schema --- dsc/tests/dsc_sshdconfig.tests.ps1 | 84 +++++ sshdconfig/src/get.rs | 6 +- sshdconfig/src/main.rs | 11 +- sshdconfig/sshd_config.dsc.resource.json | 404 ++++++++++++++++++++++- 4 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 dsc/tests/dsc_sshdconfig.tests.ps1 diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 new file mode 100644 index 00000000..0e13db0e --- /dev/null +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'SSHDConfig resource tests' { + BeforeAll { + $sshdExists = ($null -ne (Get-Command sshd -CommandType Application -ErrorAction Ignore)) + $isAdmin = if ($IsWindows) { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + [System.Security.Principal.WindowsPrincipal]::new($identity).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + [System.Environment]::UserName -eq 'root' + } + $skipTest = -not ($sshdExists -and $isAdmin) + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +metadata: + Microsoft.DSC: + securityContext: elevated +resources: +- name: sshdconfig + type: Microsoft.OpenSSH.SSHD/sshd_config + properties: +'@ + } + + It 'Export works' -Skip:$skipTest { + $out = dsc config export -i "$yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.resources.count | Should -Be 1 + $out.resources[0].properties | Should -Not -BeNullOrEmpty + $out.resources[0].properties.port[0] | Should -Be 22 + } + + It 'Get works' -Skip:$skipTest { + $out = dsc config get -i "$yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.resources.count | Should -Be 1 + $out.resources[0].properties | Should -Not -BeNullOrEmpty + $out.resources[0].properties.port[0] | Should -Be 22 + $out.resources[0].properties.passwordAuthentication[0] | Should -Be 'yes' + } + + It 'Get with a specific setting works' -Skip:$skipTest { + $get_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +metadata: + Microsoft.DSC: + securityContext: elevated +resources: +- name: sshdconfig + type: Microsoft.OpenSSH.SSHD/sshd_config + properties: + passwordauthentication: 'no' +'@ + $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results.result.actualState.count | Should -Be 1 + $out.results.result.actualState.passwordauthentication | Should -Be 'yes' + } + + # get with exclude defaults works + It 'Get with exclude defaults works' -Skip:$skipTest { + $get_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +metadata: + Microsoft.DSC: + securityContext: elevated +resources: +- name: sshdconfig + type: Microsoft.OpenSSH.SSHD/sshd_config + metadata: + excludeDefaults: true + properties: +'@ + $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results.result.actualState.count | Should -Be 1 + $out.results.result.actualState.port | Should -Not -Be 22 + $out.results.result.actualState.authorizedkeys | Should -Not -BeNullOrEmpty + } +} \ No newline at end of file diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index fcbb8cf9..a5188ebb 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -91,6 +91,9 @@ fn get_sshd_settings(exclude_defaults: bool, input: Option<&String>) -> Result<( if exclude_defaults { let defaults = extract_sshd_defaults()?; + // Filter result based on default settings. + // If a value in result is equal to the default, it will be excluded. + // Note that this excludes all defaults, even if they are explicitly set in sshd_config. result = result.into_iter() .filter(|(key, value)| { if let Some(default) = defaults.get(key) { @@ -117,6 +120,7 @@ fn get_sshd_settings(exclude_defaults: bool, input: Option<&String>) -> Result<( .collect(); } - println!("{}", serde_json::to_string(&result)?); + let json = serde_json::to_string(&result)?; + println!("{json}"); Ok(()) } diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index 6d7b19bd..01026769 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -14,6 +14,8 @@ use parser::SshdConfigParser; use set::invoke_set; use util::enable_tracing; +use crate::error::SshdConfigError; + mod args; mod error; mod export; @@ -38,8 +40,13 @@ fn main() { debug!("{}", t!("main.export").to_string()); match invoke_export() { Ok(output) => { - println!("{:?}", serde_json::to_string(&output)); - Ok(()) + match serde_json::to_string(&output) { + Ok(json) => { + println!("{json}"); + Ok(()) + }, + Err(e) => Err(SshdConfigError::Json(e)), + } }, Err(e) => Err(e), } diff --git a/sshdconfig/sshd_config.dsc.resource.json b/sshdconfig/sshd_config.dsc.resource.json index ea47622e..ea49d7c0 100644 --- a/sshdconfig/sshd_config.dsc.resource.json +++ b/sshdconfig/sshd_config.dsc.resource.json @@ -34,13 +34,401 @@ ] }, "schema": { - "command": { - "executable": "sshdconfig", - "args": [ - "schema", - "-s", - "sshd-config" - ] - } + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "sshdconfig", + "type": "object", + "properties": { + "port": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "addressfamily": { + "type": "string", + "minLength": 1 + }, + "listenaddress": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "logingracetime": { + "type": "number" + }, + "x11displayoffset": { + "type": "number" + }, + "maxauthtries": { + "type": "number" + }, + "maxsessions": { + "type": "number" + }, + "clientaliveinterval": { + "type": "number" + }, + "clientalivecountmax": { + "type": "number" + }, + "requiredrsasize": { + "type": "number" + }, + "streamlocalbindmask": { + "type": "number" + }, + "unusedconnectiontimeout": { + "type": "string", + "minLength": 1 + }, + "permitrootlogin": { + "type": "string", + "minLength": 1 + }, + "ignorerhosts": { + "type": "string", + "minLength": 1 + }, + "ignoreuserknownhosts": { + "type": "string", + "minLength": 1 + }, + "hostbasedauthentication": { + "type": "string", + "minLength": 1 + }, + "hostbasedusesnamefrompacketonly": { + "type": "string", + "minLength": 1 + }, + "pubkeyauthentication": { + "type": "string", + "minLength": 1 + }, + "gssapiauthentication": { + "type": "string", + "minLength": 1 + }, + "gssapicleanupcredentials": { + "type": "string", + "minLength": 1 + }, + "passwordauthentication": { + "type": "string", + "minLength": 1 + }, + "kbdinteractiveauthentication": { + "type": "string", + "minLength": 1 + }, + "printmotd": { + "type": "string", + "minLength": 1 + }, + "printlastlog": { + "type": "string", + "minLength": 1 + }, + "x11forwarding": { + "type": "string", + "minLength": 1 + }, + "x11uselocalhost": { + "type": "string", + "minLength": 1 + }, + "permittty": { + "type": "string", + "minLength": 1 + }, + "permituserrc": { + "type": "string", + "minLength": 1 + }, + "strictmodes": { + "type": "string", + "minLength": 1 + }, + "tcpkeepalive": { + "type": "string", + "minLength": 1 + }, + "permitemptypasswords": { + "type": "string", + "minLength": 1 + }, + "compression": { + "type": "string", + "minLength": 1 + }, + "gatewayports": { + "type": "string", + "minLength": 1 + }, + "usedns": { + "type": "string", + "minLength": 1 + }, + "allowtcpforwarding": { + "type": "string", + "minLength": 1 + }, + "allowagentforwarding": { + "type": "string", + "minLength": 1 + }, + "disableforwarding": { + "type": "string", + "minLength": 1 + }, + "allowstreamlocalforwarding": { + "type": "string", + "minLength": 1 + }, + "streamlocalbindunlink": { + "type": "string", + "minLength": 1 + }, + "fingerprinthash": { + "type": "string", + "minLength": 1 + }, + "exposeauthinfo": { + "type": "string", + "minLength": 1 + }, + "pidfile": { + "type": "string", + "minLength": 1 + }, + "modulifile": { + "type": "string", + "minLength": 1 + }, + "xauthlocation": { + "type": "string", + "minLength": 1 + }, + "ciphers": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "macs": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "banner": { + "type": "string", + "minLength": 1 + }, + "forcecommand": { + "type": "string", + "minLength": 1 + }, + "chrootdirectory": { + "type": "string", + "minLength": 1 + }, + "trustedusercakeys": { + "type": "string", + "minLength": 1 + }, + "revokedkeys": { + "type": "string", + "minLength": 1 + }, + "securitykeyprovider": { + "type": "string", + "minLength": 1 + }, + "authorizedprincipalsfile": { + "type": "string", + "minLength": 1 + }, + "versionaddendum": { + "type": "string", + "minLength": 1 + }, + "authorizedkeyscommand": { + "type": "string", + "minLength": 1 + }, + "authorizedkeyscommanduser": { + "type": "string", + "minLength": 1 + }, + "authorizedprincipalscommand": { + "type": "string", + "minLength": 1 + }, + "authorizedprincipalscommanduser": { + "type": "string", + "minLength": 1 + }, + "hostkeyagent": { + "type": "string", + "minLength": 1 + }, + "kexalgorithms": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "casignaturealgorithms": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "hostbasedacceptedalgorithms": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "hostkeyalgorithms": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "pubkeyacceptedalgorithms": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "sshdsessionpath": { + "type": "string", + "minLength": 1 + }, + "persourcepenaltyexemptlist": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "loglevel": { + "type": "string", + "minLength": 1 + }, + "syslogfacility": { + "type": "string", + "minLength": 1 + }, + "authorizedkeysfile": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "hostkey": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "allowgroups": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "authenticationmethods": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "channeltimeout": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "maxstartups": { + "type": "string", + "minLength": 1 + }, + "persourcemaxstartups": { + "type": "string", + "minLength": 1 + }, + "persourcenetblocksize": { + "type": "string", + "minLength": 1 + }, + "permittunnel": { + "type": "string", + "minLength": 1 + }, + "ipqos": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "rekeylimit": { + "type": "string", + "minLength": 1 + }, + "permitopen": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "permitlisten": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "permituserenvironment": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + }, + "pubkeyauthoptions": { + "type": "string", + "minLength": 1 + }, + "persourcepenalties": { + "type": "array", + "items": { + "required": [], + "properties": {} + } + } + }, + "additionalProperties": false + } } } From 2e82fc7bdac70296b54301f2f2e31c8f59c9e458 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Mon, 21 Jul 2025 11:38:12 -0400 Subject: [PATCH 05/27] cleanup get/export display --- dsc/tests/dsc_sshdconfig.tests.ps1 | 42 +++++++++++++++--------------- sshdconfig/src/export.rs | 23 ++++++++++++++-- sshdconfig/src/get.rs | 4 +-- sshdconfig/src/main.rs | 15 +---------- 4 files changed, 45 insertions(+), 39 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 0e13db0e..00862061 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -60,25 +60,25 @@ resources: $out.results.result.actualState.passwordauthentication | Should -Be 'yes' } - # get with exclude defaults works - It 'Get with exclude defaults works' -Skip:$skipTest { - $get_yaml = @' -$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json -metadata: - Microsoft.DSC: - securityContext: elevated -resources: -- name: sshdconfig - type: Microsoft.OpenSSH.SSHD/sshd_config - metadata: - excludeDefaults: true - properties: -'@ - $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - $out.results.result.actualState.count | Should -Be 1 - $out.results.result.actualState.port | Should -Not -Be 22 - $out.results.result.actualState.authorizedkeys | Should -Not -BeNullOrEmpty - } +# TODO: dsc needs to pass metadata to the resource +# It 'Get with exclude defaults works' -Skip:$skipTest { +# $get_yaml = @' +# $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +# metadata: +# Microsoft.DSC: +# securityContext: elevated +# resources: +# - name: sshdconfig +# type: Microsoft.OpenSSH.SSHD/sshd_config +# metadata: +# excludeDefaults: true +# properties: +# '@ +# $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 +# $LASTEXITCODE | Should -Be 0 +# $out.results.count | Should -Be 1 +# $out.results.result.actualState.count | Should -Be 1 +# $out.results.result.actualState.port | Should -Not -Be 22 +# $out.results.result.actualState.authorizedkeys | Should -Not -BeNullOrEmpty +# } } \ No newline at end of file diff --git a/sshdconfig/src/export.rs b/sshdconfig/src/export.rs index 290adc74..24977ec1 100644 --- a/sshdconfig/src/export.rs +++ b/sshdconfig/src/export.rs @@ -7,13 +7,32 @@ use crate::error::SshdConfigError; use crate::parser::parse_text_to_map; use crate::util::invoke_sshd_config_validation; -/// Invoke the export command. +/// Invoke the export command and return a map. /// /// # Errors /// /// This function will return an error if the command cannot invoke sshd -T, parse the return, or convert it to json. -pub fn invoke_export() -> Result, SshdConfigError> { +/// +/// # Returns +/// +/// This function will return `Ok(Map)` if the export is successful. +pub fn invoke_export_to_map() -> Result, SshdConfigError> { let sshd_config_text = invoke_sshd_config_validation(None)?; let sshd_config: Map = parse_text_to_map(&sshd_config_text)?; Ok(sshd_config) } + +/// Invoke the export command and print the result as JSON. +/// +/// # Errors +/// This function will return an error if the export fails to retrieve the sshd configuration or convert it to JSON. +/// +/// # Returns +/// +/// This function will return `Ok(())` if the export is successful. +pub fn invoke_export() -> Result<(), SshdConfigError> { + let result = invoke_export_to_map()?; + let json = serde_json::to_string(&result)?; + println!("{json}"); + Ok(()) +} diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index a5188ebb..9adc24b8 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -14,7 +14,7 @@ use tracing::debug; use crate::args::Setting; use crate::error::SshdConfigError; -use crate::export::invoke_export; +use crate::export::invoke_export_to_map; use crate::util::extract_sshd_defaults; /// Invoke the get command. @@ -87,7 +87,7 @@ fn get_default_shell() -> Result<(), SshdConfigError> { } fn get_sshd_settings(exclude_defaults: bool, input: Option<&String>) -> Result<(), SshdConfigError> { - let mut result = invoke_export()?; + let mut result = invoke_export_to_map()?; if exclude_defaults { let defaults = extract_sshd_defaults()?; diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index 01026769..cd39e52e 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -14,8 +14,6 @@ use parser::SshdConfigParser; use set::invoke_set; use util::enable_tracing; -use crate::error::SshdConfigError; - mod args; mod error; mod export; @@ -38,18 +36,7 @@ fn main() { let result = match &args.command { Command::Export => { debug!("{}", t!("main.export").to_string()); - match invoke_export() { - Ok(output) => { - match serde_json::to_string(&output) { - Ok(json) => { - println!("{json}"); - Ok(()) - }, - Err(e) => Err(SshdConfigError::Json(e)), - } - }, - Err(e) => Err(e), - } + invoke_export() }, Command::Get { exclude_defaults, input, setting } => { invoke_get(*exclude_defaults, input.as_ref(), setting) From 812e1cca9b330af4fee4e0f97cc1c803a1554577 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 24 Jul 2025 16:34:20 -0400 Subject: [PATCH 06/27] update get to read _metadata --- dsc/tests/dsc_sshdconfig.tests.ps1 | 52 +-- sshdconfig/locales/en-us.toml | 2 + sshdconfig/src/args.rs | 2 - sshdconfig/src/get.rs | 34 +- sshdconfig/src/main.rs | 4 +- sshdconfig/src/util.rs | 50 ++- sshdconfig/sshd_config.dsc.resource.json | 393 +---------------------- 7 files changed, 106 insertions(+), 431 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 00862061..08994df9 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -35,10 +35,11 @@ resources: It 'Get works' -Skip:$skipTest { $out = dsc config get -i "$yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 - $out.resources.count | Should -Be 1 - $out.resources[0].properties | Should -Not -BeNullOrEmpty - $out.resources[0].properties.port[0] | Should -Be 22 - $out.resources[0].properties.passwordAuthentication[0] | Should -Be 'yes' + $out.results.count | Should -Be 1 + $out.results.metadata.defaults | Should -Be $true + $out.results.result.actualState | Should -Not -BeNullOrEmpty + $out.results.result.actualState.port | Should -Be 22 + $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' } It 'Get with a specific setting works' -Skip:$skipTest { @@ -58,27 +59,28 @@ resources: $out.results.count | Should -Be 1 $out.results.result.actualState.count | Should -Be 1 $out.results.result.actualState.passwordauthentication | Should -Be 'yes' + $out.results.result.actualState.port | Should -BeNullOrEmpty } -# TODO: dsc needs to pass metadata to the resource -# It 'Get with exclude defaults works' -Skip:$skipTest { -# $get_yaml = @' -# $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json -# metadata: -# Microsoft.DSC: -# securityContext: elevated -# resources: -# - name: sshdconfig -# type: Microsoft.OpenSSH.SSHD/sshd_config -# metadata: -# excludeDefaults: true -# properties: -# '@ -# $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 -# $LASTEXITCODE | Should -Be 0 -# $out.results.count | Should -Be 1 -# $out.results.result.actualState.count | Should -Be 1 -# $out.results.result.actualState.port | Should -Not -Be 22 -# $out.results.result.actualState.authorizedkeys | Should -Not -BeNullOrEmpty -# } + It 'Get with defaults excluded works' -Skip:$skipTest { + $get_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +metadata: + Microsoft.DSC: + securityContext: elevated +resources: +- name: sshdconfig + type: Microsoft.OpenSSH.SSHD/sshd_config + properties: + _metadata: + defaults: false +'@ + $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results.metadata.defaults | Should -Be $false + $out.results.result.actualState.count | Should -Be 1 + $out.results.result.actualState.port | Should -Not -Be 22 + $out.results.result.actualState.authorizedkeys | Should -Not -BeNullOrEmpty + } } \ No newline at end of file diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index b47b7e4a..1c24029b 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -19,6 +19,7 @@ registry = "Registry" [get] debugSetting = "Get setting:" +defaultsMustBeBoolean = "defaults value must be true or false" defaultShellCmdOptionMustBeString = "cmdOption must be a string" defaultShellEscapeArgsMustBe0Or1 = "'%{input}' must be a 0 or 1" defaultShellEscapeArgsMustBeDWord = "escapeArguments must be a DWord" @@ -55,5 +56,6 @@ shellPathDoesNotExist = "shell path does not exist: '%{shell}'" shellPathMustNotBeRelative = "shell path must not be relative" [util] +metadataMustBeObject = "_metadata must be an object" sshdElevation = "elevated security context required" tracingInitError = "Failed to initialize tracing" diff --git a/sshdconfig/src/args.rs b/sshdconfig/src/args.rs index 561f2a42..d397283d 100644 --- a/sshdconfig/src/args.rs +++ b/sshdconfig/src/args.rs @@ -16,8 +16,6 @@ pub struct Args { pub enum Command { /// Get default shell, eventually to be used for `sshd_config` and repeatable keywords Get { - #[clap(short = 'e', long, help = t!("args.getDefaults").to_string())] - exclude_defaults: bool, #[clap(short = 'i', long, help = t!("args.getInput").to_string())] input: Option, #[clap(short = 's', long, hide = true)] diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index 9adc24b8..a7f7b4fb 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -15,17 +15,17 @@ use tracing::debug; use crate::args::Setting; use crate::error::SshdConfigError; use crate::export::invoke_export_to_map; -use crate::util::extract_sshd_defaults; +use crate::util::{extract_metadata_from_input, extract_sshd_defaults}; /// Invoke the get command. /// /// # Errors /// /// This function will return an error if the desired settings cannot be retrieved. -pub fn invoke_get(exclude_defaults: bool, input: Option<&String>, setting: &Setting) -> Result<(), SshdConfigError> { +pub fn invoke_get(input: Option<&String>, setting: &Setting) -> Result<(), SshdConfigError> { debug!("{}: {:?}", t!("get.debugSetting").to_string(), setting); match *setting { - Setting::SshdConfig => get_sshd_settings(exclude_defaults, input), + Setting::SshdConfig => get_sshd_settings(input), Setting::WindowsGlobal => get_default_shell() } } @@ -86,9 +86,21 @@ fn get_default_shell() -> Result<(), SshdConfigError> { Err(SshdConfigError::InvalidInput(t!("get.windowsOnly").to_string())) } -fn get_sshd_settings(exclude_defaults: bool, input: Option<&String>) -> Result<(), SshdConfigError> { +fn get_sshd_settings(input: Option<&String>) -> Result<(), SshdConfigError> { let mut result = invoke_export_to_map()?; + let config = extract_metadata_from_input(input)?; + let mut exclude_defaults = false; + if !config.metadata.is_empty() { + if let Some(value) = config.metadata.get("defaults") { + if let Value::Bool(b) = value { + exclude_defaults = !b; + } else { + return Err(SshdConfigError::InvalidInput(t!("get.defaultsMustBeBoolean").to_string())); + } + } + } + if exclude_defaults { let defaults = extract_sshd_defaults()?; // Filter result based on default settings. @@ -105,11 +117,10 @@ fn get_sshd_settings(exclude_defaults: bool, input: Option<&String>) -> Result<( .collect(); } - if let Some(config) = input { + if !config.input.is_empty() { // Filter result based on the keys provided in the input JSON. // If a provided key is not found in the result, its value is null. - let input_config: Map = serde_json::from_str(config)?; - result = input_config + result = config.input .keys() .map(|key| { let value = result.get(key) @@ -120,6 +131,15 @@ fn get_sshd_settings(exclude_defaults: bool, input: Option<&String>) -> Result<( .collect(); } + let map = if config.metadata.is_empty() { + let mut map = Map::new(); + map.insert("defaults".to_string(), Value::Bool(!exclude_defaults)); + map + } else { + config.metadata + }; + result.insert("_metadata".to_string(), Value::Object(map)); + let json = serde_json::to_string(&result)?; println!("{json}"); Ok(()) diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index cd39e52e..593c181d 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -38,8 +38,8 @@ fn main() { debug!("{}", t!("main.export").to_string()); invoke_export() }, - Command::Get { exclude_defaults, input, setting } => { - invoke_get(*exclude_defaults, input.as_ref(), setting) + Command::Get { input, setting } => { + invoke_get(input.as_ref(), setting) }, Command::Schema { setting } => { debug!("{}; {:?}", t!("main.schema").to_string(), setting); diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index c842a2b4..18a51fc2 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -11,11 +11,27 @@ use tracing_subscriber::{EnvFilter, filter::LevelFilter, Layer, prelude::__traci use crate::error::SshdConfigError; use crate::parser::parse_text_to_map; -// create a struct for sshdconfig arguments +pub struct CommandInfo { + pub metadata: Map, + pub input: Map, +} + +impl CommandInfo { + /// Create a new `CommandInfo` instance. + pub fn new() -> Self { + Self { + metadata: Map::new(), + input: Map::new() + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct SshdCmdArgs { +pub struct SshdCommandArgs { + /// the path to the sshd_config file to be processed #[serde(skip_serializing_if = "Option::is_none")] filepath: Option, + /// additional arguments to pass to the sshd -T command #[serde(rename = "additionalArgs", skip_serializing_if = "Option::is_none")] additional_args: Option>, } @@ -48,7 +64,7 @@ pub fn enable_tracing() { /// # Errors /// /// This function will return an error if sshd -T fails to validate `sshd_config`. -pub fn invoke_sshd_config_validation(args: Option) -> Result { +pub fn invoke_sshd_config_validation(args: Option) -> Result { let sshd_command = if cfg!(target_os = "windows") { "sshd.exe" } else { @@ -105,7 +121,7 @@ pub fn extract_sshd_defaults() -> Result, SshdConfigError> { debug!("temporary file created at: {}", temp_path); let args = Some( - SshdCmdArgs { + SshdCommandArgs { filepath: Some(temp_path.clone()), additional_args: None, } @@ -121,3 +137,29 @@ pub fn extract_sshd_defaults() -> Result, SshdConfigError> { let sshd_config: Map = parse_text_to_map(&output)?; Ok(sshd_config) } + +/// Extract _metadata field from the input string, if it can be parsed as JSON. +/// +/// # Errors +/// +/// This function will return an error if it fails to parse the input string and if the _metadata field exists, extract it. +pub fn extract_metadata_from_input(input: Option<&String>) -> Result { + if let Some(inputs) = input { + let mut sshd_config: Map = serde_json::from_str(inputs.as_str())?; + let metadata; + if let Some(value) = sshd_config.remove("_metadata") { + if let Some(obj) = value.as_object().cloned() { + metadata = obj; + } else { + return Err(SshdConfigError::InvalidInput(t!("util.metadataMustBeObject").to_string())); + } + } else { + metadata = Map::new() + }; + return Ok(CommandInfo { + metadata, + input: sshd_config, + }) + } + Ok(CommandInfo::new()) +} diff --git a/sshdconfig/sshd_config.dsc.resource.json b/sshdconfig/sshd_config.dsc.resource.json index ea49d7c0..9c845be7 100644 --- a/sshdconfig/sshd_config.dsc.resource.json +++ b/sshdconfig/sshd_config.dsc.resource.json @@ -38,397 +38,8 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "sshdconfig", "type": "object", - "properties": { - "port": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "addressfamily": { - "type": "string", - "minLength": 1 - }, - "listenaddress": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "logingracetime": { - "type": "number" - }, - "x11displayoffset": { - "type": "number" - }, - "maxauthtries": { - "type": "number" - }, - "maxsessions": { - "type": "number" - }, - "clientaliveinterval": { - "type": "number" - }, - "clientalivecountmax": { - "type": "number" - }, - "requiredrsasize": { - "type": "number" - }, - "streamlocalbindmask": { - "type": "number" - }, - "unusedconnectiontimeout": { - "type": "string", - "minLength": 1 - }, - "permitrootlogin": { - "type": "string", - "minLength": 1 - }, - "ignorerhosts": { - "type": "string", - "minLength": 1 - }, - "ignoreuserknownhosts": { - "type": "string", - "minLength": 1 - }, - "hostbasedauthentication": { - "type": "string", - "minLength": 1 - }, - "hostbasedusesnamefrompacketonly": { - "type": "string", - "minLength": 1 - }, - "pubkeyauthentication": { - "type": "string", - "minLength": 1 - }, - "gssapiauthentication": { - "type": "string", - "minLength": 1 - }, - "gssapicleanupcredentials": { - "type": "string", - "minLength": 1 - }, - "passwordauthentication": { - "type": "string", - "minLength": 1 - }, - "kbdinteractiveauthentication": { - "type": "string", - "minLength": 1 - }, - "printmotd": { - "type": "string", - "minLength": 1 - }, - "printlastlog": { - "type": "string", - "minLength": 1 - }, - "x11forwarding": { - "type": "string", - "minLength": 1 - }, - "x11uselocalhost": { - "type": "string", - "minLength": 1 - }, - "permittty": { - "type": "string", - "minLength": 1 - }, - "permituserrc": { - "type": "string", - "minLength": 1 - }, - "strictmodes": { - "type": "string", - "minLength": 1 - }, - "tcpkeepalive": { - "type": "string", - "minLength": 1 - }, - "permitemptypasswords": { - "type": "string", - "minLength": 1 - }, - "compression": { - "type": "string", - "minLength": 1 - }, - "gatewayports": { - "type": "string", - "minLength": 1 - }, - "usedns": { - "type": "string", - "minLength": 1 - }, - "allowtcpforwarding": { - "type": "string", - "minLength": 1 - }, - "allowagentforwarding": { - "type": "string", - "minLength": 1 - }, - "disableforwarding": { - "type": "string", - "minLength": 1 - }, - "allowstreamlocalforwarding": { - "type": "string", - "minLength": 1 - }, - "streamlocalbindunlink": { - "type": "string", - "minLength": 1 - }, - "fingerprinthash": { - "type": "string", - "minLength": 1 - }, - "exposeauthinfo": { - "type": "string", - "minLength": 1 - }, - "pidfile": { - "type": "string", - "minLength": 1 - }, - "modulifile": { - "type": "string", - "minLength": 1 - }, - "xauthlocation": { - "type": "string", - "minLength": 1 - }, - "ciphers": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "macs": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "banner": { - "type": "string", - "minLength": 1 - }, - "forcecommand": { - "type": "string", - "minLength": 1 - }, - "chrootdirectory": { - "type": "string", - "minLength": 1 - }, - "trustedusercakeys": { - "type": "string", - "minLength": 1 - }, - "revokedkeys": { - "type": "string", - "minLength": 1 - }, - "securitykeyprovider": { - "type": "string", - "minLength": 1 - }, - "authorizedprincipalsfile": { - "type": "string", - "minLength": 1 - }, - "versionaddendum": { - "type": "string", - "minLength": 1 - }, - "authorizedkeyscommand": { - "type": "string", - "minLength": 1 - }, - "authorizedkeyscommanduser": { - "type": "string", - "minLength": 1 - }, - "authorizedprincipalscommand": { - "type": "string", - "minLength": 1 - }, - "authorizedprincipalscommanduser": { - "type": "string", - "minLength": 1 - }, - "hostkeyagent": { - "type": "string", - "minLength": 1 - }, - "kexalgorithms": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "casignaturealgorithms": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "hostbasedacceptedalgorithms": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "hostkeyalgorithms": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "pubkeyacceptedalgorithms": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "sshdsessionpath": { - "type": "string", - "minLength": 1 - }, - "persourcepenaltyexemptlist": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "loglevel": { - "type": "string", - "minLength": 1 - }, - "syslogfacility": { - "type": "string", - "minLength": 1 - }, - "authorizedkeysfile": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "hostkey": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "allowgroups": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "authenticationmethods": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "channeltimeout": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "maxstartups": { - "type": "string", - "minLength": 1 - }, - "persourcemaxstartups": { - "type": "string", - "minLength": 1 - }, - "persourcenetblocksize": { - "type": "string", - "minLength": 1 - }, - "permittunnel": { - "type": "string", - "minLength": 1 - }, - "ipqos": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "rekeylimit": { - "type": "string", - "minLength": 1 - }, - "permitopen": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "permitlisten": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "permituserenvironment": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - }, - "pubkeyauthoptions": { - "type": "string", - "minLength": 1 - }, - "persourcepenalties": { - "type": "array", - "items": { - "required": [], - "properties": {} - } - } - }, - "additionalProperties": false + "properties": {}, + "additionalProperties": true } } } From 8bf176d26ec6b8adb6dfbe3257e52440e5f47389 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 24 Jul 2025 16:59:36 -0400 Subject: [PATCH 07/27] support custom sshdconfig filepath for get tests --- dsc/tests/dsc_sshdconfig.tests.ps1 | 20 +++++++++++++++----- sshdconfig/locales/en-us.toml | 1 + sshdconfig/src/export.rs | 10 +++++----- sshdconfig/src/get.rs | 19 ++++++++++++++++--- sshdconfig/src/main.rs | 2 +- sshdconfig/src/util.rs | 4 ++-- 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 08994df9..886e871f 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -22,6 +22,14 @@ resources: type: Microsoft.OpenSSH.SSHD/sshd_config properties: '@ + # set a non-default value in a temporary sshd_config file + "LogLevel Debug3" | Set-Content -Path $TestDrive/test_sshd_config + } + + AfterAll { + if (Test-Path $TestDrive/test_sshd_config) { + Remove-Item -Path $TestDrive/test_sshd_config -Force + } } It 'Export works' -Skip:$skipTest { @@ -63,8 +71,9 @@ resources: } It 'Get with defaults excluded works' -Skip:$skipTest { - $get_yaml = @' -$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + $filepath = Join-Path $TestDrive 'test_sshd_config' + $get_yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: Microsoft.DSC: securityContext: elevated @@ -74,13 +83,14 @@ resources: properties: _metadata: defaults: false -'@ + filepath: $filepath +"@ $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 $out.results.count | Should -Be 1 $out.results.metadata.defaults | Should -Be $false $out.results.result.actualState.count | Should -Be 1 $out.results.result.actualState.port | Should -Not -Be 22 - $out.results.result.actualState.authorizedkeys | Should -Not -BeNullOrEmpty + $out.results.result.actualState.loglevel | Should -Be 'debug3' } -} \ No newline at end of file +} diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index 1c24029b..eb18eaff 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -24,6 +24,7 @@ defaultShellCmdOptionMustBeString = "cmdOption must be a string" defaultShellEscapeArgsMustBe0Or1 = "'%{input}' must be a 0 or 1" defaultShellEscapeArgsMustBeDWord = "escapeArguments must be a DWord" defaultShellMustBeString = "shell must be a string" +filepathMustBeString = "filePath must be a string" notImplemented = "get not yet implemented for Microsoft.OpenSSH.SSHD/sshd_config" windowsOnly = "Microsoft.OpenSSH.SSHD/Windows is only applicable to Windows" diff --git a/sshdconfig/src/export.rs b/sshdconfig/src/export.rs index 24977ec1..7dae345f 100644 --- a/sshdconfig/src/export.rs +++ b/sshdconfig/src/export.rs @@ -5,7 +5,7 @@ use serde_json::{Map, Value}; use crate::error::SshdConfigError; use crate::parser::parse_text_to_map; -use crate::util::invoke_sshd_config_validation; +use crate::util::{invoke_sshd_config_validation, SshdCommandArgs}; /// Invoke the export command and return a map. /// @@ -16,8 +16,8 @@ use crate::util::invoke_sshd_config_validation; /// # Returns /// /// This function will return `Ok(Map)` if the export is successful. -pub fn invoke_export_to_map() -> Result, SshdConfigError> { - let sshd_config_text = invoke_sshd_config_validation(None)?; +pub fn invoke_export_to_map(sshd_args: Option) -> Result, SshdConfigError> { + let sshd_config_text = invoke_sshd_config_validation(sshd_args)?; let sshd_config: Map = parse_text_to_map(&sshd_config_text)?; Ok(sshd_config) } @@ -30,8 +30,8 @@ pub fn invoke_export_to_map() -> Result, SshdConfigError> { /// # Returns /// /// This function will return `Ok(())` if the export is successful. -pub fn invoke_export() -> Result<(), SshdConfigError> { - let result = invoke_export_to_map()?; +pub fn invoke_export(sshd_args: Option) -> Result<(), SshdConfigError> { + let result = invoke_export_to_map(sshd_args)?; let json = serde_json::to_string(&result)?; println!("{json}"); Ok(()) diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index a7f7b4fb..26e821db 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -15,7 +15,7 @@ use tracing::debug; use crate::args::Setting; use crate::error::SshdConfigError; use crate::export::invoke_export_to_map; -use crate::util::{extract_metadata_from_input, extract_sshd_defaults}; +use crate::util::{extract_metadata_from_input, extract_sshd_defaults, SshdCommandArgs}; /// Invoke the get command. /// @@ -87,10 +87,9 @@ fn get_default_shell() -> Result<(), SshdConfigError> { } fn get_sshd_settings(input: Option<&String>) -> Result<(), SshdConfigError> { - let mut result = invoke_export_to_map()?; - let config = extract_metadata_from_input(input)?; let mut exclude_defaults = false; + let mut args = None; if !config.metadata.is_empty() { if let Some(value) = config.metadata.get("defaults") { if let Value::Bool(b) = value { @@ -99,8 +98,22 @@ fn get_sshd_settings(input: Option<&String>) -> Result<(), SshdConfigError> { return Err(SshdConfigError::InvalidInput(t!("get.defaultsMustBeBoolean").to_string())); } } + if let Some(filepath) = config.metadata.get("filepath") { + if let Value::String(path) = filepath { + args = Some( + SshdCommandArgs { + filepath: Some(path.clone()), + additional_args: None, + } + ); + } else { + return Err(SshdConfigError::InvalidInput(t!("get.filepathMustBeString").to_string())); + } + } } + let mut result = invoke_export_to_map(args)?; + if exclude_defaults { let defaults = extract_sshd_defaults()?; // Filter result based on default settings. diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index 593c181d..9d7faa94 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -36,7 +36,7 @@ fn main() { let result = match &args.command { Command::Export => { debug!("{}", t!("main.export").to_string()); - invoke_export() + invoke_export(None) }, Command::Get { input, setting } => { invoke_get(input.as_ref(), setting) diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index 18a51fc2..4c87f128 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -30,10 +30,10 @@ impl CommandInfo { pub struct SshdCommandArgs { /// the path to the sshd_config file to be processed #[serde(skip_serializing_if = "Option::is_none")] - filepath: Option, + pub filepath: Option, /// additional arguments to pass to the sshd -T command #[serde(rename = "additionalArgs", skip_serializing_if = "Option::is_none")] - additional_args: Option>, + pub additional_args: Option>, } /// Enable tracing. From b881d7da91194dfa965aad8fca7ebb650e906f6f Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 24 Jul 2025 17:03:40 -0400 Subject: [PATCH 08/27] update toml --- sshdconfig/locales/en-us.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index eb18eaff..4c17c9d1 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -1,7 +1,6 @@ _version = 1 [args] -getDefaults = "exclude defaults from sshd_config" getInput = "input to get from sshd_config" setInput = "input to set in sshd_config" From 894c9b0390d3036cbfa6303498cb553c952578e6 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 24 Jul 2025 17:04:46 -0400 Subject: [PATCH 09/27] add comment to struct --- sshdconfig/src/util.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index 4c87f128..7ccf612f 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -12,7 +12,9 @@ use crate::error::SshdConfigError; use crate::parser::parse_text_to_map; pub struct CommandInfo { + /// metadata provided with the command pub metadata: Map, + /// input provided with the command pub input: Map, } From 011bd8c370a5502001d6fc72849c5bbc59a59b80 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 24 Jul 2025 17:31:38 -0400 Subject: [PATCH 10/27] fix clippy --- sshdconfig/src/util.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index 7ccf612f..89659ffe 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -30,7 +30,7 @@ impl CommandInfo { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct SshdCommandArgs { - /// the path to the sshd_config file to be processed + /// the path to the `sshd_config` file to be processed #[serde(skip_serializing_if = "Option::is_none")] pub filepath: Option, /// additional arguments to pass to the sshd -T command @@ -156,8 +156,8 @@ pub fn extract_metadata_from_input(input: Option<&String>) -> Result Date: Thu, 24 Jul 2025 18:44:13 -0400 Subject: [PATCH 11/27] fix skip logic --- dsc/tests/dsc_sshdconfig.tests.ps1 | 81 +++++++++++++++++------------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 886e871f..26b23bab 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -3,6 +3,7 @@ Describe 'SSHDConfig resource tests' { BeforeAll { + $brewExists = ($null -ne (Get-Command brew -CommandType Application -ErrorAction Ignore)) $sshdExists = ($null -ne (Get-Command sshd -CommandType Application -ErrorAction Ignore)) $isAdmin = if ($IsWindows) { $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() @@ -11,7 +12,7 @@ Describe 'SSHDConfig resource tests' { else { [System.Environment]::UserName -eq 'root' } - $skipTest = -not ($sshdExists -and $isAdmin) + $runTest = $sshdExists -and $isAdmin $yaml = @' $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: @@ -32,47 +33,54 @@ resources: } } - It 'Export works' -Skip:$skipTest { - $out = dsc config export -i "$yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.resources.count | Should -Be 1 - $out.resources[0].properties | Should -Not -BeNullOrEmpty - $out.resources[0].properties.port[0] | Should -Be 22 + It 'Export works' { + if ($runTest) { + $out = dsc config export -i "$yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.resources.count | Should -Be 1 + $out.resources[0].properties | Should -Not -BeNullOrEmpty + $out.resources[0].properties.port[0] | Should -Be 22 + } } - It 'Get works' -Skip:$skipTest { - $out = dsc config get -i "$yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - $out.results.metadata.defaults | Should -Be $true - $out.results.result.actualState | Should -Not -BeNullOrEmpty - $out.results.result.actualState.port | Should -Be 22 - $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' + It 'Get works'{ + if ($runTest) { + $out = dsc config get -i "$yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results.metadata.defaults | Should -Be $true + $out.results.result.actualState | Should -Not -BeNullOrEmpty + $out.results.result.actualState.port | Should -Be 22 + $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' + } } - It 'Get with a specific setting works' -Skip:$skipTest { - $get_yaml = @' + It 'Get with a specific setting works' { + if ($runTest) { + $get_yaml = @' $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: - Microsoft.DSC: +Microsoft.DSC: securityContext: elevated resources: - name: sshdconfig - type: Microsoft.OpenSSH.SSHD/sshd_config - properties: +type: Microsoft.OpenSSH.SSHD/sshd_config +properties: passwordauthentication: 'no' '@ - $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - $out.results.result.actualState.count | Should -Be 1 - $out.results.result.actualState.passwordauthentication | Should -Be 'yes' - $out.results.result.actualState.port | Should -BeNullOrEmpty + $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results.result.actualState.count | Should -Be 1 + $out.results.result.actualState.passwordauthentication | Should -Be 'yes' + $out.results.result.actualState.port | Should -BeNullOrEmpty + } } - It 'Get with defaults excluded works' -Skip:$skipTest { - $filepath = Join-Path $TestDrive 'test_sshd_config' - $get_yaml = @" + It 'Get with defaults excluded works' { + if ($runTest) { + $filepath = Join-Path $TestDrive 'test_sshd_config' + $get_yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: Microsoft.DSC: @@ -85,12 +93,13 @@ resources: defaults: false filepath: $filepath "@ - $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - $out.results.metadata.defaults | Should -Be $false - $out.results.result.actualState.count | Should -Be 1 - $out.results.result.actualState.port | Should -Not -Be 22 - $out.results.result.actualState.loglevel | Should -Be 'debug3' + $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results.metadata.defaults | Should -Be $false + $out.results.result.actualState.count | Should -Be 1 + $out.results.result.actualState.port | Should -Not -Be 22 + $out.results.result.actualState.loglevel | Should -Be 'debug3' + } } } From af54285eb6957564968d04ab3c147306d07f5dc6 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 25 Jul 2025 14:25:53 -0400 Subject: [PATCH 12/27] fix i8n --- sshdconfig/locales/en-us.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index 4c17c9d1..d5559b78 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -10,7 +10,6 @@ invalidInput = "Invalid Input" io = "IO" json = "JSON" language = "Language" -notImplemented = "Not Implemented" parser = "Parser" parseInt = "Parse Integer" persist = "Persist" From 4695d565a7fde4a89cdf3810893803ff33f0928a Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 25 Jul 2025 15:17:38 -0400 Subject: [PATCH 13/27] Revert "fix i8n" This reverts commit af54285eb6957564968d04ab3c147306d07f5dc6. --- sshdconfig/locales/en-us.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index d5559b78..4c17c9d1 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -10,6 +10,7 @@ invalidInput = "Invalid Input" io = "IO" json = "JSON" language = "Language" +notImplemented = "Not Implemented" parser = "Parser" parseInt = "Parse Integer" persist = "Persist" From 3acbbddda6a55aab363aa2f57c5a173984f3cf8c Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 25 Jul 2025 15:17:59 -0400 Subject: [PATCH 14/27] fix i8n take 2 --- sshdconfig/locales/en-us.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index 4c17c9d1..3d92d1f9 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -24,7 +24,6 @@ defaultShellEscapeArgsMustBe0Or1 = "'%{input}' must be a 0 or 1" defaultShellEscapeArgsMustBeDWord = "escapeArguments must be a DWord" defaultShellMustBeString = "shell must be a string" filepathMustBeString = "filePath must be a string" -notImplemented = "get not yet implemented for Microsoft.OpenSSH.SSHD/sshd_config" windowsOnly = "Microsoft.OpenSSH.SSHD/Windows is only applicable to Windows" [main] From c72f6a59297c40215c49b51d7170204e605fb05b Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 25 Jul 2025 15:36:54 -0400 Subject: [PATCH 15/27] fix i8n take 3 --- sshdconfig/locales/en-us.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index 3d92d1f9..613f9954 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -10,7 +10,6 @@ invalidInput = "Invalid Input" io = "IO" json = "JSON" language = "Language" -notImplemented = "Not Implemented" parser = "Parser" parseInt = "Parse Integer" persist = "Persist" From 484003c52c41fd51e1496b7644b01e5a6b7b07e4 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 25 Jul 2025 17:04:01 -0400 Subject: [PATCH 16/27] use copilot suggestions --- dsc/tests/dsc_sshdconfig.tests.ps1 | 8 ++++---- sshdconfig/src/get.rs | 29 +++++++++++------------------ sshdconfig/src/util.rs | 12 ++++++------ 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 26b23bab..fc2867fb 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -60,12 +60,12 @@ resources: $get_yaml = @' $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: -Microsoft.DSC: - securityContext: elevated + Microsoft.DSC: + securityContext: elevated resources: - name: sshdconfig -type: Microsoft.OpenSSH.SSHD/sshd_config -properties: + type: Microsoft.OpenSSH.SSHD/sshd_config + properties: passwordauthentication: 'no' '@ $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index 26e821db..be8bd192 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -119,29 +119,22 @@ fn get_sshd_settings(input: Option<&String>) -> Result<(), SshdConfigError> { // Filter result based on default settings. // If a value in result is equal to the default, it will be excluded. // Note that this excludes all defaults, even if they are explicitly set in sshd_config. - result = result.into_iter() - .filter(|(key, value)| { - if let Some(default) = defaults.get(key) { - default != value - } else { - true - } - }) - .collect(); + result.retain(|key, value| { + if let Some(default) = defaults.get(key) { + default != value + } else { + true + } + }); } if !config.input.is_empty() { // Filter result based on the keys provided in the input JSON. // If a provided key is not found in the result, its value is null. - result = config.input - .keys() - .map(|key| { - let value = result.get(key) - .cloned() - .unwrap_or(Value::Null); - (key.clone(), value) - }) - .collect(); + result.retain(|key, _| config.input.contains_key(key)); + for key in config.input.keys() { + result.entry(key.clone()).or_insert(Value::Null); + } } let map = if config.metadata.is_empty() { diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index 89659ffe..469e1740 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -115,10 +115,11 @@ pub fn extract_sshd_defaults() -> Result, SshdConfigError> { .suffix(".tmp") .tempfile()?; + // on Windows, sshd cannot read from the file if it is still open let temp_path = temp_file.path().to_string_lossy().into_owned(); + // do not automatically delete the file when it goes out of scope let (file, path) = temp_file.keep()?; - - // close file so another process (sshd) can read it + // close the file handle to allow sshd to read it drop(file); debug!("temporary file created at: {}", temp_path); @@ -129,14 +130,13 @@ pub fn extract_sshd_defaults() -> Result, SshdConfigError> { } ); + // Clean up the temporary file regardless of success or failure let output = invoke_sshd_config_validation(args); - if let Err(e) = std::fs::remove_file(&path) { debug!("Failed to clean up temporary file {}: {}", path.display(), e); } - - let output = output?; - let sshd_config: Map = parse_text_to_map(&output)?; + let result = output?; + let sshd_config: Map = parse_text_to_map(&result)?; Ok(sshd_config) } From f5b619fada11e443ebedcd82cd4c2893b5db4ea8 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Mon, 28 Jul 2025 16:40:19 -0400 Subject: [PATCH 17/27] address Steve's feedback --- dsc/tests/dsc_sshdconfig.tests.ps1 | 90 ++++++++++++------------------ sshdconfig/src/export.rs | 20 +------ sshdconfig/src/get.rs | 64 ++++++--------------- sshdconfig/src/inputs.rs | 55 ++++++++++++++++++ sshdconfig/src/main.rs | 26 +++++++-- sshdconfig/src/set.rs | 9 ++- sshdconfig/src/util.rs | 59 +++++--------------- 7 files changed, 151 insertions(+), 172 deletions(-) create mode 100644 sshdconfig/src/inputs.rs diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index fc2867fb..9f2c3082 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -1,18 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +BeforeDiscovery { + if ($IsWindows) { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [System.Security.Principal.WindowsPrincipal]::new($identity) + $isElevated = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + } +} -Describe 'SSHDConfig resource tests' { +Describe 'SSHDConfig resource tests' -Skip:(!$IsWindows -or !$isElevated) { BeforeAll { - $brewExists = ($null -ne (Get-Command brew -CommandType Application -ErrorAction Ignore)) - $sshdExists = ($null -ne (Get-Command sshd -CommandType Application -ErrorAction Ignore)) - $isAdmin = if ($IsWindows) { - $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() - [System.Security.Principal.WindowsPrincipal]::new($identity).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) - } - else { - [System.Environment]::UserName -eq 'root' - } - $runTest = $sshdExists -and $isAdmin $yaml = @' $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: @@ -27,37 +24,26 @@ resources: "LogLevel Debug3" | Set-Content -Path $TestDrive/test_sshd_config } - AfterAll { - if (Test-Path $TestDrive/test_sshd_config) { - Remove-Item -Path $TestDrive/test_sshd_config -Force - } - } - It 'Export works' { - if ($runTest) { - $out = dsc config export -i "$yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.resources.count | Should -Be 1 - $out.resources[0].properties | Should -Not -BeNullOrEmpty - $out.resources[0].properties.port[0] | Should -Be 22 - } + $out = dsc config export -i "$yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.resources.count | Should -Be 1 + $out.resources[0].properties | Should -Not -BeNullOrEmpty + $out.resources[0].properties.port[0] | Should -Be 22 } It 'Get works'{ - if ($runTest) { - $out = dsc config get -i "$yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - $out.results.metadata.defaults | Should -Be $true - $out.results.result.actualState | Should -Not -BeNullOrEmpty - $out.results.result.actualState.port | Should -Be 22 - $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' - } + $out = dsc config get -i "$yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results.metadata.includeDefaults | Should -Be $true + $out.results.result.actualState | Should -Not -BeNullOrEmpty + $out.results.result.actualState.port | Should -Be 22 + $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' } It 'Get with a specific setting works' { - if ($runTest) { - $get_yaml = @' + $get_yaml = @' $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: Microsoft.DSC: @@ -68,19 +54,16 @@ resources: properties: passwordauthentication: 'no' '@ - $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - $out.results.result.actualState.count | Should -Be 1 - $out.results.result.actualState.passwordauthentication | Should -Be 'yes' - $out.results.result.actualState.port | Should -BeNullOrEmpty - } + $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + ($out.results.result.actualState.psobject.properties | measure-object).count | Should -Be 1 + $out.results.result.actualState.passwordauthentication | Should -Be 'yes' } It 'Get with defaults excluded works' { - if ($runTest) { - $filepath = Join-Path $TestDrive 'test_sshd_config' - $get_yaml = @" + $filepath = Join-Path $TestDrive 'test_sshd_config' + $get_yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: Microsoft.DSC: @@ -90,16 +73,15 @@ resources: type: Microsoft.OpenSSH.SSHD/sshd_config properties: _metadata: - defaults: false + includeDefaults: false filepath: $filepath "@ - $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - $out.results.metadata.defaults | Should -Be $false - $out.results.result.actualState.count | Should -Be 1 - $out.results.result.actualState.port | Should -Not -Be 22 - $out.results.result.actualState.loglevel | Should -Be 'debug3' - } + $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results.metadata.includeDefaults | Should -Be $false + $out.results.result.actualState.count | Should -Be 1 + $out.results.result.actualState.port | Should -Not -Be 22 + $out.results.result.actualState.loglevel | Should -Be 'debug3' } } diff --git a/sshdconfig/src/export.rs b/sshdconfig/src/export.rs index 7dae345f..d8e917bc 100644 --- a/sshdconfig/src/export.rs +++ b/sshdconfig/src/export.rs @@ -4,8 +4,9 @@ use serde_json::{Map, Value}; use crate::error::SshdConfigError; +use crate::inputs::SshdCommandArgs; use crate::parser::parse_text_to_map; -use crate::util::{invoke_sshd_config_validation, SshdCommandArgs}; +use crate::util::invoke_sshd_config_validation; /// Invoke the export command and return a map. /// @@ -16,23 +17,8 @@ use crate::util::{invoke_sshd_config_validation, SshdCommandArgs}; /// # Returns /// /// This function will return `Ok(Map)` if the export is successful. -pub fn invoke_export_to_map(sshd_args: Option) -> Result, SshdConfigError> { +pub fn invoke_export(sshd_args: Option) -> Result, SshdConfigError> { let sshd_config_text = invoke_sshd_config_validation(sshd_args)?; let sshd_config: Map = parse_text_to_map(&sshd_config_text)?; Ok(sshd_config) } - -/// Invoke the export command and print the result as JSON. -/// -/// # Errors -/// This function will return an error if the export fails to retrieve the sshd configuration or convert it to JSON. -/// -/// # Returns -/// -/// This function will return `Ok(())` if the export is successful. -pub fn invoke_export(sshd_args: Option) -> Result<(), SshdConfigError> { - let result = invoke_export_to_map(sshd_args)?; - let json = serde_json::to_string(&result)?; - println!("{json}"); - Ok(()) -} diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index be8bd192..b9069eb5 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -14,19 +14,22 @@ use tracing::debug; use crate::args::Setting; use crate::error::SshdConfigError; -use crate::export::invoke_export_to_map; -use crate::util::{extract_metadata_from_input, extract_sshd_defaults, SshdCommandArgs}; +use crate::export::invoke_export; +use crate::util::{extract_metadata_from_input, extract_sshd_defaults}; /// Invoke the get command. /// /// # Errors /// /// This function will return an error if the desired settings cannot be retrieved. -pub fn invoke_get(input: Option<&String>, setting: &Setting) -> Result<(), SshdConfigError> { +pub fn invoke_get(input: Option<&String>, setting: &Setting) -> Result, SshdConfigError> { debug!("{}: {:?}", t!("get.debugSetting").to_string(), setting); match *setting { Setting::SshdConfig => get_sshd_settings(input), - Setting::WindowsGlobal => get_default_shell() + Setting::WindowsGlobal => { + get_default_shell()?; + Ok(Map::new()) + } } } @@ -86,35 +89,11 @@ fn get_default_shell() -> Result<(), SshdConfigError> { Err(SshdConfigError::InvalidInput(t!("get.windowsOnly").to_string())) } -fn get_sshd_settings(input: Option<&String>) -> Result<(), SshdConfigError> { - let config = extract_metadata_from_input(input)?; - let mut exclude_defaults = false; - let mut args = None; - if !config.metadata.is_empty() { - if let Some(value) = config.metadata.get("defaults") { - if let Value::Bool(b) = value { - exclude_defaults = !b; - } else { - return Err(SshdConfigError::InvalidInput(t!("get.defaultsMustBeBoolean").to_string())); - } - } - if let Some(filepath) = config.metadata.get("filepath") { - if let Value::String(path) = filepath { - args = Some( - SshdCommandArgs { - filepath: Some(path.clone()), - additional_args: None, - } - ); - } else { - return Err(SshdConfigError::InvalidInput(t!("get.filepathMustBeString").to_string())); - } - } - } - - let mut result = invoke_export_to_map(args)?; +fn get_sshd_settings(input: Option<&String>) -> Result, SshdConfigError> { + let cmd_info = extract_metadata_from_input(input)?; + let mut result = invoke_export(cmd_info.sshd_args)?; - if exclude_defaults { + if !cmd_info.metadata.include_defaults { let defaults = extract_sshd_defaults()?; // Filter result based on default settings. // If a value in result is equal to the default, it will be excluded. @@ -128,25 +107,16 @@ fn get_sshd_settings(input: Option<&String>) -> Result<(), SshdConfigError> { }); } - if !config.input.is_empty() { + if !cmd_info.input.is_empty() { // Filter result based on the keys provided in the input JSON. // If a provided key is not found in the result, its value is null. - result.retain(|key, _| config.input.contains_key(key)); - for key in config.input.keys() { + result.retain(|key, _| cmd_info.input.contains_key(key)); + for key in cmd_info.input.keys() { result.entry(key.clone()).or_insert(Value::Null); } } - let map = if config.metadata.is_empty() { - let mut map = Map::new(); - map.insert("defaults".to_string(), Value::Bool(!exclude_defaults)); - map - } else { - config.metadata - }; - result.insert("_metadata".to_string(), Value::Object(map)); - - let json = serde_json::to_string(&result)?; - println!("{json}"); - Ok(()) + // Add the _metadata field to the result + result.insert("_metadata".to_string(), serde_json::to_value(cmd_info.metadata)?); + Ok(result) } diff --git a/sshdconfig/src/inputs.rs b/sshdconfig/src/inputs.rs new file mode 100644 index 00000000..cd409bcc --- /dev/null +++ b/sshdconfig/src/inputs.rs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub struct CommandInfo { + /// input provided with the command + pub input: Map, + /// metadata provided with the command + pub metadata: Metadata, + /// additional arguments for the call to sshd -T + pub sshd_args: Option +} + +impl CommandInfo { + /// Create a new `CommandInfo` instance. + pub fn new() -> Self { + Self { + input: Map::new(), + metadata: Metadata::new(), + sshd_args: None + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Metadata { + /// Filepath for the `sshd_config` file to be processed + #[serde(skip_serializing_if = "Option::is_none")] + pub filepath: Option, + /// Switch to include defaults in the output + #[serde(rename = "includeDefaults")] + pub include_defaults: bool, +} + +impl Metadata { + /// Create a new `Metadata` instance. + pub fn new() -> Self { + Self { + filepath: None, + include_defaults: true + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SshdCommandArgs { + /// the path to the `sshd_config` file to be processed + #[serde(skip_serializing_if = "Option::is_none")] + pub filepath: Option, + /// additional arguments to pass to the sshd -T command + #[serde(rename = "additionalArgs", skip_serializing_if = "Option::is_none")] + pub additional_args: Option>, +} \ No newline at end of file diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index 9d7faa94..c8b5ea36 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -4,6 +4,7 @@ use clap::{Parser}; use rust_i18n::{i18n, t}; use schemars::schema_for; +use serde_json::Map; use std::process::exit; use tracing::{debug, error}; @@ -18,6 +19,7 @@ mod args; mod error; mod export; mod get; +mod inputs; mod metadata; mod parser; mod set; @@ -52,7 +54,7 @@ fn main() { } }; println!("{}", serde_json::to_string(&schema).unwrap()); - Ok(()) + Ok(Map::new()) }, Command::Set { input } => { debug!("{}", t!("main.set", input = input).to_string()); @@ -60,10 +62,22 @@ fn main() { }, }; - if let Err(e) = result { - error!("{e}"); - exit(EXIT_FAILURE); + match result { + Ok(output) => { + if !output.is_empty() { + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => { + error!("{}", e); + exit(EXIT_FAILURE); + } + } + } + exit(EXIT_SUCCESS); + }, + Err(e) => { + error!("{}", e); + exit(EXIT_FAILURE); + } } - - exit(EXIT_SUCCESS); } diff --git a/sshdconfig/src/set.rs b/sshdconfig/src/set.rs index 349e36cf..90eb0b53 100644 --- a/sshdconfig/src/set.rs +++ b/sshdconfig/src/set.rs @@ -8,19 +8,22 @@ use { crate::metadata::windows::{DEFAULT_SHELL, DEFAULT_SHELL_CMD_OPTION, DEFAULT_SHELL_ESCAPE_ARGS, REGISTRY_PATH}, }; +use rust_i18n::t; +use serde_json::{Map, Value}; + use crate::args::DefaultShell; use crate::error::SshdConfigError; -use rust_i18n::t; /// Invoke the set command. /// /// # Errors /// /// This function will return an error if the desired settings cannot be applied. -pub fn invoke_set(input: &str) -> Result<(), SshdConfigError> { +pub fn invoke_set(input: &str) -> Result, SshdConfigError> { match serde_json::from_str::(input) { Ok(default_shell) => { - set_default_shell(default_shell.shell, default_shell.cmd_option, default_shell.escape_arguments) + set_default_shell(default_shell.shell, default_shell.cmd_option, default_shell.escape_arguments)?; + Ok(Map::new()) }, Err(e) => { Err(SshdConfigError::InvalidInput(t!("set.failedToParseInput", error = e).to_string())) diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index 469e1740..b4b47b55 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -2,42 +2,15 @@ // Licensed under the MIT License. use rust_i18n::t; -use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::process::Command; use tracing::debug; use tracing_subscriber::{EnvFilter, filter::LevelFilter, Layer, prelude::__tracing_subscriber_SubscriberExt}; use crate::error::SshdConfigError; +use crate::inputs::{CommandInfo, Metadata, SshdCommandArgs}; use crate::parser::parse_text_to_map; -pub struct CommandInfo { - /// metadata provided with the command - pub metadata: Map, - /// input provided with the command - pub input: Map, -} - -impl CommandInfo { - /// Create a new `CommandInfo` instance. - pub fn new() -> Self { - Self { - metadata: Map::new(), - input: Map::new() - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct SshdCommandArgs { - /// the path to the `sshd_config` file to be processed - #[serde(skip_serializing_if = "Option::is_none")] - pub filepath: Option, - /// additional arguments to pass to the sshd -T command - #[serde(rename = "additionalArgs", skip_serializing_if = "Option::is_none")] - pub additional_args: Option>, -} - /// Enable tracing. /// /// # Errors @@ -67,13 +40,7 @@ pub fn enable_tracing() { /// /// This function will return an error if sshd -T fails to validate `sshd_config`. pub fn invoke_sshd_config_validation(args: Option) -> Result { - let sshd_command = if cfg!(target_os = "windows") { - "sshd.exe" - } else { - "sshd" - }; - - let mut command = Command::new(sshd_command); + let mut command = Command::new("sshd"); command.arg("-T"); if let Some(args) = args { @@ -148,19 +115,21 @@ pub fn extract_sshd_defaults() -> Result, SshdConfigError> { pub fn extract_metadata_from_input(input: Option<&String>) -> Result { if let Some(inputs) = input { let mut sshd_config: Map = serde_json::from_str(inputs.as_str())?; - let metadata; - if let Some(value) = sshd_config.remove("_metadata") { - if let Some(obj) = value.as_object().cloned() { - metadata = obj; - } else { - return Err(SshdConfigError::InvalidInput(t!("util.metadataMustBeObject").to_string())); - } + let metadata: Metadata = if let Some(value) = sshd_config.remove("_metadata") { + serde_json::from_value(value)? } else { - metadata = Map::new(); - } + Metadata::new() + }; + let sshd_args = metadata.filepath.as_ref().map(|filepath| { + SshdCommandArgs { + filepath: Some(filepath.clone()), + additional_args: None, + } + }); return Ok(CommandInfo { - metadata, input: sshd_config, + metadata, + sshd_args }) } Ok(CommandInfo::new()) From 5e2ab58e3e8ddfb4aa4c3b1743fb65aa812c0aca Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Mon, 28 Jul 2025 16:59:02 -0400 Subject: [PATCH 18/27] Update en-us.toml --- sshdconfig/locales/en-us.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index 613f9954..e5f522c1 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -17,12 +17,10 @@ registry = "Registry" [get] debugSetting = "Get setting:" -defaultsMustBeBoolean = "defaults value must be true or false" defaultShellCmdOptionMustBeString = "cmdOption must be a string" defaultShellEscapeArgsMustBe0Or1 = "'%{input}' must be a 0 or 1" defaultShellEscapeArgsMustBeDWord = "escapeArguments must be a DWord" defaultShellMustBeString = "shell must be a string" -filepathMustBeString = "filePath must be a string" windowsOnly = "Microsoft.OpenSSH.SSHD/Windows is only applicable to Windows" [main] @@ -54,6 +52,5 @@ shellPathDoesNotExist = "shell path does not exist: '%{shell}'" shellPathMustNotBeRelative = "shell path must not be relative" [util] -metadataMustBeObject = "_metadata must be an object" sshdElevation = "elevated security context required" tracingInitError = "Failed to initialize tracing" From 64e1dfc518f8a31f064c7a5e8ed7a87f64d360c5 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Tue, 5 Aug 2025 16:04:41 -0400 Subject: [PATCH 19/27] add check for sshd in test discovery --- dsc/tests/dsc_sshdconfig.tests.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 9f2c3082..2d4753a6 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -5,10 +5,12 @@ BeforeDiscovery { $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $principal = [System.Security.Principal.WindowsPrincipal]::new($identity) $isElevated = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + $sshdExists = ($null -ne (Get-Command sshd -CommandType Application -ErrorAction Ignore)) + $skipTest = !$isElevated -or !$sshdExists } } -Describe 'SSHDConfig resource tests' -Skip:(!$IsWindows -or !$isElevated) { +Describe 'SSHDConfig resource tests' -Skip:(!$IsWindows -or $skipTest) { BeforeAll { $yaml = @' $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json From 8ad00adddf4f7af0a3b4842cce9d537372b1c901 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Wed, 6 Aug 2025 15:43:34 -0400 Subject: [PATCH 20/27] add newline --- sshdconfig/src/inputs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sshdconfig/src/inputs.rs b/sshdconfig/src/inputs.rs index cd409bcc..d71c1226 100644 --- a/sshdconfig/src/inputs.rs +++ b/sshdconfig/src/inputs.rs @@ -52,4 +52,4 @@ pub struct SshdCommandArgs { /// additional arguments to pass to the sshd -T command #[serde(rename = "additionalArgs", skip_serializing_if = "Option::is_none")] pub additional_args: Option>, -} \ No newline at end of file +} From dba561a4733636017ae956f8714a04b98b2b2203 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 7 Aug 2025 15:01:15 -0400 Subject: [PATCH 21/27] combine export and get command behavior --- dsc/tests/dsc_sshdconfig.tests.ps1 | 93 ++++++++++++++++-------- sshdconfig/src/args.rs | 2 - sshdconfig/src/export.rs | 24 ------ sshdconfig/src/get.rs | 7 +- sshdconfig/src/inputs.rs | 8 +- sshdconfig/src/main.rs | 6 -- sshdconfig/sshd_config.dsc.resource.json | 8 +- 7 files changed, 78 insertions(+), 70 deletions(-) delete mode 100644 sshdconfig/src/export.rs diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 2d4753a6..769e0a14 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -12,8 +12,8 @@ BeforeDiscovery { Describe 'SSHDConfig resource tests' -Skip:(!$IsWindows -or $skipTest) { BeforeAll { - $yaml = @' -$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + $yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: Microsoft.DSC: securityContext: elevated @@ -21,32 +21,43 @@ resources: - name: sshdconfig type: Microsoft.OpenSSH.SSHD/sshd_config properties: -'@ + _metadata: + filepath: $filepath +"@ # set a non-default value in a temporary sshd_config file "LogLevel Debug3" | Set-Content -Path $TestDrive/test_sshd_config } - It 'Export works' { - $out = dsc config export -i "$yaml" | ConvertFrom-Json -Depth 10 - $LASTEXITCODE | Should -Be 0 - $out.resources.count | Should -Be 1 - $out.resources[0].properties | Should -Not -BeNullOrEmpty - $out.resources[0].properties.port[0] | Should -Be 22 - } - It 'Get works'{ - $out = dsc config get -i "$yaml" | ConvertFrom-Json -Depth 10 + It ' works' -TestCases @( + @{ command = 'get' } + @{ command = 'export' } + ) { + param($command) + $out = dsc config $command -i "$yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - $out.results.metadata.includeDefaults | Should -Be $true - $out.results.result.actualState | Should -Not -BeNullOrEmpty - $out.results.result.actualState.port | Should -Be 22 - $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' + if ($command -eq 'export') { + $out.resources.count | Should -Be 1 + $out.resources[0].metadata.includeDefaults | Should -Be $true + $out.resources[0].properties | Should -Not -BeNullOrEmpty + $out.resources[0].properties.port[0] | Should -Be 22 + $out.resources[0].properties.passwordAuthentication | Should -Be 'yes' + } else { + $out.results.count | Should -Be 1 + $out.results.metadata.includeDefaults | Should -Be $true + $out.results.result.actualState | Should -Not -BeNullOrEmpty + $out.results.result.actualState.port | Should -Be 22 + $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' + } } - It 'Get with a specific setting works' { - $get_yaml = @' -$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + It ' with filter works' -TestCases @( + @{ command = 'get' } + @{ command = 'export' } + ) { + param($command) + $get_yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: Microsoft.DSC: securityContext: elevated @@ -55,15 +66,27 @@ resources: type: Microsoft.OpenSSH.SSHD/sshd_config properties: passwordauthentication: 'no' -'@ - $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 + _metadata: + filepath: $filepath +"@ + $out = dsc config $command -i "$get_yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - ($out.results.result.actualState.psobject.properties | measure-object).count | Should -Be 1 - $out.results.result.actualState.passwordauthentication | Should -Be 'yes' + if ($command -eq 'export') { + $out.resources.count | Should -Be 1 + ($out.resources[0].properties | Measure-Object).count | Should -Be 1 + $out.resources[0].properties.passwordAuthentication | Should -Be 'yes' + } else { + $out.results.count | Should -Be 1 + ($out.results.result.actualState.psobject.properties | Measure-Object).count | Should -Be 1 + $out.results.result.actualState.passwordauthentication | Should -Be 'yes' + } } - It 'Get with defaults excluded works' { + It ' with defaults excluded works' -TestCases @( + @{ command = 'get' } + @{ command = 'export' } + ) { + param($command) $filepath = Join-Path $TestDrive 'test_sshd_config' $get_yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json @@ -78,12 +101,18 @@ resources: includeDefaults: false filepath: $filepath "@ - $out = dsc config get -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $out = dsc config $command -i "$get_yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 - $out.results.count | Should -Be 1 - $out.results.metadata.includeDefaults | Should -Be $false - $out.results.result.actualState.count | Should -Be 1 - $out.results.result.actualState.port | Should -Not -Be 22 - $out.results.result.actualState.loglevel | Should -Be 'debug3' + if ($command -eq 'export') { + $out.resources.count | Should -Be 1 + $out.resources[0].metadata.includeDefaults | Should -Be $false + ($out.resources[0].properties | Measure-Object).count | Should -Be 1 + $out.resources[0].properties.loglevel | Should -Be 'debug3' + } else { + $out.results.count | Should -Be 1 + $out.results.metadata.includeDefaults | Should -Be $false + ($out.results.result.actualState.psobject.properties | Measure-Object).count | Should -Be 1 + $out.results.result.actualState.loglevel | Should -Be 'debug3' + } } } diff --git a/sshdconfig/src/args.rs b/sshdconfig/src/args.rs index d397283d..30c94b2c 100644 --- a/sshdconfig/src/args.rs +++ b/sshdconfig/src/args.rs @@ -26,8 +26,6 @@ pub enum Command { #[clap(short = 'i', long, help = t!("args.setInput").to_string())] input: String }, - /// Export `sshd_config` - Export, Schema { // Used to inform which schema to generate #[clap(short = 's', long, hide = true)] diff --git a/sshdconfig/src/export.rs b/sshdconfig/src/export.rs deleted file mode 100644 index d8e917bc..00000000 --- a/sshdconfig/src/export.rs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use serde_json::{Map, Value}; - -use crate::error::SshdConfigError; -use crate::inputs::SshdCommandArgs; -use crate::parser::parse_text_to_map; -use crate::util::invoke_sshd_config_validation; - -/// Invoke the export command and return a map. -/// -/// # Errors -/// -/// This function will return an error if the command cannot invoke sshd -T, parse the return, or convert it to json. -/// -/// # Returns -/// -/// This function will return `Ok(Map)` if the export is successful. -pub fn invoke_export(sshd_args: Option) -> Result, SshdConfigError> { - let sshd_config_text = invoke_sshd_config_validation(sshd_args)?; - let sshd_config: Map = parse_text_to_map(&sshd_config_text)?; - Ok(sshd_config) -} diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index b9069eb5..ed05b714 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -14,8 +14,8 @@ use tracing::debug; use crate::args::Setting; use crate::error::SshdConfigError; -use crate::export::invoke_export; -use crate::util::{extract_metadata_from_input, extract_sshd_defaults}; +use crate::parser::parse_text_to_map; +use crate::util::{extract_metadata_from_input, extract_sshd_defaults, invoke_sshd_config_validation}; /// Invoke the get command. /// @@ -91,7 +91,8 @@ fn get_default_shell() -> Result<(), SshdConfigError> { fn get_sshd_settings(input: Option<&String>) -> Result, SshdConfigError> { let cmd_info = extract_metadata_from_input(input)?; - let mut result = invoke_export(cmd_info.sshd_args)?; + let sshd_config_text = invoke_sshd_config_validation(cmd_info.sshd_args)?; + let mut result = parse_text_to_map(&sshd_config_text)?; if !cmd_info.metadata.include_defaults { let defaults = extract_sshd_defaults()?; diff --git a/sshdconfig/src/inputs.rs b/sshdconfig/src/inputs.rs index d71c1226..0de93f18 100644 --- a/sshdconfig/src/inputs.rs +++ b/sshdconfig/src/inputs.rs @@ -24,16 +24,20 @@ impl CommandInfo { } } -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Metadata { /// Filepath for the `sshd_config` file to be processed #[serde(skip_serializing_if = "Option::is_none")] pub filepath: Option, /// Switch to include defaults in the output - #[serde(rename = "includeDefaults")] + #[serde(rename = "includeDefaults", default = "include_defaults")] pub include_defaults: bool, } +pub fn include_defaults() -> bool { + true +} + impl Metadata { /// Create a new `Metadata` instance. pub fn new() -> Self { diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index c8b5ea36..3f599975 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -9,7 +9,6 @@ use std::process::exit; use tracing::{debug, error}; use args::{Args, Command, DefaultShell, Setting}; -use export::invoke_export; use get::invoke_get; use parser::SshdConfigParser; use set::invoke_set; @@ -17,7 +16,6 @@ use util::enable_tracing; mod args; mod error; -mod export; mod get; mod inputs; mod metadata; @@ -36,10 +34,6 @@ fn main() { let args = Args::parse(); let result = match &args.command { - Command::Export => { - debug!("{}", t!("main.export").to_string()); - invoke_export(None) - }, Command::Get { input, setting } => { invoke_get(input.as_ref(), setting) }, diff --git a/sshdconfig/sshd_config.dsc.resource.json b/sshdconfig/sshd_config.dsc.resource.json index 9c845be7..858b6510 100644 --- a/sshdconfig/sshd_config.dsc.resource.json +++ b/sshdconfig/sshd_config.dsc.resource.json @@ -30,7 +30,13 @@ "export": { "executable": "sshdconfig", "args": [ - "export" + "get", + "-s", + "sshd-config", + { + "jsonInputArg": "--input", + "mandatory": false + } ] }, "schema": { From ea0502481248dee5ba4be40bc566e2a7280dda67 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 14 Aug 2025 12:41:50 -0400 Subject: [PATCH 22/27] update get and export _includeDefaults behavior --- dsc/tests/dsc_sshdconfig.tests.ps1 | 100 ++++++++++++++--------- sshdconfig/locales/en-us.toml | 4 +- sshdconfig/src/args.rs | 7 +- sshdconfig/src/get.rs | 23 ++++-- sshdconfig/src/inputs.rs | 19 ++--- sshdconfig/src/main.rs | 10 ++- sshdconfig/src/util.rs | 17 +++- sshdconfig/sshd_config.dsc.resource.json | 4 +- 8 files changed, 118 insertions(+), 66 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 769e0a14..393d62c2 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -12,6 +12,9 @@ BeforeDiscovery { Describe 'SSHDConfig resource tests' -Skip:(!$IsWindows -or $skipTest) { BeforeAll { + # set a non-default value in a temporary sshd_config file + "LogLevel Debug3`nPasswordAuthentication no" | Set-Content -Path $TestDrive/test_sshd_config + $filepath = Join-Path $TestDrive 'test_sshd_config' $yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: @@ -24,39 +27,34 @@ resources: _metadata: filepath: $filepath "@ - # set a non-default value in a temporary sshd_config file - "LogLevel Debug3" | Set-Content -Path $TestDrive/test_sshd_config } - It ' works' -TestCases @( - @{ command = 'get' } - @{ command = 'export' } + @{ command = 'get'; default = $true } + @{ command = 'export'; default = $false } ) { - param($command) + param($command, $default) $out = dsc config $command -i "$yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 if ($command -eq 'export') { $out.resources.count | Should -Be 1 - $out.resources[0].metadata.includeDefaults | Should -Be $true + # $out.resources[0]._includeDefaults | Should -Be $default $out.resources[0].properties | Should -Not -BeNullOrEmpty - $out.resources[0].properties.port[0] | Should -Be 22 - $out.resources[0].properties.passwordAuthentication | Should -Be 'yes' + $out.resources[0].properties.port | Should -BeNullOrEmpty + $out.resources[0].properties.passwordAuthentication | Should -Be 'no' + $out.resources[0].properties._inheritedDefaults | Should -BeNullOrEmpty } else { $out.results.count | Should -Be 1 - $out.results.metadata.includeDefaults | Should -Be $true + # $out.results._includeDefaults | Should -Be $default $out.results.result.actualState | Should -Not -BeNullOrEmpty - $out.results.result.actualState.port | Should -Be 22 - $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' + $out.results.result.actualState.port[0] | Should -Be 22 + $out.results.result.actualState.passwordAuthentication | Should -Be 'no' + $out.results.result.actualState._inheritedDefaults | Should -Contain 'port' } } - It ' with filter works' -TestCases @( - @{ command = 'get' } - @{ command = 'export' } - ) { - param($command) - $get_yaml = @" + It 'Export with filter works' { + $export_yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: Microsoft.DSC: @@ -65,30 +63,24 @@ resources: - name: sshdconfig type: Microsoft.OpenSSH.SSHD/sshd_config properties: - passwordauthentication: 'no' + passwordauthentication: 'yes' _metadata: filepath: $filepath "@ - $out = dsc config $command -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $out = dsc config $command -i "$export_yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 - if ($command -eq 'export') { - $out.resources.count | Should -Be 1 - ($out.resources[0].properties | Measure-Object).count | Should -Be 1 - $out.resources[0].properties.passwordAuthentication | Should -Be 'yes' - } else { - $out.results.count | Should -Be 1 - ($out.results.result.actualState.psobject.properties | Measure-Object).count | Should -Be 1 - $out.results.result.actualState.passwordauthentication | Should -Be 'yes' - } + $out.resources.count | Should -Be 1 + ($out.resources[0].properties | Measure-Object).count | Should -Be 1 + $out.resources[0].properties.passwordAuthentication | Should -Be 'no' } - It ' with defaults excluded works' -TestCases @( - @{ command = 'get' } - @{ command = 'export' } + It ' with _includeDefaults specified works' -TestCases @( + @{ command = 'get'; includeDefaults = $false } + @{ command = 'export'; includeDefaults = $true } ) { - param($command) + param($command, $includeDefaults) $filepath = Join-Path $TestDrive 'test_sshd_config' - $get_yaml = @" + $input = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: Microsoft.DSC: @@ -97,22 +89,54 @@ resources: - name: sshdconfig type: Microsoft.OpenSSH.SSHD/sshd_config properties: + _includeDefaults: $includeDefaults _metadata: - includeDefaults: false filepath: $filepath "@ - $out = dsc config $command -i "$get_yaml" | ConvertFrom-Json -Depth 10 + $out = dsc config $command -i "$input" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 if ($command -eq 'export') { $out.resources.count | Should -Be 1 - $out.resources[0].metadata.includeDefaults | Should -Be $false + # $out.resources[0].metadata.includeDefaults | Should -Be $includeDefaults ($out.resources[0].properties | Measure-Object).count | Should -Be 1 $out.resources[0].properties.loglevel | Should -Be 'debug3' + $out.resources[0].properties._inheritedDefaults | Should -Contain 'port' } else { $out.results.count | Should -Be 1 - $out.results.metadata.includeDefaults | Should -Be $false + # $out.results.metadata.includeDefaults | Should -Be $includeDefaults ($out.results.result.actualState.psobject.properties | Measure-Object).count | Should -Be 1 $out.results.result.actualState.loglevel | Should -Be 'debug3' + $out.results.result.actualState._inheritedDefaults | Should -BeNullOrEmpty + } + } + + Context 'Explicit Default Setting Behavior' { + BeforeAll { + "Port 22" | Set-Content -Path $TestDrive/test_sshd_config + } + + It ' works' -TestCases @( + @{ command = 'get'; default = $true } + @{ command = 'export'; default = $false } + ) { + param($command, $default) + $out = dsc config $command -i "$yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + if ($command -eq 'export') { + $out.resources.count | Should -Be 1 + # $out.resources[0]._includeDefaults | Should -Be $default + $out.resources[0].properties | Should -Not -BeNullOrEmpty + $out.resources[0].properties.port[0] | Should -Be 22 + $out.resources[0].properties.passwordauthentication | Should -BeNullOrEmpty + $out.resources[0].properties._inheritedDefaults | Should -BeNullOrEmpty + } else { + $out.results.count | Should -Be 1 + # $out.results._includeDefaults | Should -Be $default + $out.results.result.actualState | Should -Not -BeNullOrEmpty + $out.results.result.actualState.port | Should -Be 22 + $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' + $out.results.result.actualState._inheritedDefaults | Should -Not -Contain 'port' + } } } } diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index e5f522c1..3393fc52 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -1,7 +1,8 @@ _version = 1 [args] -getInput = "input to get from sshd_config" +getInput = "input to get for sshd_config or default shell settings" +exportInput = "input to export from sshd_config" setInput = "input to set in sshd_config" [error] @@ -52,5 +53,6 @@ shellPathDoesNotExist = "shell path does not exist: '%{shell}'" shellPathMustNotBeRelative = "shell path must not be relative" [util] +inputMustBeEmpty = "get command does not support filtering based on input settings" sshdElevation = "elevated security context required" tracingInitError = "Failed to initialize tracing" diff --git a/sshdconfig/src/args.rs b/sshdconfig/src/args.rs index 30c94b2c..616ec407 100644 --- a/sshdconfig/src/args.rs +++ b/sshdconfig/src/args.rs @@ -14,7 +14,7 @@ pub struct Args { #[derive(Subcommand)] pub enum Command { - /// Get default shell, eventually to be used for `sshd_config` and repeatable keywords + /// Get default shell and `sshd_config`, eventually to be used for repeatable keywords Get { #[clap(short = 'i', long, help = t!("args.getInput").to_string())] input: Option, @@ -26,6 +26,11 @@ pub enum Command { #[clap(short = 'i', long, help = t!("args.setInput").to_string())] input: String }, + /// Export `sshd_config`, eventually to be used for repeatable keywords + Export { + #[clap(short = 'i', long, help = t!("args.exportInput").to_string())] + input: Option + }, Schema { // Used to inform which schema to generate #[clap(short = 's', long, hide = true)] diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index ed05b714..287a12bf 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -14,8 +14,9 @@ use tracing::debug; use crate::args::Setting; use crate::error::SshdConfigError; +use crate::inputs::CommandInfo; use crate::parser::parse_text_to_map; -use crate::util::{extract_metadata_from_input, extract_sshd_defaults, invoke_sshd_config_validation}; +use crate::util::{build_command_info, extract_sshd_defaults, invoke_sshd_config_validation}; /// Invoke the get command. /// @@ -25,7 +26,10 @@ use crate::util::{extract_metadata_from_input, extract_sshd_defaults, invoke_ssh pub fn invoke_get(input: Option<&String>, setting: &Setting) -> Result, SshdConfigError> { debug!("{}: {:?}", t!("get.debugSetting").to_string(), setting); match *setting { - Setting::SshdConfig => get_sshd_settings(input), + Setting::SshdConfig => { + let cmd_info = build_command_info(input, true)?; + get_sshd_settings(&cmd_info) + }, Setting::WindowsGlobal => { get_default_shell()?; Ok(Map::new()) @@ -89,12 +93,12 @@ fn get_default_shell() -> Result<(), SshdConfigError> { Err(SshdConfigError::InvalidInput(t!("get.windowsOnly").to_string())) } -fn get_sshd_settings(input: Option<&String>) -> Result, SshdConfigError> { - let cmd_info = extract_metadata_from_input(input)?; - let sshd_config_text = invoke_sshd_config_validation(cmd_info.sshd_args)?; +pub fn get_sshd_settings(cmd_info: &CommandInfo) -> Result, SshdConfigError> { + let sshd_config_text = invoke_sshd_config_validation(cmd_info.sshd_args.clone())?; let mut result = parse_text_to_map(&sshd_config_text)?; + let mut inherited_defaults: Vec = Vec::new(); - if !cmd_info.metadata.include_defaults { + if !cmd_info.include_defaults { let defaults = extract_sshd_defaults()?; // Filter result based on default settings. // If a value in result is equal to the default, it will be excluded. @@ -117,7 +121,10 @@ fn get_sshd_settings(input: Option<&String>) -> Result, SshdC } } - // Add the _metadata field to the result - result.insert("_metadata".to_string(), serde_json::to_value(cmd_info.metadata)?); + // does _metadata need to be checked if it has any value or will serde ignore during serialization? + result.insert("_metadata".to_string(), serde_json::to_value(cmd_info.metadata.clone())?); + if cmd_info.include_defaults { + result.insert("_inheritedDefaults".to_string(), serde_json::to_value(inherited_defaults)?); + } Ok(result) } diff --git a/sshdconfig/src/inputs.rs b/sshdconfig/src/inputs.rs index 0de93f18..208e98f4 100644 --- a/sshdconfig/src/inputs.rs +++ b/sshdconfig/src/inputs.rs @@ -4,7 +4,11 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct CommandInfo { + /// Switch to include defaults in the output + #[serde(rename = "_includeDefaults")] + pub include_defaults: bool, /// input provided with the command pub input: Map, /// metadata provided with the command @@ -15,8 +19,9 @@ pub struct CommandInfo { impl CommandInfo { /// Create a new `CommandInfo` instance. - pub fn new() -> Self { + pub fn new(is_get: bool) -> Self { Self { + include_defaults: is_get, input: Map::new(), metadata: Metadata::new(), sshd_args: None @@ -28,22 +33,14 @@ impl CommandInfo { pub struct Metadata { /// Filepath for the `sshd_config` file to be processed #[serde(skip_serializing_if = "Option::is_none")] - pub filepath: Option, - /// Switch to include defaults in the output - #[serde(rename = "includeDefaults", default = "include_defaults")] - pub include_defaults: bool, -} - -pub fn include_defaults() -> bool { - true + pub filepath: Option } impl Metadata { /// Create a new `Metadata` instance. pub fn new() -> Self { Self { - filepath: None, - include_defaults: true + filepath: None } } } diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index 3f599975..c0b59273 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -9,10 +9,10 @@ use std::process::exit; use tracing::{debug, error}; use args::{Args, Command, DefaultShell, Setting}; -use get::invoke_get; +use get::{get_sshd_settings, invoke_get}; use parser::SshdConfigParser; use set::invoke_set; -use util::enable_tracing; +use util::{build_command_info, enable_tracing}; mod args; mod error; @@ -34,6 +34,12 @@ fn main() { let args = Args::parse(); let result = match &args.command { + Command::Export { input } => { + match build_command_info(input.as_ref(), false) { + Ok(cmd_info) => get_sshd_settings(&cmd_info), + Err(e) => Err(e), + } + }, Command::Get { input, setting } => { invoke_get(input.as_ref(), setting) }, diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index b4b47b55..824c9541 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -112,7 +112,7 @@ pub fn extract_sshd_defaults() -> Result, SshdConfigError> { /// # Errors /// /// This function will return an error if it fails to parse the input string and if the _metadata field exists, extract it. -pub fn extract_metadata_from_input(input: Option<&String>) -> Result { +pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result { if let Some(inputs) = input { let mut sshd_config: Map = serde_json::from_str(inputs.as_str())?; let metadata: Metadata = if let Some(value) = sshd_config.remove("_metadata") { @@ -126,11 +126,24 @@ pub fn extract_metadata_from_input(input: Option<&String>) -> Result Date: Thu, 14 Aug 2025 15:37:54 -0400 Subject: [PATCH 23/27] add _inheritedDefaults functionality --- dsc/tests/dsc_sshdconfig.tests.ps1 | 27 ++++++--------- sshdconfig/locales/en-us.toml | 2 ++ sshdconfig/src/get.rs | 54 +++++++++++++++++++++++++----- sshdconfig/src/parser.rs | 5 ++- sshdconfig/src/util.rs | 37 +++++++++++++++++++- 5 files changed, 98 insertions(+), 27 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 393d62c2..bab97d43 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -30,22 +30,20 @@ resources: } It ' works' -TestCases @( - @{ command = 'get'; default = $true } - @{ command = 'export'; default = $false } + @{ command = 'get' } + @{ command = 'export' } ) { - param($command, $default) + param($command) $out = dsc config $command -i "$yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 if ($command -eq 'export') { $out.resources.count | Should -Be 1 - # $out.resources[0]._includeDefaults | Should -Be $default $out.resources[0].properties | Should -Not -BeNullOrEmpty $out.resources[0].properties.port | Should -BeNullOrEmpty $out.resources[0].properties.passwordAuthentication | Should -Be 'no' $out.resources[0].properties._inheritedDefaults | Should -BeNullOrEmpty } else { $out.results.count | Should -Be 1 - # $out.results._includeDefaults | Should -Be $default $out.results.result.actualState | Should -Not -BeNullOrEmpty $out.results.result.actualState.port[0] | Should -Be 22 $out.results.result.actualState.passwordAuthentication | Should -Be 'no' @@ -67,10 +65,10 @@ resources: _metadata: filepath: $filepath "@ - $out = dsc config $command -i "$export_yaml" | ConvertFrom-Json -Depth 10 + $out = dsc config export -i "$export_yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 $out.resources.count | Should -Be 1 - ($out.resources[0].properties | Measure-Object).count | Should -Be 1 + ($out.resources[0].properties.psobject.properties | Measure-Object).count | Should -Be 1 $out.resources[0].properties.passwordAuthentication | Should -Be 'no' } @@ -97,41 +95,36 @@ resources: $LASTEXITCODE | Should -Be 0 if ($command -eq 'export') { $out.resources.count | Should -Be 1 - # $out.resources[0].metadata.includeDefaults | Should -Be $includeDefaults - ($out.resources[0].properties | Measure-Object).count | Should -Be 1 $out.resources[0].properties.loglevel | Should -Be 'debug3' $out.resources[0].properties._inheritedDefaults | Should -Contain 'port' } else { $out.results.count | Should -Be 1 - # $out.results.metadata.includeDefaults | Should -Be $includeDefaults - ($out.results.result.actualState.psobject.properties | Measure-Object).count | Should -Be 1 + ($out.results.result.actualState.psobject.properties | Measure-Object).count | Should -Be 2 $out.results.result.actualState.loglevel | Should -Be 'debug3' $out.results.result.actualState._inheritedDefaults | Should -BeNullOrEmpty } } - Context 'Explicit Default Setting Behavior' { + Context 'Surface a default value that has been set in file' { BeforeAll { "Port 22" | Set-Content -Path $TestDrive/test_sshd_config } It ' works' -TestCases @( - @{ command = 'get'; default = $true } - @{ command = 'export'; default = $false } + @{ command = 'get' } + @{ command = 'export' } ) { - param($command, $default) + param($command) $out = dsc config $command -i "$yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 if ($command -eq 'export') { $out.resources.count | Should -Be 1 - # $out.resources[0]._includeDefaults | Should -Be $default $out.resources[0].properties | Should -Not -BeNullOrEmpty $out.resources[0].properties.port[0] | Should -Be 22 $out.resources[0].properties.passwordauthentication | Should -BeNullOrEmpty $out.resources[0].properties._inheritedDefaults | Should -BeNullOrEmpty } else { $out.results.count | Should -Be 1 - # $out.results._includeDefaults | Should -Be $default $out.results.result.actualState | Should -Not -BeNullOrEmpty $out.results.result.actualState.port | Should -Be 22 $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index 3393fc52..e67228bf 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -54,5 +54,7 @@ shellPathMustNotBeRelative = "shell path must not be relative" [util] inputMustBeEmpty = "get command does not support filtering based on input settings" +sshdConfigNotFound = "sshd_config not found at path: '%{path}'" +sshdConfigReadFailed = "failed to read sshd_config at path: '%{path}'" sshdElevation = "elevated security context required" tracingInitError = "Failed to initialize tracing" diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index 287a12bf..6c5a887f 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -16,7 +16,12 @@ use crate::args::Setting; use crate::error::SshdConfigError; use crate::inputs::CommandInfo; use crate::parser::parse_text_to_map; -use crate::util::{build_command_info, extract_sshd_defaults, invoke_sshd_config_validation}; +use crate::util::{ + build_command_info, + extract_sshd_defaults, + invoke_sshd_config_validation, + read_sshd_config +}; /// Invoke the get command. /// @@ -93,16 +98,47 @@ fn get_default_shell() -> Result<(), SshdConfigError> { Err(SshdConfigError::InvalidInput(t!("get.windowsOnly").to_string())) } +/// Retrieve sshd settings. +/// +/// # Arguments +/// +/// * `cmd_info` - CommandInfo struct containing optional filters, metadata, and includeDefaults flag. +/// +/// # Errors +/// +/// This function will return an error if it cannot retrieve the sshd settings. pub fn get_sshd_settings(cmd_info: &CommandInfo) -> Result, SshdConfigError> { let sshd_config_text = invoke_sshd_config_validation(cmd_info.sshd_args.clone())?; let mut result = parse_text_to_map(&sshd_config_text)?; let mut inherited_defaults: Vec = Vec::new(); - if !cmd_info.include_defaults { - let defaults = extract_sshd_defaults()?; - // Filter result based on default settings. - // If a value in result is equal to the default, it will be excluded. - // Note that this excludes all defaults, even if they are explicitly set in sshd_config. + // parse settings from sshd_config file + let sshd_config_file = read_sshd_config(cmd_info.metadata.filepath.clone())?; + let explicit_settings = parse_text_to_map(&sshd_config_file)?; + + // get default from SSHD -T with empty config + let mut defaults = extract_sshd_defaults()?; + + // remove any explicit keys from default settings list + for key in explicit_settings.keys() { + if defaults.contains_key(key) { + defaults.remove(key); + } + } + + if cmd_info.include_defaults { + // Update inherited_defaults with any keys that are not explicitly set + // check result for any keys that are in defaults + for (key, value) in &result { + if let Some(default) = defaults.get(key) { + if default == value { + inherited_defaults.push(key.clone()); + } + } + } + } else { + // Filter result based on default settings + // If a value in result is equal to the default, it will be excluded result.retain(|key, value| { if let Some(default) = defaults.get(key) { default != value @@ -116,13 +152,15 @@ pub fn get_sshd_settings(cmd_info: &CommandInfo) -> Result, S // Filter result based on the keys provided in the input JSON. // If a provided key is not found in the result, its value is null. result.retain(|key, _| cmd_info.input.contains_key(key)); + inherited_defaults.retain(|key| cmd_info.input.contains_key(key)); for key in cmd_info.input.keys() { result.entry(key.clone()).or_insert(Value::Null); } } - // does _metadata need to be checked if it has any value or will serde ignore during serialization? - result.insert("_metadata".to_string(), serde_json::to_value(cmd_info.metadata.clone())?); + if cmd_info.metadata.filepath.is_some() { + result.insert("_metadata".to_string(), serde_json::to_value(cmd_info.metadata.clone())?); + } if cmd_info.include_defaults { result.insert("_inheritedDefaults".to_string(), serde_json::to_value(inherited_defaults)?); } diff --git a/sshdconfig/src/parser.rs b/sshdconfig/src/parser.rs index b1e91b69..e76c073d 100644 --- a/sshdconfig/src/parser.rs +++ b/sshdconfig/src/parser.rs @@ -204,7 +204,10 @@ fn parse_arguments_node(arg_node: tree_sitter::Node, input: &str, input_bytes: & pub fn parse_text_to_map(input: &str) -> Result, SshdConfigError> { let mut parser = SshdConfigParser::new(); parser.parse_text(input)?; - Ok(parser.map) + let lowercased_map = parser.map.into_iter() + .map(|(k, v)| (k.to_lowercase(), v)) + .collect(); + Ok(lowercased_map) } #[cfg(test)] diff --git a/sshdconfig/src/util.rs b/sshdconfig/src/util.rs index 824c9541..d095f3bb 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -3,7 +3,7 @@ use rust_i18n::t; use serde_json::{Map, Value}; -use std::process::Command; +use std::{path::Path, process::Command}; use tracing::debug; use tracing_subscriber::{EnvFilter, filter::LevelFilter, Layer, prelude::__tracing_subscriber_SubscriberExt}; @@ -147,3 +147,38 @@ pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result) -> Result { + let sshd_config_path = if let Some(input) = input { + input + } else if cfg!(windows) { + let program_data = std::env::var("ProgramData").unwrap_or_else(|_| "C:\\ProgramData".into()); + format!("{program_data}\\ssh\\sshd_config") + } else { + "/etc/ssh/sshd_config".to_string() + }; + let filepath = Path::new(&sshd_config_path); + + if filepath.exists() { + let mut sshd_config_content = String::new(); + if let Ok(mut file) = std::fs::OpenOptions::new().read(true).open(filepath) { + use std::io::Read; + file.read_to_string(&mut sshd_config_content) + .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; + } else { + return Err(SshdConfigError::CommandError(t!("util.sshdConfigReadFailed", path = filepath.display()).to_string())); + } + Ok(sshd_config_content) + } else { + Err(SshdConfigError::CommandError(t!("util.sshdConfigNotFound", path = filepath.display()).to_string())) + } +} From 11702a0acc9bdfb8f0a45226daa44e7223b1ffdb Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 14 Aug 2025 15:43:13 -0400 Subject: [PATCH 24/27] update parser for match --- sshdconfig/src/parser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sshdconfig/src/parser.rs b/sshdconfig/src/parser.rs index e76c073d..53447ced 100644 --- a/sshdconfig/src/parser.rs +++ b/sshdconfig/src/parser.rs @@ -60,7 +60,7 @@ impl SshdConfigParser { } match node.kind() { "keyword" => self.parse_keyword_node(node, input, input_bytes), - "comment" | "empty_line" => Ok(()), + "comment" | "empty_line" | "match" => Ok(()), // TODO: do not ignore match nodes when parsing _ => Err(SshdConfigError::ParserError(t!("parser.unknownNodeType", node = node.kind()).to_string())), } } From 33ab8567ba0630905cb856faa360e66f1b0a2890 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 14 Aug 2025 15:59:09 -0400 Subject: [PATCH 25/27] modify export behavior --- dsc/tests/dsc_sshdconfig.tests.ps1 | 3 ++- sshdconfig/src/get.rs | 8 ++++---- sshdconfig/src/inputs.rs | 4 ++-- sshdconfig/src/main.rs | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index bab97d43..d1140293 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -96,7 +96,8 @@ resources: if ($command -eq 'export') { $out.resources.count | Should -Be 1 $out.resources[0].properties.loglevel | Should -Be 'debug3' - $out.resources[0].properties._inheritedDefaults | Should -Contain 'port' + $out.resources[0].properties.port | Should -Be 22 + $out.resources[0].properties._inheritedDefaults | Should -BeNullOrEmpty } else { $out.results.count | Should -Be 1 ($out.results.result.actualState.psobject.properties | Measure-Object).count | Should -Be 2 diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index 6c5a887f..76a493d3 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -33,7 +33,7 @@ pub fn invoke_get(input: Option<&String>, setting: &Setting) -> Result { let cmd_info = build_command_info(input, true)?; - get_sshd_settings(&cmd_info) + get_sshd_settings(&cmd_info, true) }, Setting::WindowsGlobal => { get_default_shell()?; @@ -102,12 +102,12 @@ fn get_default_shell() -> Result<(), SshdConfigError> { /// /// # Arguments /// -/// * `cmd_info` - CommandInfo struct containing optional filters, metadata, and includeDefaults flag. +/// * `cmd_info` - `CommandInfo` struct containing optional filters, metadata, and includeDefaults flag. /// /// # Errors /// /// This function will return an error if it cannot retrieve the sshd settings. -pub fn get_sshd_settings(cmd_info: &CommandInfo) -> Result, SshdConfigError> { +pub fn get_sshd_settings(cmd_info: &CommandInfo, is_get: bool) -> Result, SshdConfigError> { let sshd_config_text = invoke_sshd_config_validation(cmd_info.sshd_args.clone())?; let mut result = parse_text_to_map(&sshd_config_text)?; let mut inherited_defaults: Vec = Vec::new(); @@ -161,7 +161,7 @@ pub fn get_sshd_settings(cmd_info: &CommandInfo) -> Result, S if cmd_info.metadata.filepath.is_some() { result.insert("_metadata".to_string(), serde_json::to_value(cmd_info.metadata.clone())?); } - if cmd_info.include_defaults { + if cmd_info.include_defaults && is_get { result.insert("_inheritedDefaults".to_string(), serde_json::to_value(inherited_defaults)?); } Ok(result) diff --git a/sshdconfig/src/inputs.rs b/sshdconfig/src/inputs.rs index 208e98f4..97507d19 100644 --- a/sshdconfig/src/inputs.rs +++ b/sshdconfig/src/inputs.rs @@ -19,9 +19,9 @@ pub struct CommandInfo { impl CommandInfo { /// Create a new `CommandInfo` instance. - pub fn new(is_get: bool) -> Self { + pub fn new(include_defaults: bool) -> Self { Self { - include_defaults: is_get, + include_defaults, input: Map::new(), metadata: Metadata::new(), sshd_args: None diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index c0b59273..cddeebe5 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -36,7 +36,7 @@ fn main() { let result = match &args.command { Command::Export { input } => { match build_command_info(input.as_ref(), false) { - Ok(cmd_info) => get_sshd_settings(&cmd_info), + Ok(cmd_info) => get_sshd_settings(&cmd_info, false), Err(e) => Err(e), } }, From 2c348ef1490a6955f8b311d6dfbea76c51c8c11f Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 14 Aug 2025 16:16:14 -0400 Subject: [PATCH 26/27] add localization --- sshdconfig/locales/en-us.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index e67228bf..882c77e7 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -53,6 +53,7 @@ shellPathDoesNotExist = "shell path does not exist: '%{shell}'" shellPathMustNotBeRelative = "shell path must not be relative" [util] +includeDefaultsMustBeBoolean = "_includeDefaults must be true or false" inputMustBeEmpty = "get command does not support filtering based on input settings" sshdConfigNotFound = "sshd_config not found at path: '%{path}'" sshdConfigReadFailed = "failed to read sshd_config at path: '%{path}'" From d0c61da03579a2cbcccdc6b483556f4a3c91ae31 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 14 Aug 2025 16:44:49 -0400 Subject: [PATCH 27/27] fix localization --- sshdconfig/locales/en-us.toml | 2 +- sshdconfig/src/main.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index 882c77e7..ae70c7ed 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -25,7 +25,7 @@ defaultShellMustBeString = "shell must be a string" windowsOnly = "Microsoft.OpenSSH.SSHD/Windows is only applicable to Windows" [main] -export = "Export" +export = "Export command: %{input}" schema = "Schema command:" set = "Set command: '%{input}'" diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index cddeebe5..bf6440ee 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -35,6 +35,7 @@ fn main() { let result = match &args.command { Command::Export { input } => { + debug!("{}: {:?}", t!("main.export").to_string(), input); match build_command_info(input.as_ref(), false) { Ok(cmd_info) => get_sshd_settings(&cmd_info, false), Err(e) => Err(e),