diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 2b215dc9e..ce838b746 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -11,6 +11,7 @@ inputMustBeBoolean = "value of '%{input}' must be true or false" [error] command = "Command" +configInitRequired = "Configuration File Initialization Required" envVar = "Environment Variable" fileNotFound = "File not found: %{path}" invalidInput = "Invalid Input" @@ -86,7 +87,6 @@ defaultShellDebug = "default_shell: %{shell}" expectedArrayForKeyword = "Expected array for keyword '%{keyword}'" failedToParse = "failed to parse: '%{input}'" failedToParseDefaultShell = "failed to parse input for DefaultShell with error: '%{error}'" -purgeFalseRequiresExistingFile = "_purge=false requires an existing sshd_config file. Use _purge=true to create a new configuration file." settingDefaultShell = "Setting default shell" settingSshdConfig = "Setting sshd_config" shellPathDoesNotExist = "shell path does not exist: '%{shell}'" @@ -99,6 +99,9 @@ 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" +seededConfigFromDefault = "Seeded missing sshd_config from '%{source}' to '%{target}'" +sshdConfigDefaultNotFound = "'%{path}' does not exist and no default source could be found. Checked: %{paths}. Start the sshd service to initialize it, then retry." +sshdConfigNotFoundNonWindows = "'%{path}' does not exist. Start the sshd service to initialize it, then retry." sshdConfigReadFailed = "failed to read sshd_config at path: '%{path}'" sshdElevation = "elevated security context required" tempFileCreated = "temporary file created at: %{path}" diff --git a/resources/sshdconfig/src/error.rs b/resources/sshdconfig/src/error.rs index 52d0d62f5..94a0edbe3 100644 --- a/resources/sshdconfig/src/error.rs +++ b/resources/sshdconfig/src/error.rs @@ -9,6 +9,8 @@ use thiserror::Error; pub enum SshdConfigError { #[error("{t}: {0}", t = t!("error.command"))] CommandError(String), + #[error("{t}: {0}", t = t!("error.configInitRequired"))] + ConfigInitRequired(String), #[error("{t}: {0}", t = t!("error.envVar"))] EnvVarError(#[from] std::env::VarError), #[error("{t}", t = t!("error.fileNotFound", path = .0))] diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 14c7564ad..47d4e9f47 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -24,7 +24,7 @@ use crate::repeat_keyword::{ RepeatInput, RepeatListInput, NameValueEntry, add_or_update_entry, extract_single_keyword, remove_entry, parse_and_validate_entries }; -use crate::util::{build_command_info, get_default_sshd_config_path, invoke_sshd_config_validation}; +use crate::util::{build_command_info, ensure_sshd_config_exists, get_default_sshd_config_path, invoke_sshd_config_validation}; /// Invoke the set command. /// @@ -189,16 +189,9 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { let mut get_cmd_info = cmd_info.clone(); get_cmd_info.include_defaults = false; get_cmd_info.input = Map::new(); + ensure_sshd_config_exists(get_cmd_info.metadata.filepath.clone())?; - let mut existing_config = match get_sshd_settings(&get_cmd_info, true) { - Ok(config) => config, - Err(SshdConfigError::FileNotFound(_)) => { - return Err(SshdConfigError::InvalidInput( - t!("set.purgeFalseRequiresExistingFile").to_string() - )); - } - Err(e) => return Err(e), - }; + let mut existing_config = get_sshd_settings(&get_cmd_info, true)?; for (key, value) in &cmd_info.input { if value.is_null() { existing_config.remove(key); @@ -281,12 +274,6 @@ fn get_existing_config(cmd_info: &CommandInfo) -> Result, Ssh let mut get_cmd_info = cmd_info.clone(); get_cmd_info.include_defaults = false; get_cmd_info.input = Map::new(); - match get_sshd_settings(&get_cmd_info, false) { - Ok(config) => Ok(config), - Err(SshdConfigError::FileNotFound(_)) => { - // If file doesn't exist, create empty config - Ok(Map::new()) - } - Err(e) => Err(e), - } + ensure_sshd_config_exists(get_cmd_info.metadata.filepath.clone())?; + get_sshd_settings(&get_cmd_info, false) } diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 4031e190b..27dae940e 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -107,6 +107,69 @@ pub fn get_default_sshd_config_path(input: Option) -> Result Vec { + let mut candidates: Vec = Vec::new(); + + if cfg!(windows) && let Ok(win_dir) = std::env::var("windir") { + candidates.push( + PathBuf::from(win_dir) + .join("System32") + .join("OpenSSH") + .join("sshd_config_default"), + ); + } + + candidates +} + +/// Ensure the target `sshd_config` exists by seeding it from a platform default source. +/// +/// # Errors +/// +/// This function returns an error if the target cannot be created or no source default config is available. +pub fn ensure_sshd_config_exists(input: Option) -> Result { + let target_path = get_default_sshd_config_path(input)?; + if target_path.exists() { + return Ok(target_path); + } + + if !cfg!(windows) { + return Err(SshdConfigError::ConfigInitRequired( + t!("util.sshdConfigNotFoundNonWindows", path = target_path.display()).to_string(), + )); + } + + let candidates = get_sshd_config_default_source_candidates(); + let source_path = candidates + .iter() + .find(|candidate| candidate.is_file()) + .cloned() + .ok_or_else(|| { + let paths = candidates + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + SshdConfigError::ConfigInitRequired( + t!( + "util.sshdConfigDefaultNotFound", + path = target_path.display(), + paths = paths + ) + .to_string(), + ) + })?; + + if let Some(parent) = target_path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::copy(&source_path, &target_path)?; + debug!("{}", t!("util.seededConfigFromDefault", source = source_path.display(), target = target_path.display())); + + Ok(target_path) +} + /// Invoke sshd -T. /// /// # Errors @@ -244,5 +307,3 @@ pub fn read_sshd_config(input: Option) -> Result$stderrFile $LASTEXITCODE | Should -Not -Be 0 + Test-Path $nonExistentPath | Should -Be $false + $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue $stderr | Should -Match "File not found" + Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue } } diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index e90261bad..ae20c30ba 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -21,6 +21,9 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { $TestDir = Join-Path $TestDrive "sshd_test" New-Item -Path $TestDir -ItemType Directory -Force | Out-Null $TestConfigPath = Join-Path $TestDir "sshd_config" + + $script:DefaultSourceExists = $IsWindows -and + (Test-Path -Path "$env:SystemDrive\Windows\System32\OpenSSH\sshd_config_default" -PathType Leaf -ErrorAction SilentlyContinue) } AfterEach { @@ -180,7 +183,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { sshdconfig set --input $validConfig -s sshd-config } - It 'Should fail with purge=false when file does not exist' { + It 'Should seed missing file from default source when available on Windows, or fail otherwise' { $nonExistentPath = Join-Path $TestDrive "nonexistent_sshd_config" $inputConfig = @{ @@ -193,12 +196,32 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { $stderrFile = Join-Path $TestDrive "stderr_purgefalse_nofile.txt" sshdconfig set --input $inputConfig -s sshd-config 2>$stderrFile - $LASTEXITCODE | Should -Not -Be 0 - $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue - $stderr | Should -Match "_purge=false requires an existing sshd_config file" - $stderr | Should -Match "Use _purge=true to create a new configuration file" + if ($IsWindows -and $script:DefaultSourceExists) { + $LASTEXITCODE | Should -Be 0 + Test-Path $nonExistentPath | Should -Be $true + + $getInput = @{ + _metadata = @{ + filepath = $nonExistentPath + } + } | ConvertTo-Json + $result = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json + $result.Port | Should -Be "8888" + } + elseif ($IsWindows) { + $LASTEXITCODE | Should -Not -Be 0 + $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue + $stderr | Should -Match "no default source could be found" + } + else { + $LASTEXITCODE | Should -Not -Be 0 + $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue + $stderr | Should -Match "does not exist" + } + Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue + Remove-Item -Path $nonExistentPath -Force -ErrorAction SilentlyContinue } It 'Should fail with invalid keyword and not modify file' { diff --git a/resources/sshdconfig/tests/sshdconfigRepeat.tests.ps1 b/resources/sshdconfig/tests/sshdconfigRepeat.tests.ps1 index 8a2a34d7b..156cb8b74 100644 --- a/resources/sshdconfig/tests/sshdconfigRepeat.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfigRepeat.tests.ps1 @@ -33,6 +33,9 @@ Describe 'sshd-config-repeat Set Tests' -Skip:($skipTest) { $script:DefaultSftpPath = "/usr/lib/openssh/sftp-server" $script:AlternatePath = "/usr/libexec/sftp-server" } + + $script:DefaultSourceExists = $IsWindows -and + (Test-Path -Path "$env:SystemDrive\Windows\System32\OpenSSH\sshd_config_default" -PathType Leaf -ErrorAction SilentlyContinue) } AfterEach { @@ -230,5 +233,41 @@ PasswordAuthentication yes $subsystems = Get-Content $TestConfigPath | Where-Object { $_ -match '^\s*subsystem\s+' } $subsystems | Should -Contain "subsystem testExistDefault /path/to/subsystem" } + + It 'Should seed missing file from default source when available on Windows, or fail otherwise' { + $nonExistentPath = Join-Path $TestDrive "nonexistent_sshd_config" + + $inputConfig = @{ + _metadata = @{ + filepath = $nonExistentPath + } + _exist = $true + subsystem = @{ + name = "powershell" + value = "/usr/bin/pwsh -sshs" + } + } | ConvertTo-Json + + $stderrFile = Join-Path $TestDrive "stderr_missing_default_repeat.txt" + sshdconfig set --input $inputConfig -s sshd-config-repeat 2>$stderrFile + + if ($IsWindows -and $script:DefaultSourceExists) { + $LASTEXITCODE | Should -Be 0 + Test-Path $nonExistentPath | Should -Be $true + } + elseif ($IsWindows) { + $LASTEXITCODE | Should -Not -Be 0 + $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue + $stderr | Should -Match "no default source could be found" + } + else { + $LASTEXITCODE | Should -Not -Be 0 + $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue + $stderr | Should -Match "does not exist" + } + + Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue + Remove-Item -Path $nonExistentPath -Force -ErrorAction SilentlyContinue + } } } diff --git a/resources/sshdconfig/tests/sshdconfigRepeatList.tests.ps1 b/resources/sshdconfig/tests/sshdconfigRepeatList.tests.ps1 index 1869ea60d..06033442a 100644 --- a/resources/sshdconfig/tests/sshdconfigRepeatList.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfigRepeatList.tests.ps1 @@ -33,6 +33,9 @@ Describe 'sshd-config-repeat-list Set Tests' -Skip:($skipTest) { $script:DefaultSftpPath = "/usr/lib/openssh/sftp-server" $script:AlternatePath = "/usr/libexec/sftp-server" } + + $script:DefaultSourceExists = $IsWindows -and + (Test-Path -Path "$env:SystemDrive\Windows\System32\OpenSSH\sshd_config_default" -PathType Leaf -ErrorAction SilentlyContinue) } AfterEach { @@ -320,5 +323,43 @@ PasswordAuthentication yes $subsystems = Get-Content $TestConfigPath | Where-Object { $_ -match '^\s*subsystem\s+' } $subsystems.Count | Should -Be 0 } + + It 'Should seed missing file from default source when available on Windows, or fail otherwise' { + $nonExistentPath = Join-Path $TestDrive "nonexistent_sshd_config" + + $inputConfig = @{ + _metadata = @{ + filepath = $nonExistentPath + } + _purge = $false + subsystem = @( + @{ + name = "powershell" + value = "/usr/bin/pwsh -sshs" + } + ) + } | ConvertTo-Json -Depth 10 + + $stderrFile = Join-Path $TestDrive "stderr_missing_default_repeat_list.txt" + sshdconfig set --input $inputConfig -s sshd-config-repeat-list 2>$stderrFile + + if ($IsWindows -and $script:DefaultSourceExists) { + $LASTEXITCODE | Should -Be 0 + Test-Path $nonExistentPath | Should -Be $true + } + elseif ($IsWindows) { + $LASTEXITCODE | Should -Not -Be 0 + $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue + $stderr | Should -Match "no default source could be found" + } + else { + $LASTEXITCODE | Should -Not -Be 0 + $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue + $stderr | Should -Match "does not exist" + } + + Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue + Remove-Item -Path $nonExistentPath -Force -ErrorAction SilentlyContinue + } } }