diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index a3dbf4058..9ff323e39 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -133,4 +133,44 @@ 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 + 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.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/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 6572c2b90..86b568671 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}'" @@ -67,13 +68,17 @@ 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] +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" -inputMustBeEmpty = "get command does not support filtering based on input settings" +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/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/metadata.rs b/resources/sshdconfig/src/metadata.rs index fc0517f21..87c2b54a7 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 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: [&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 arguments 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..bb90f382c 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()); } @@ -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 ae4155e3c..c26d07a2e 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,27 @@ 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}")?; + 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}")?; + } + } + } else { + // Single value for repeatable keyword, write as-is + if let Some(formatted) = format_sshd_value(key, value)? { + writeln!(&mut config_text, "{key} {formatted}")?; + } + } } else { - return Err(SshdConfigError::InvalidInput(t!("set.valueMustBeString", key = key).to_string())); + // 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 ea4fcea54..5a9b3fc7e 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -4,29 +4,63 @@ use rust_i18n::t; use serde_json::{Map, Value}; use std::{path::PathBuf, process::Command}; -use tracing::debug; -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::{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. /// +/// # 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); @@ -35,6 +69,71 @@ 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::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::Number(n) => string_values.push(n.to_string()), + Value::String(s) => string_values.push(s.clone()), + _ => return Err(SshdConfigError::InvalidInput( + t!("util.arrayElementMustBeStringNumber", key = key).to_string() + )), + } + } + + if string_values.is_empty() { + return Ok(None); + } + + let separator = if MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_lower.as_str()) { + "," + } else { + " " + }; + + Ok(Some(string_values.join(separator))) + }, + 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())), + } +} + /// Get the `sshd_config` path /// Uses the input value, if provided. /// If input value not provided, get default path for the OS. @@ -139,6 +238,8 @@ pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result, 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!("util.matchBlockMissingCriteria").to_string())); + }; + + let Value::Object(criteria) = criteria_value else { + 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!("util.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"))) +} diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index b369ce3b8..d9eab06e4 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,46 @@ Describe 'sshd_config Set Tests' -Skip:(!$IsWindows -or $skipTest) { # Verify file was created Test-Path $TestConfigPath | Should -Be $true + $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" + } - # Verify content using get - $getInput = @{ + It 'Should set with valid match blocks' { + $inputConfig = @{ _metadata = @{ filepath = $TestConfigPath } - } | ConvertTo-Json - $result = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json - $result.Port | Should -Be "1234" + _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' {