diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 new file mode 100644 index 000000000..2d4753a6c --- /dev/null +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -0,0 +1,89 @@ +# 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) + $sshdExists = ($null -ne (Get-Command sshd -CommandType Application -ErrorAction Ignore)) + $skipTest = !$isElevated -or !$sshdExists + } +} + +Describe 'SSHDConfig resource tests' -Skip:(!$IsWindows -or $skipTest) { + BeforeAll { + $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: +'@ + # 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 + $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' { + $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.psobject.properties | measure-object).count | Should -Be 1 + $out.results.result.actualState.passwordauthentication | Should -Be 'yes' + } + + It 'Get with defaults excluded works' { + $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 +resources: +- name: sshdconfig + type: Microsoft.OpenSSH.SSHD/sshd_config + properties: + _metadata: + 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.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/Cargo.lock b/sshdconfig/Cargo.lock index 0980477fb..e352659f0 100644 --- a/sshdconfig/Cargo.lock +++ b/sshdconfig/Cargo.lock @@ -325,6 +325,24 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[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" @@ -511,7 +529,7 @@ checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -608,6 +626,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" @@ -967,6 +991,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "tempfile", "thiserror 2.0.12", "tracing", "tracing-subscriber", @@ -1010,6 +1035,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" @@ -1277,6 +1315,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.100" @@ -1643,3 +1690,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 96edba1bf..b0b9eacfa 100644 --- a/sshdconfig/Cargo.toml +++ b/sshdconfig/Cargo.toml @@ -21,6 +21,7 @@ rust-i18n = { version = "3.1" } schemars = "1.0" 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 = { version = "0.3.17", features = ["ansi", "env-filter", "json"] } diff --git a/sshdconfig/locales/en-us.toml b/sshdconfig/locales/en-us.toml index 8e3de9077..e5f522c1b 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -1,16 +1,18 @@ _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" parser = "Parser" parseInt = "Parse Integer" +persist = "Persist" registry = "Registry" [get] @@ -19,7 +21,6 @@ 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" -notImplemented = "get not yet implemented for Microsoft.OpenSSH.SSHD/sshd_config" windowsOnly = "Microsoft.OpenSSH.SSHD/Windows is only applicable to Windows" [main] diff --git a/sshdconfig/src/args.rs b/sshdconfig/src/args.rs index b8c595c05..d397283d6 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 53206aced..71c330186 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)] @@ -10,16 +11,18 @@ 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"))] 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/export.rs b/sshdconfig/src/export.rs index bc720c3a7..d8e917bc0 100644 --- a/sshdconfig/src/export.rs +++ b/sshdconfig/src/export.rs @@ -1,19 +1,24 @@ // 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. +/// 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> { - 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(()) +/// +/// # 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 bae350d20..b9069eb56 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -9,21 +9,27 @@ 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; +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(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 => Err(SshdConfigError::NotImplemented(t!("get.notImplemented").to_string())), - Setting::WindowsGlobal => get_default_shell() + Setting::SshdConfig => get_sshd_settings(input), + Setting::WindowsGlobal => { + get_default_shell()?; + Ok(Map::new()) + } } } @@ -82,3 +88,35 @@ 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 cmd_info = extract_metadata_from_input(input)?; + let mut result = invoke_export(cmd_info.sshd_args)?; + + 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. + // Note that this excludes all defaults, even if they are explicitly set in sshd_config. + result.retain(|key, value| { + if let Some(default) = defaults.get(key) { + default != value + } else { + true + } + }); + } + + 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, _| cmd_info.input.contains_key(key)); + for key in cmd_info.input.keys() { + result.entry(key.clone()).or_insert(Value::Null); + } + } + + // 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 000000000..cd409bcc5 --- /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 014aeee3f..c8b5ea36a 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; @@ -36,10 +38,10 @@ fn main() { let result = match &args.command { Command::Export => { debug!("{}", t!("main.export").to_string()); - invoke_export() + invoke_export(None) }, - Command::Get { setting } => { - invoke_get(setting) + Command::Get { input, setting } => { + invoke_get(input.as_ref(), setting) }, Command::Schema { setting } => { debug!("{}; {:?}", t!("main.schema").to_string(), setting); @@ -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 349e36cf3..90eb0b530 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 bff2a5dae..b4b47b553 100644 --- a/sshdconfig/src/util.rs +++ b/sshdconfig/src/util.rs @@ -2,11 +2,14 @@ // Licensed under the MIT License. use rust_i18n::t; +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; /// Enable tracing. /// @@ -36,16 +39,20 @@ 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 { - let sshd_command = if cfg!(target_os = "windows") { - "sshd.exe" - } else { - "sshd" - }; +pub fn invoke_sshd_config_validation(args: Option) -> Result { + let mut command = Command::new("sshd"); + 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.additional_args { + command.args(additional_args); + } + } - let output = Command::new(sshd_command) - .arg("-T") - .output() + let output = command.output() .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; if output.status.success() { @@ -63,3 +70,67 @@ 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> { + let temp_file = tempfile::Builder::new() + .prefix("sshd_config_empty_") + .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 the file handle to allow sshd to read it + drop(file); + + debug!("temporary file created at: {}", temp_path); + let args = Some( + SshdCommandArgs { + filepath: Some(temp_path.clone()), + additional_args: None, + } + ); + + // 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 result = output?; + let sshd_config: Map = parse_text_to_map(&result)?; + 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: Metadata = if let Some(value) = sshd_config.remove("_metadata") { + serde_json::from_value(value)? + } else { + Metadata::new() + }; + let sshd_args = metadata.filepath.as_ref().map(|filepath| { + SshdCommandArgs { + filepath: Some(filepath.clone()), + additional_args: None, + } + }); + return Ok(CommandInfo { + input: sshd_config, + metadata, + sshd_args + }) + } + Ok(CommandInfo::new()) +} diff --git a/sshdconfig/sshd_config.dsc.resource.json b/sshdconfig/sshd_config.dsc.resource.json index c18dd7d9a..9c845be7a 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": [ @@ -10,13 +34,12 @@ ] }, "schema": { - "command": { - "executable": "sshdconfig", - "args": [ - "schema", - "-s", - "sshd-config" - ] - } + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "sshdconfig", + "type": "object", + "properties": {}, + "additionalProperties": true + } } }