From a4ddda6312ab74c54831bd43285d39f211bab2d4 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 4 Dec 2025 15:43:41 -0500 Subject: [PATCH 1/8] add E2E test for sshdconfig set --- dsc/tests/dsc_sshdconfig.tests.ps1 | 23 +++++++++++++++++++++++ resources/sshdconfig/locales/en-us.toml | 2 +- resources/sshdconfig/src/util.rs | 4 ++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index ce58f85b4..6d65c26d9 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -133,4 +133,27 @@ resources: } } } + + Context 'Set Commands' { + It 'Set works with _clobber: true' { + $set_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: + filepath: $filepath + properties: + _clobber: true + port: 1234 +"@ + $out = dsc config set -i "$set_yaml" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results[0].actualState.port | Should -Be 1234 + } + } } diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 5cf74214b..168aad710 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -70,8 +70,8 @@ writingTempConfig = "Writing temporary sshd_config file" [util] cleanupFailed = "Failed to clean up temporary file %{path}: %{error}" +getIgnoresInputFilters = "get command does not support filtering based on input settings, provided input will be ignored" inputMustBeBoolean = "value of '%{input}' 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" diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index ea4fcea54..a7773d85a 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -4,7 +4,7 @@ use rust_i18n::t; use serde_json::{Map, Value}; use std::{path::PathBuf, process::Command}; -use tracing::debug; +use tracing::{debug, warn}; use tracing_subscriber::{EnvFilter, filter::LevelFilter, Layer, prelude::__tracing_subscriber_SubscriberExt}; use crate::error::SshdConfigError; @@ -146,7 +146,7 @@ pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result Date: Thu, 4 Dec 2025 17:03:55 -0500 Subject: [PATCH 2/8] add set test for various keyword types --- dsc/tests/dsc_sshdconfig.tests.ps1 | 19 +++++++++++++++- .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 22 +++++++++++-------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 9a941eed7..28f931186 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -149,11 +149,28 @@ resources: properties: _clobber: true port: 1234 + allowusers: + - user1 + - user2 + passwordauthentication: $false + ciphers: + - aes128-ctr + - aes192-ctr + - aes256-ctr + addressfamily: inet6 + authorizedkeysfile: + - ./.ssh/authorized_keys + - ./.ssh/authorized_keys2 "@ $out = dsc config set -i "$set_yaml" | ConvertFrom-Json -Depth 10 $LASTEXITCODE | Should -Be 0 $out.results.count | Should -Be 1 - $out.results[0].actualState.port | Should -Be 1234 + $out.results.result.afterState.port | Should -Be 1234 + $out.results.result.afterState.passwordauthentication | Should -Be $false + $out.results.result.afterState.ciphers | Should -Be @('aes128-ctr', 'aes192-ctr', 'aes256-ctr') + $out.results.result.afterState.allowusers | Should -Be @('user1', 'user2') + $out.results.result.afterState.addressfamily | Should -Be 'inet6' + $out.results.result.afterState.authorizedkeysfile | Should -Be @('./.ssh/authorized_keys', './.ssh/authorized_keys2') } } } diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index b369ce3b8..6fbc51848 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -37,6 +37,11 @@ Describe 'sshd_config Set Tests' -Skip:(!$IsWindows -or $skipTest) { } _clobber = $true Port = "1234" + passwordauthentication = $false + allowusers = @("user1", "user2") + ciphers = @("aes128-ctr", "aes192-ctr", "aes256-ctr") + addressfamily = "inet6" + authorizedkeysfile = @(".ssh/authorized_keys", ".ssh/authorized_keys2") } | ConvertTo-Json $output = sshdconfig set --input $inputConfig -s sshd-config 2>$null @@ -44,15 +49,14 @@ Describe 'sshd_config Set Tests' -Skip:(!$IsWindows -or $skipTest) { # Verify file was created Test-Path $TestConfigPath | Should -Be $true - - # Verify content using get - $getInput = @{ - _metadata = @{ - filepath = $TestConfigPath - } - } | ConvertTo-Json - $result = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json - $result.Port | Should -Be "1234" + $sshdConfigContents = Get-Content $TestConfigPath + $sshdConfigContents | Should -Contain "Port 1234" + $sshdConfigContents | Should -Contain "PasswordAuthentication no" + $sshdConfigContents | Should -Contain "AllowUsers user1" + $sshdConfigContents | Should -Contain "AllowUsers user2" + $sshdConfigContents | Should -Contain "Ciphers aes128-ctr,aes192-ctr,aes256-ctr" + $sshdConfigContents | Should -Contain "AddressFamily inet6" + $sshdConfigContents | Should -Contain "AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2" } It 'Should create backup when file exists and is not managed by DSC' { From 6585cd3e1cb1fa61a3932148b5992b1d54ef8bc9 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 4 Dec 2025 17:04:32 -0500 Subject: [PATCH 3/8] expand set display capabilities --- resources/sshdconfig/src/metadata.rs | 32 ++++++------ resources/sshdconfig/src/parser.rs | 8 +-- resources/sshdconfig/src/set.rs | 31 +++++++++--- resources/sshdconfig/src/util.rs | 75 +++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 25 deletions(-) diff --git a/resources/sshdconfig/src/metadata.rs b/resources/sshdconfig/src/metadata.rs index fc0517f21..e489d80a7 100644 --- a/resources/sshdconfig/src/metadata.rs +++ b/resources/sshdconfig/src/metadata.rs @@ -1,34 +1,39 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// note that it is possible for a keyword to be in one, neither, or both of the multi-arg and repeatable lists below. +// the multi-arg comma-separated and space-separated lists are mutually exclusive, but the repeatable list can overlap with either of them. +// the mult-arg lists are maintained for formatting arrays into the correct format when writing back to the config file. // keywords that can have multiple comma-separated arguments per line and should be represented as arrays. -pub const MULTI_ARG_KEYWORDS: [&str; 22] = [ - "acceptenv", - "allowgroups", - "allowusers", +pub const MULTI_ARG_KEYWORDS_COMMA_SEP: [&str; 11] = [ "authenticationmethods", - "authorizedkeysfile", "casignaturealgorithms", - "channeltimeout", "ciphers", - "denygroups", - "denyusers", "hostbasedacceptedalgorithms", "hostkeyalgorithms", - "ipqos", "kexalgorithms", "macs", - "permitlisten", - "permitopen", "permituserenvironment", - "persourcepenalties", "persourcepenaltyexemptlist", "pubkeyacceptedalgorithms", "rekeylimit" // first arg is bytes, second arg (optional) is amount of time ]; +// keywords that can have multiple space-separated argments per line and should be represented as arrays. +pub const MULTI_ARG_KEYWORDS_SPACE_SEP: [&str; 11] = [ + "acceptenv", + "allowgroups", + "allowusers", + "authorizedkeysfile", + "channeltimeout", + "denygroups", + "denyusers", + "ipqos", + "permitlisten", + "permitopen", + "persourcepenalties", +]; + // keywords that can be repeated over multiple lines and should be represented as arrays. pub const REPEATABLE_KEYWORDS: [&str; 12] = [ "acceptenv", @@ -45,7 +50,6 @@ pub const REPEATABLE_KEYWORDS: [&str; 12] = [ "subsystem" ]; - pub const SSHD_CONFIG_HEADER: &str = "# This file is managed by the Microsoft.OpenSSH.SSHD/sshd_config DSC Resource"; pub const SSHD_CONFIG_HEADER_VERSION: &str = concat!("# The Microsoft.OpenSSH.SSHD/sshd_config DSC Resource version is ", env!("CARGO_PKG_VERSION")); pub const SSHD_CONFIG_HEADER_WARNING: &str = "# Please do not modify manually, as any changes may be overwritten"; diff --git a/resources/sshdconfig/src/parser.rs b/resources/sshdconfig/src/parser.rs index fae4c6070..a272b56c3 100644 --- a/resources/sshdconfig/src/parser.rs +++ b/resources/sshdconfig/src/parser.rs @@ -8,7 +8,7 @@ use tracing::debug; use tree_sitter::Parser; use crate::error::SshdConfigError; -use crate::metadata::{MULTI_ARG_KEYWORDS, REPEATABLE_KEYWORDS}; +use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, MULTI_ARG_KEYWORDS_SPACE_SEP, REPEATABLE_KEYWORDS}; #[derive(Debug, JsonSchema)] pub struct SshdConfigParser { @@ -147,9 +147,9 @@ impl SshdConfigParser { let Ok(text) = keyword.utf8_text(input_bytes) else { return Err(SshdConfigError::ParserError(t!("parser.failedToParseNode", input = input).to_string())); }; - - is_repeatable = REPEATABLE_KEYWORDS.contains(&text); - is_vec = is_repeatable || MULTI_ARG_KEYWORDS.contains(&text); + let lowercase_key = text.to_lowercase(); + is_repeatable = REPEATABLE_KEYWORDS.contains(&lowercase_key.as_str()); + is_vec = is_repeatable || MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&lowercase_key.as_str()) || MULTI_ARG_KEYWORDS_SPACE_SEP.contains(&lowercase_key.as_str()); key = Some(text.to_string()); } diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index ae4155e3c..0079b2602 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -16,8 +16,8 @@ use tracing::{debug, info, warn}; use crate::args::{DefaultShell, Setting}; use crate::error::SshdConfigError; use crate::inputs::{CommandInfo, SshdCommandArgs}; -use crate::metadata::{SSHD_CONFIG_HEADER, SSHD_CONFIG_HEADER_VERSION, SSHD_CONFIG_HEADER_WARNING}; -use crate::util::{build_command_info, get_default_sshd_config_path, invoke_sshd_config_validation}; +use crate::metadata::{REPEATABLE_KEYWORDS, SSHD_CONFIG_HEADER, SSHD_CONFIG_HEADER_VERSION, SSHD_CONFIG_HEADER_WARNING}; +use crate::util::{build_command_info, format_sshd_value, get_default_sshd_config_path, invoke_sshd_config_validation}; /// Invoke the set command. /// @@ -114,10 +114,29 @@ fn set_sshd_config(cmd_info: &CommandInfo) -> Result<(), SshdConfigError> { let mut config_text = SSHD_CONFIG_HEADER.to_string() + "\n" + SSHD_CONFIG_HEADER_VERSION + "\n" + SSHD_CONFIG_HEADER_WARNING + "\n"; if cmd_info.clobber { for (key, value) in &cmd_info.input { - if let Some(value_str) = value.as_str() { - writeln!(&mut config_text, "{key} {value_str}")?; - } else { - return Err(SshdConfigError::InvalidInput(t!("set.valueMustBeString", key = key).to_string())); + let key_lower = key.to_lowercase(); + + // Handle repeatable keywords - write multiple lines + if REPEATABLE_KEYWORDS.contains(&key_lower.as_str()) { + if let Value::Array(arr) = value { + for item in arr { + if let Some(formatted) = format_sshd_value(key, item)? { + writeln!(&mut config_text, "{key} {formatted}")?; + } + } + continue; + } else { + // Single value for repeatable keyword, write as-is + if let Some(formatted) = format_sshd_value(key, value)? { + writeln!(&mut config_text, "{key} {formatted}")?; + } + continue; + } + } + + // Handle non-repeatable keywords - format and write single line + if let Some(formatted) = format_sshd_value(key, value)? { + writeln!(&mut config_text, "{key} {formatted}")?; } } } else { diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index a7773d85a..93b1dd27e 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -9,7 +9,7 @@ use tracing_subscriber::{EnvFilter, filter::LevelFilter, Layer, prelude::__traci use crate::error::SshdConfigError; use crate::inputs::{CommandInfo, Metadata, SshdCommandArgs}; -use crate::metadata::{SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; +use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, MULTI_ARG_KEYWORDS_SPACE_SEP, SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; use crate::parser::parse_text_to_map; /// Enable tracing. @@ -35,6 +35,79 @@ pub fn enable_tracing() { } } +/// Format a JSON value for writing to sshd_config. +/// +/// # Arguments +/// +/// * `key` - The configuration key name (used to determine formatting rules) +/// * `value` - The JSON value to format +/// +/// # Returns +/// +/// * `Ok(Some(String))` - Formatted value string +/// * `Ok(None)` - Value is null and should be skipped +/// * `Err(SshdConfigError)` - Invalid value type or formatting error +/// +/// # Errors +/// +/// Returns an error if the value type is not supported or if formatting fails. +pub fn format_sshd_value(key: &str, value: &Value) -> Result, SshdConfigError> { + let key_lower = key.to_lowercase(); + + match value { + Value::Null => Ok(None), + Value::String(s) => Ok(Some(s.clone())), + Value::Number(n) => Ok(Some(n.to_string())), + Value::Bool(b) => { + let bool_str = if *b { "yes" } else { "no" }; + Ok(Some(bool_str.to_string())) + }, + Value::Array(arr) => { + if arr.is_empty() { + return Ok(None); + } + + // Convert array elements to strings + let mut string_values = Vec::new(); + for item in arr { + match item { + Value::String(s) => string_values.push(s.clone()), + Value::Number(n) => string_values.push(n.to_string()), + Value::Bool(b) => { + let bool_str = if *b { "yes" } else { "no" }; + string_values.push(bool_str.to_string()); + }, + Value::Null => continue, // Skip null values in arrays + _ => return Err(SshdConfigError::InvalidInput( + t!("set.arrayElementMustBeStringNumberOrBool", key = key).to_string() + )), + } + } + + if string_values.is_empty() { + return Ok(None); + } + + // Determine separator based on keyword type + let separator = if MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_lower.as_str()) { + "," + } else if MULTI_ARG_KEYWORDS_SPACE_SEP.contains(&key_lower.as_str()) { + " " + } else { + // Default to comma for unknown multi-arg keywords + "," + }; + + Ok(Some(string_values.join(separator))) + }, + Value::Object(_) => { + Err(SshdConfigError::InvalidInput( + t!("set.objectValuesNotSupported", key = key).to_string() + )) + } + } +} + /// Get the `sshd_config` path /// Uses the input value, if provided. /// If input value not provided, get default path for the OS. From 82df904a3670cec67d6579fb9b8ed5afb7f4c002 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Thu, 4 Dec 2025 17:24:07 -0500 Subject: [PATCH 4/8] add tracing level and format options --- resources/sshdconfig/locales/en-us.toml | 1 + resources/sshdconfig/src/args.rs | 20 +++++++++++ resources/sshdconfig/src/main.rs | 26 ++++++++++++-- resources/sshdconfig/src/util.rs | 48 +++++++++++++++++++++---- 4 files changed, 85 insertions(+), 10 deletions(-) diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 9970efdbc..e51a8adb5 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -30,6 +30,7 @@ windowsOnly = "Microsoft.OpenSSH.SSHD/Windows is only applicable to Windows" [main] export = "Export command: %{input}" +invalidTraceLevel = "Invalid trace level" schema = "Schema command:" set = "Set command: '%{input}'" diff --git a/resources/sshdconfig/src/args.rs b/resources/sshdconfig/src/args.rs index d1d012141..56c32b91a 100644 --- a/resources/sshdconfig/src/args.rs +++ b/resources/sshdconfig/src/args.rs @@ -6,10 +6,30 @@ use rust_i18n::t; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)] +pub enum TraceFormat { + Default, + Plaintext, + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)] +pub enum TraceLevel { + Error, + Warn, + Info, + Debug, + Trace +} + #[derive(Parser)] pub struct Args { #[clap(subcommand)] pub command: Command, + #[clap(short = 'l', long, help = "Trace level to use", value_enum)] + pub trace_level: Option, + #[clap(short = 'f', long, help = "Trace format to use", value_enum, default_value = "json")] + pub trace_format: TraceFormat, } #[derive(Subcommand)] diff --git a/resources/sshdconfig/src/main.rs b/resources/sshdconfig/src/main.rs index f473a47d3..b20054b4e 100644 --- a/resources/sshdconfig/src/main.rs +++ b/resources/sshdconfig/src/main.rs @@ -8,7 +8,7 @@ use serde_json::Map; use std::process::exit; use tracing::{debug, error}; -use args::{Args, Command, DefaultShell, Setting}; +use args::{Args, Command, DefaultShell, Setting, TraceLevel}; use get::{get_sshd_settings, invoke_get}; use parser::SshdConfigParser; use set::invoke_set; @@ -29,10 +29,30 @@ const EXIT_SUCCESS: i32 = 0; const EXIT_FAILURE: i32 = 1; fn main() { - enable_tracing(); - let args = Args::parse(); + let trace_level = match &args.trace_level { + Some(trace_level) => trace_level.clone(), + None => { + if let Ok(trace_level) = std::env::var("DSC_TRACE_LEVEL") { + match trace_level.to_lowercase().as_str() { + "error" => TraceLevel::Error, + "warn" => TraceLevel::Warn, + "info" => TraceLevel::Info, + "debug" => TraceLevel::Debug, + "trace" => TraceLevel::Trace, + _ => { + eprintln!("{}: {trace_level}", t!("main.invalidTraceLevel")); + TraceLevel::Info + } + } + } else { + TraceLevel::Info + } + } + }; + enable_tracing(&trace_level, &args.trace_format); + let result = match &args.command { Command::Export { input } => { debug!("{}: {:?}", t!("main.export").to_string(), input); diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 93b1dd27e..415bdf366 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -4,9 +4,10 @@ use rust_i18n::t; use serde_json::{Map, Value}; use std::{path::PathBuf, process::Command}; -use tracing::{debug, warn}; -use tracing_subscriber::{EnvFilter, filter::LevelFilter, Layer, prelude::__tracing_subscriber_SubscriberExt}; +use tracing::{debug, warn, Level}; +use tracing_subscriber::{EnvFilter, Layer, prelude::__tracing_subscriber_SubscriberExt}; +use crate::args::{TraceFormat, TraceLevel}; use crate::error::SshdConfigError; use crate::inputs::{CommandInfo, Metadata, SshdCommandArgs}; use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, MULTI_ARG_KEYWORDS_SPACE_SEP, SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; @@ -14,19 +15,52 @@ use crate::parser::parse_text_to_map; /// Enable tracing. /// +/// # Arguments +/// +/// * `trace_level` - The level of information to output +/// * `trace_format` - The format of the output +/// /// # Errors /// /// This function will return an error if it fails to initialize tracing. -pub fn enable_tracing() { - // default filter to trace level - let filter = EnvFilter::builder().with_default_directive(LevelFilter::TRACE.into()).parse("").unwrap_or_default(); +pub fn enable_tracing(trace_level: &TraceLevel, trace_format: &TraceFormat) { + let tracing_level = match trace_level { + TraceLevel::Error => Level::ERROR, + TraceLevel::Warn => Level::WARN, + TraceLevel::Info => Level::INFO, + TraceLevel::Debug => Level::DEBUG, + TraceLevel::Trace => Level::TRACE, + }; + + let filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new("warn")) + .unwrap_or_default() + .add_directive(tracing_level.into()); let layer = tracing_subscriber::fmt::Layer::default().with_writer(std::io::stderr); - let fmt = layer + let fmt = match trace_format { + TraceFormat::Default => { + layer + .with_ansi(true) + .with_level(true) + .with_line_number(true) + .boxed() + }, + TraceFormat::Plaintext => { + layer + .with_ansi(false) + .with_level(true) + .with_line_number(false) + .boxed() + }, + TraceFormat::Json => { + layer .with_ansi(false) .with_level(true) .with_line_number(true) .json() - .boxed(); + .boxed() + } + }; let subscriber = tracing_subscriber::Registry::default().with(fmt).with(filter); From 1638392691935e207f7aa2c9c535312b70a53a54 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 5 Dec 2025 15:37:03 -0500 Subject: [PATCH 5/8] update tests --- dsc/tests/dsc_sshdconfig.tests.ps1 | 8 ++--- .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index 28f931186..9ff323e39 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -149,16 +149,16 @@ resources: properties: _clobber: true port: 1234 - allowusers: + allowUsers: - user1 - user2 - passwordauthentication: $false + passwordAuthentication: $false ciphers: - aes128-ctr - aes192-ctr - aes256-ctr - addressfamily: inet6 - authorizedkeysfile: + addressFamily: inet6 + authorizedKeysFile: - ./.ssh/authorized_keys - ./.ssh/authorized_keys2 "@ diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index 6fbc51848..e7047fbe4 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -59,6 +59,38 @@ Describe 'sshd_config Set Tests' -Skip:(!$IsWindows -or $skipTest) { $sshdConfigContents | Should -Contain "AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2" } + It 'Should set with valud match blocks' { + $inputConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _clobber = $true + match = @( + @{ + criteria = @{ + user = @("alice", "bob") + } + passwordauthentication = $true + }, + @{ + criteria = @{ + group = @("administrators") + } + permitrootlogin = $false + } + ) + } | ConvertTo-Json -Depth 10 + + $output = sshdconfig set --input $inputConfig -s sshd-config 2>$null + $LASTEXITCODE | Should -Be 0 + Test-Path $TestConfigPath | Should -Be $true + $sshdConfigContents = Get-Content $TestConfigPath -Raw + $sshdConfigContents | Should -Match "match user alice,bob" + $sshdConfigContents | Should -Match "passwordauthentication yes" + $sshdConfigContents | Should -Match "match group administrators" + $sshdConfigContents | Should -Match "permitrootlogin no" + } + It 'Should create backup when file exists and is not managed by DSC' { # Create a non-DSC managed file "Port 22`nPermitRootLogin yes" | Set-Content $TestConfigPath From 3df28d23e40f59b04930dd98a635bae545f3283f Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 5 Dec 2025 15:37:46 -0500 Subject: [PATCH 6/8] lowercase any input keys for filtering --- resources/sshdconfig/locales/en-us.toml | 5 ++ resources/sshdconfig/src/parser.rs | 6 -- resources/sshdconfig/src/set.rs | 12 ++-- resources/sshdconfig/src/util.rs | 87 ++++++++++++++++++------- 4 files changed, 72 insertions(+), 38 deletions(-) diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index e51a8adb5..3e7d20ce9 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -72,9 +72,14 @@ valueMustBeString = "value for key '%{key}' must be a string" writingTempConfig = "Writing temporary sshd_config file" [util] +arrayElementMustBeStringNumber = "array element must be a string or number for key '%{key}'" cleanupFailed = "Failed to clean up temporary file %{path}: %{error}" getIgnoresInputFilters = "get command does not support filtering based on input settings, provided input will be ignored" inputMustBeBoolean = "value of '%{input}' must be true or false" +matchBlockMissingCriteria = "Match block must contain 'criteria' field" +matchBlockCriteriaMustBeObject = "Match block 'criteria' must be an object" +matchCriterionMustBeArray = "Match criterion '%{key}' must be an array" +objectValuesNotSupported = "Object values are not supported for key '%{key}'" sshdConfigNotFound = "sshd_config not found at path: '%{path}'" sshdConfigReadFailed = "failed to read sshd_config at path: '%{path}'" sshdElevation = "elevated security context required" diff --git a/resources/sshdconfig/src/parser.rs b/resources/sshdconfig/src/parser.rs index a272b56c3..bb90f382c 100644 --- a/resources/sshdconfig/src/parser.rs +++ b/resources/sshdconfig/src/parser.rs @@ -467,12 +467,6 @@ match user testuser let result: Map = parse_text_to_map(input).unwrap(); let match_array = result.get("match").unwrap().as_array().unwrap(); let match_obj = match_array[0].as_object().unwrap(); - for (k, v) in match_obj.iter() { - eprintln!(" {}: {:?}", k, v); - } - - // allowgroups is both MULTI_ARG and REPEATABLE - // Space-separated values should be parsed as array let allowgroups = match_obj.get("allowgroups").unwrap().as_array().unwrap(); assert_eq!(allowgroups.len(), 2); assert_eq!(allowgroups[0], Value::String("administrators".to_string())); diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 0079b2602..c26d07a2e 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -124,19 +124,17 @@ fn set_sshd_config(cmd_info: &CommandInfo) -> Result<(), SshdConfigError> { writeln!(&mut config_text, "{key} {formatted}")?; } } - continue; } else { // Single value for repeatable keyword, write as-is if let Some(formatted) = format_sshd_value(key, value)? { writeln!(&mut config_text, "{key} {formatted}")?; } - continue; } - } - - // Handle non-repeatable keywords - format and write single line - if let Some(formatted) = format_sshd_value(key, value)? { - writeln!(&mut config_text, "{key} {formatted}")?; + } else { + // Handle non-repeatable keywords - format and write single line + if let Some(formatted) = format_sshd_value(key, value)? { + writeln!(&mut config_text, "{key} {formatted}")?; + } } } } else { diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 415bdf366..42b46bab5 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -10,7 +10,7 @@ use tracing_subscriber::{EnvFilter, Layer, prelude::__tracing_subscriber_Subscri use crate::args::{TraceFormat, TraceLevel}; use crate::error::SshdConfigError; use crate::inputs::{CommandInfo, Metadata, SshdCommandArgs}; -use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, MULTI_ARG_KEYWORDS_SPACE_SEP, SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; +use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; use crate::parser::parse_text_to_map; /// Enable tracing. @@ -69,7 +69,7 @@ pub fn enable_tracing(trace_level: &TraceLevel, trace_format: &TraceFormat) { } } -/// Format a JSON value for writing to sshd_config. +/// Format a JSON value for writing to `sshd_config`. /// /// # Arguments /// @@ -89,13 +89,6 @@ pub fn format_sshd_value(key: &str, value: &Value) -> Result, Ssh let key_lower = key.to_lowercase(); match value { - Value::Null => Ok(None), - Value::String(s) => Ok(Some(s.clone())), - Value::Number(n) => Ok(Some(n.to_string())), - Value::Bool(b) => { - let bool_str = if *b { "yes" } else { "no" }; - Ok(Some(bool_str.to_string())) - }, Value::Array(arr) => { if arr.is_empty() { return Ok(None); @@ -105,15 +98,10 @@ pub fn format_sshd_value(key: &str, value: &Value) -> Result, Ssh let mut string_values = Vec::new(); for item in arr { match item { - Value::String(s) => string_values.push(s.clone()), Value::Number(n) => string_values.push(n.to_string()), - Value::Bool(b) => { - let bool_str = if *b { "yes" } else { "no" }; - string_values.push(bool_str.to_string()); - }, - Value::Null => continue, // Skip null values in arrays + Value::String(s) => string_values.push(s.clone()), _ => return Err(SshdConfigError::InvalidInput( - t!("set.arrayElementMustBeStringNumberOrBool", key = key).to_string() + t!("util.arrayElementMustBeStringNumber", key = key).to_string() )), } } @@ -122,23 +110,27 @@ pub fn format_sshd_value(key: &str, value: &Value) -> Result, Ssh return Ok(None); } - // Determine separator based on keyword type let separator = if MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_lower.as_str()) { "," - } else if MULTI_ARG_KEYWORDS_SPACE_SEP.contains(&key_lower.as_str()) { - " " } else { - // Default to comma for unknown multi-arg keywords - "," + " " }; Ok(Some(string_values.join(separator))) }, - Value::Object(_) => { - Err(SshdConfigError::InvalidInput( - t!("set.objectValuesNotSupported", key = key).to_string() - )) + Value::Bool(b) => { + let bool_str = if *b { "yes" } else { "no" }; + Ok(Some(bool_str.to_string())) + }, + Value::Null => Ok(None), + Value::Number(n) => Ok(Some(n.to_string())), + Value::Object(obj) => { + if key_lower == "match" { + return format_match_block(obj); + } + Err(SshdConfigError::InvalidInput(t!("util.objectValuesNotSupported", key = key).to_string())) } + Value::String(s) => Ok(Some(s.clone())), } } @@ -246,6 +238,8 @@ pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result, key: &str, default: bool) - Ok(default) } } + +fn format_match_block(match_obj: &Map) -> Result, SshdConfigError> { + let mut lines = Vec::new(); + + let Some(criteria_value) = match_obj.get("criteria") else { + return Err(SshdConfigError::InvalidInput(t!("set.matchBlockMissingCriteria").to_string())); + }; + + let Value::Object(criteria) = criteria_value else { + return Err(SshdConfigError::InvalidInput(t!("set.matchBlockCriteriaMustBeObject").to_string())); + }; + + let mut match_parts = vec![]; + + for (criterion_key, criterion_value) in criteria { + let Value::Array(values) = criterion_value else { + return Err(SshdConfigError::InvalidInput(t!("set.matchCriterionMustBeArray", key = criterion_key).to_string())); + }; + + // Convert array values to comma-separated string + let value_strings: Vec = values.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + if !value_strings.is_empty() { + match_parts.push(format!("{} {}", criterion_key, value_strings.join(","))); + } + } + + lines.push(match_parts.join(" ")); + + // Format other keywords in the match block + for (key, value) in match_obj { + if key == "criteria" { + continue; // handled above + } + + if let Some(formatted_value) = format_sshd_value(key, value)? { + lines.push(format!(" {key} {formatted_value}")); + } + } + Ok(Some(lines.join("\n"))) +} From ac1924be998834d4012fbde2368ef6476c565133 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 5 Dec 2025 15:58:40 -0500 Subject: [PATCH 7/8] fix i8n --- resources/sshdconfig/locales/en-us.toml | 1 - resources/sshdconfig/src/util.rs | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 3e7d20ce9..86b568671 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -68,7 +68,6 @@ shellPathMustNotBeRelative = "shell path must not be relative" sshdConfigReadFailed = "failed to read existing sshd_config file at path: '%{path}'" tempFileCreated = "temporary file created at: %{path}" validatingTempConfig = "Validating temporary sshd_config file" -valueMustBeString = "value for key '%{key}' must be a string" writingTempConfig = "Writing temporary sshd_config file" [util] diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 42b46bab5..5a9b3fc7e 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -314,18 +314,18 @@ fn format_match_block(match_obj: &Map) -> Result, let mut lines = Vec::new(); let Some(criteria_value) = match_obj.get("criteria") else { - return Err(SshdConfigError::InvalidInput(t!("set.matchBlockMissingCriteria").to_string())); + return Err(SshdConfigError::InvalidInput(t!("util.matchBlockMissingCriteria").to_string())); }; let Value::Object(criteria) = criteria_value else { - return Err(SshdConfigError::InvalidInput(t!("set.matchBlockCriteriaMustBeObject").to_string())); + return Err(SshdConfigError::InvalidInput(t!("util.matchBlockCriteriaMustBeObject").to_string())); }; let mut match_parts = vec![]; for (criterion_key, criterion_value) in criteria { let Value::Array(values) = criterion_value else { - return Err(SshdConfigError::InvalidInput(t!("set.matchCriterionMustBeArray", key = criterion_key).to_string())); + return Err(SshdConfigError::InvalidInput(t!("util.matchCriterionMustBeArray", key = criterion_key).to_string())); }; // Convert array values to comma-separated string From c25251362cbe215b8735550ade9e46e5fed17d5d Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Fri, 5 Dec 2025 15:59:53 -0500 Subject: [PATCH 8/8] fix typos --- resources/sshdconfig/src/metadata.rs | 4 ++-- resources/sshdconfig/tests/sshdconfig.set.tests.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/sshdconfig/src/metadata.rs b/resources/sshdconfig/src/metadata.rs index e489d80a7..87c2b54a7 100644 --- a/resources/sshdconfig/src/metadata.rs +++ b/resources/sshdconfig/src/metadata.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. // the multi-arg comma-separated and space-separated lists are mutually exclusive, but the repeatable list can overlap with either of them. -// the mult-arg lists are maintained for formatting arrays into the correct format when writing back to the config file. +// the multi-arg lists are maintained for formatting arrays into the correct format when writing back to the config file. // keywords that can have multiple comma-separated arguments per line and should be represented as arrays. pub const MULTI_ARG_KEYWORDS_COMMA_SEP: [&str; 11] = [ @@ -19,7 +19,7 @@ pub const MULTI_ARG_KEYWORDS_COMMA_SEP: [&str; 11] = [ "rekeylimit" // first arg is bytes, second arg (optional) is amount of time ]; -// keywords that can have multiple space-separated argments per line and should be represented as arrays. +// keywords that can have multiple space-separated arguments per line and should be represented as arrays. pub const MULTI_ARG_KEYWORDS_SPACE_SEP: [&str; 11] = [ "acceptenv", "allowgroups", diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index e7047fbe4..d9eab06e4 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -59,7 +59,7 @@ Describe 'sshd_config Set Tests' -Skip:(!$IsWindows -or $skipTest) { $sshdConfigContents | Should -Contain "AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2" } - It 'Should set with valud match blocks' { + It 'Should set with valid match blocks' { $inputConfig = @{ _metadata = @{ filepath = $TestConfigPath