diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 new file mode 100644 index 000000000..d11402935 --- /dev/null +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -0,0 +1,136 @@ +# 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 { + # 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: + Microsoft.DSC: + securityContext: elevated +resources: +- name: sshdconfig + type: Microsoft.OpenSSH.SSHD/sshd_config + properties: + _metadata: + filepath: $filepath +"@ + } + + It ' works' -TestCases @( + @{ command = 'get' } + @{ command = 'export' } + ) { + 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].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.result.actualState | Should -Not -BeNullOrEmpty + $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 'Export with filter works' { + $export_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: 'yes' + _metadata: + filepath: $filepath +"@ + $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.psobject.properties | Measure-Object).count | Should -Be 1 + $out.resources[0].properties.passwordAuthentication | Should -Be 'no' + } + + It ' with _includeDefaults specified works' -TestCases @( + @{ command = 'get'; includeDefaults = $false } + @{ command = 'export'; includeDefaults = $true } + ) { + param($command, $includeDefaults) + $filepath = Join-Path $TestDrive 'test_sshd_config' + $input = @" +`$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: + _includeDefaults: $includeDefaults + _metadata: + filepath: $filepath +"@ + $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].properties.loglevel | Should -Be 'debug3' + $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 + $out.results.result.actualState.loglevel | Should -Be 'debug3' + $out.results.result.actualState._inheritedDefaults | Should -BeNullOrEmpty + } + } + + 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' } + @{ command = 'export' } + ) { + 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].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.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/Cargo.lock b/sshdconfig/Cargo.lock index 2299fa0d7..bd605d643 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..ae70c7ed1 100644 --- a/sshdconfig/locales/en-us.toml +++ b/sshdconfig/locales/en-us.toml @@ -1,16 +1,19 @@ _version = 1 [args] +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] 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,11 +22,10 @@ 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] -export = "Export" +export = "Export command: %{input}" schema = "Schema command:" set = "Set command: '%{input}'" @@ -51,5 +53,9 @@ 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}'" sshdElevation = "elevated security context required" tracingInitError = "Failed to initialize tracing" diff --git a/sshdconfig/src/args.rs b/sshdconfig/src/args.rs index b8c595c05..616ec4073 100644 --- a/sshdconfig/src/args.rs +++ b/sshdconfig/src/args.rs @@ -14,8 +14,10 @@ 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, #[clap(short = 's', long, hide = true)] setting: Setting, }, @@ -24,8 +26,11 @@ pub enum Command { #[clap(short = 'i', long, help = t!("args.setInput").to_string())] input: String }, - /// Export `sshd_config` - Export, + /// 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/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 deleted file mode 100644 index bc720c3a7..000000000 --- a/sshdconfig/src/export.rs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use crate::error::SshdConfigError; -use crate::parser::parse_text_to_map; -use crate::util::invoke_sshd_config_validation; - -/// Invoke the export command. -/// -/// # 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(()) -} diff --git a/sshdconfig/src/get.rs b/sshdconfig/src/get.rs index bae350d20..76a493d38 100644 --- a/sshdconfig/src/get.rs +++ b/sshdconfig/src/get.rs @@ -9,21 +9,36 @@ use { }; use rust_i18n::t; +use serde_json::{Map, Value}; 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::{ + build_command_info, + extract_sshd_defaults, + invoke_sshd_config_validation, + read_sshd_config +}; /// 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 => { + let cmd_info = build_command_info(input, true)?; + get_sshd_settings(&cmd_info, true) + }, + Setting::WindowsGlobal => { + get_default_shell()?; + Ok(Map::new()) + } } } @@ -82,3 +97,72 @@ fn get_default_shell() -> Result<(), SshdConfigError> { 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, 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(); + + // 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 + } 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)); + 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); + } + } + + 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 && 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 new file mode 100644 index 000000000..97507d196 --- /dev/null +++ b/sshdconfig/src/inputs.rs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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 + 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(include_defaults: bool) -> Self { + Self { + include_defaults, + input: Map::new(), + metadata: Metadata::new(), + sshd_args: None + } + } +} + +#[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 +} + +impl Metadata { + /// Create a new `Metadata` instance. + pub fn new() -> Self { + Self { + filepath: None + } + } +} + +#[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>, +} diff --git a/sshdconfig/src/main.rs b/sshdconfig/src/main.rs index 014aeee3f..bf6440ee5 100644 --- a/sshdconfig/src/main.rs +++ b/sshdconfig/src/main.rs @@ -4,20 +4,20 @@ use clap::{Parser}; use rust_i18n::{i18n, t}; use schemars::schema_for; +use serde_json::Map; use std::process::exit; use tracing::{debug, error}; use args::{Args, Command, DefaultShell, Setting}; -use export::invoke_export; -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; -mod export; mod get; +mod inputs; mod metadata; mod parser; mod set; @@ -34,12 +34,15 @@ fn main() { let args = Args::parse(); let result = match &args.command { - Command::Export => { - debug!("{}", t!("main.export").to_string()); - invoke_export() + 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), + } }, - 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 +55,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 +63,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/parser.rs b/sshdconfig/src/parser.rs index b1e91b69d..53447ced1 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())), } } @@ -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/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..d095f3bb8 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 std::process::Command; +use serde_json::{Map, Value}; +use std::{path::Path, 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,115 @@ 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 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") { + 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, + } + }); + let include_defaults: bool = if let Some(value) = sshd_config.remove("_includeDefaults") { + if let Value::Bool(b) = value { + b + } else { + return Err(SshdConfigError::InvalidInput(t!("util.includeDefaultsMustBeBoolean").to_string())); + } + } else { + is_get + }; + if is_get && !sshd_config.is_empty() { + return Err(SshdConfigError::InvalidInput(t!("util.inputMustBeEmpty").to_string())); + } + return Ok(CommandInfo { + include_defaults, + input: sshd_config, + metadata, + sshd_args + }) + } + Ok(CommandInfo::new(is_get)) +} + +/// Reads `sshd_config` file. +/// +/// # Arguments +/// +/// * `input` - Optional string with `sshd_config` filepath. +/// +/// # Errors +/// +/// This function will return an error if the file cannot be found or read. +pub fn read_sshd_config(input: Option) -> 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())) + } +} diff --git a/sshdconfig/sshd_config.dsc.resource.json b/sshdconfig/sshd_config.dsc.resource.json index c18dd7d9a..aafa95d6f 100644 --- a/sshdconfig/sshd_config.dsc.resource.json +++ b/sshdconfig/sshd_config.dsc.resource.json @@ -3,20 +3,47 @@ "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": [ - "export" + "export", + { + "jsonInputArg": "--input", + "mandatory": false + } ] }, "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 + } } }