//! Windows OpenSSH policy sections inside `%ProgramData%\ssh\sshd_config` //! (port of `users/windowsSshPolicy.js`). //! //! Global DenyUsers lines go into a marked section before the first existing Match block, //! per-user Match blocks into a marked section at the end, so existing Match blocks keep //! their priority. Every foreign line keeps its text and order. use super::policy::{locale_cmp, normalize_policy, policy_comment, policy_pairs, Policies, WINDOWS_IPV4_PATTERN, WINDOWS_IPV6_PATTERN}; use crate::core::error::CoreError; pub const GLOBAL_BEGIN: &str = "# BEGIN CH-J chj.users global (managed by CH-J Server Manager, do not edit)"; pub const GLOBAL_END: &str = "# END CH-J chj.users global"; pub const MATCH_BEGIN: &str = "# BEGIN CH-J chj.users match (managed by CH-J Server Manager, do not edit)"; pub const MATCH_END: &str = "# END CH-J chj.users match"; pub const CONFIG_PATH: &str = "C:\\ProgramData\\ssh\\sshd_config"; /// `^[a-z0-9_][a-z0-9._-]{0,19}$` after lower-casing. pub fn ssh_name(name: &str) -> Option { let value = name.to_lowercase(); let mut chars = value.chars(); let ok = matches!(chars.next(), Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') && value.chars().count() <= 20 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c)); ok.then_some(value) } #[derive(Debug, Clone, Default)] pub struct Marks { pub global_begin: Vec, pub global_end: Vec, pub match_begin: Vec, pub match_end: Vec, } #[derive(Debug, Clone)] pub struct Analysis { pub eol: &'static str, pub lines: Vec, pub marks: Marks, pub problems: Vec, pub supported: bool, } fn split_lines(text: &str) -> (&'static str, Vec) { let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; let mut lines: Vec = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_string()).collect(); if lines.last().is_some_and(|l| l.is_empty()) { lines.pop(); } (eol, lines) } fn keyword(line: &str) -> String { line.split_whitespace().next().unwrap_or("").to_lowercase() } /// Locates managed sections and the structure CH-J depends on. Never fails on foreign content. pub fn analyze_config(text: &str) -> Analysis { let (eol, lines) = split_lines(text); let mut marks = Marks::default(); for (index, line) in lines.iter().enumerate() { match line.as_str() { GLOBAL_BEGIN => marks.global_begin.push(index), GLOBAL_END => marks.global_end.push(index), MATCH_BEGIN => marks.match_begin.push(index), MATCH_END => marks.match_end.push(index), _ => {} } } let mut problems = Vec::new(); for (begin, end) in [(&marks.global_begin, &marks.global_end), (&marks.match_begin, &marks.match_end)] { let reversed = begin.first().zip(end.first()).is_some_and(|(b, e)| b > e); if begin.len() > 1 || end.len() > 1 || begin.len() != end.len() || reversed { problems.push("managed markers are damaged".to_string()); } } let in_managed = |index: usize| { (marks.global_begin.first().zip(marks.global_end.first()).is_some_and(|(b, e)| index >= *b && index <= *e)) || (marks.match_begin.first().zip(marks.match_end.first()).is_some_and(|(b, e)| index >= *b && index <= *e)) }; let mut first_match: Option = None; let mut include = false; for (index, line) in lines.iter().enumerate() { if in_managed(index) { continue; } let word = keyword(line); if word == "match" && first_match.is_none() { first_match = Some(index); } if word == "include" { include = true; } } if let (Some(begin), Some(first)) = (marks.global_begin.first(), first_match) { if *begin > first { problems.push("the CH-J global section is located after a Match block".into()); } } if include { problems.push("Include directives are not supported by the CH-J Windows editor".into()); } let supported = problems.is_empty(); Analysis { eol, lines, marks, problems, supported } } fn section(lines: &[String], begin: &[usize], end: &[usize]) -> Vec { match (begin.first(), end.first()) { (Some(b), Some(e)) if b <= e => lines[*b..=*e].to_vec(), _ => Vec::new(), } } pub fn render_sections(policies: &Policies) -> (Vec, Vec) { let mut entries: Vec<_> = policies.iter().filter(|(_, p)| !p.is_default()).collect(); entries.sort_by(|a, b| locale_cmp(a.0, b.0)); if entries.is_empty() { return (Vec::new(), Vec::new()); } let mut global = vec![GLOBAL_BEGIN.to_string()]; for (name, policy) in &entries { global.push(policy_comment(name, policy)); } for (name, policy) in &entries { if policy.access == "deny" { global.push(format!("DenyUsers {name}")); } if policy.ipv4 == "deny" { global.push(format!("DenyUsers {name}@{WINDOWS_IPV4_PATTERN}")); } if policy.ipv6 == "deny" { global.push(format!("DenyUsers {name}@{WINDOWS_IPV6_PATTERN}")); } } global.push(GLOBAL_END.into()); let match_entries: Vec<_> = entries.iter().filter(|(_, p)| p.password != "inherit" || p.pubkey != "inherit").collect(); let mut matches = Vec::new(); if !match_entries.is_empty() { matches.push(MATCH_BEGIN.to_string()); for (name, policy) in match_entries { matches.push(format!("Match User {name}")); if policy.password != "inherit" { matches.push(format!("\tPasswordAuthentication {}", policy.password)); } if policy.pubkey != "inherit" { matches.push(format!("\tPubkeyAuthentication {}", policy.pubkey)); } } matches.push(MATCH_END.into()); } (global, matches) } pub struct ManagedPolicies { pub analysis: Analysis, pub policies: Policies, pub consistent: bool, } pub fn parse_managed_policies(text: &str) -> ManagedPolicies { let analysis = analyze_config(text); let mut policies = Policies::new(); for line in section(&analysis.lines, &analysis.marks.global_begin, &analysis.marks.global_end) { let Some(rest) = line.strip_prefix("# chj-policy ") else { continue }; let Some((name, values)) = rest.split_once(' ') else { continue }; if ssh_name(name).as_deref() != Some(name) || values.is_empty() { continue; } match normalize_policy(&policy_pairs(values), None) { Ok(policy) => { policies.insert(name.to_string(), policy); } Err(_) => return ManagedPolicies { analysis, policies: Policies::new(), consistent: false }, } } let (global, matches) = render_sections(&policies); let consistent = analysis.problems.is_empty() && section(&analysis.lines, &analysis.marks.global_begin, &analysis.marks.global_end) == global && section(&analysis.lines, &analysis.marks.match_begin, &analysis.marks.match_end) == matches; ManagedPolicies { analysis, policies, consistent } } /// Rebuilds sshd_config with the managed sections replaced; every foreign line keeps its text and order. pub fn render_config(text: &str, policies: &Policies) -> Result { let managed = parse_managed_policies(text); if !managed.analysis.supported { return Err(CoreError::new("USERS_SSH_LAYOUT_UNSUPPORTED", format!("sshd_config cannot be edited safely: {}.", managed.analysis.problems.join("; ")))); } if !managed.consistent { return Err(CoreError::new("USERS_SSH_MANAGED_FILE_MODIFIED", "The CH-J sections in sshd_config were edited manually; CH-J will not overwrite them.")); } let Analysis { lines, marks, eol, .. } = managed.analysis; let mut managed_lines = std::collections::HashSet::new(); for (begin, end) in [(&marks.global_begin, &marks.global_end), (&marks.match_begin, &marks.match_end)] { if let (Some(b), Some(e)) = (begin.first(), end.first()) { for index in *b..=*e { managed_lines.insert(index); } } } let mut foreign: Vec = Vec::new(); let mut first_match: Option = None; for (index, line) in lines.into_iter().enumerate() { if managed_lines.contains(&index) { continue; } if first_match.is_none() && keyword(&line) == "match" { first_match = Some(foreign.len()); } foreign.push(line); } let (global, matches) = render_sections(policies); let mut output: Vec = match first_match { None => foreign.into_iter().chain(global).collect(), Some(at) => { let mut out: Vec = foreign[..at].to_vec(); out.extend(global); out.extend_from_slice(&foreign[at..]); out } }; output.extend(matches); Ok(format!("{}{eol}", output.join(eol))) } #[cfg(test)] mod tests { use super::super::policy::normalize_policy; use super::*; use serde_json::json; #[test] fn render_keeps_foreign_lines_and_priority() { let original = "Port 22\r\nPasswordAuthentication yes\r\nMatch Group administrators\r\n AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys\r\n"; let mut policies = Policies::new(); policies.insert("codex".into(), normalize_policy(&json!({ "ipv6": "deny", "password": "no" }), None).unwrap()); let rendered = render_config(original, &policies).unwrap(); let lines: Vec<&str> = rendered.split("\r\n").collect(); assert_eq!(lines[0], "Port 22"); assert_eq!(lines[2], GLOBAL_BEGIN); assert!(rendered.contains("DenyUsers codex@*:*\r\n")); assert!(rendered.contains("Match Group administrators")); assert!(rendered.trim_end().ends_with(MATCH_END)); let parsed = parse_managed_policies(&rendered); assert!(parsed.consistent); // Removing the only policy restores the original text exactly. assert_eq!(render_config(&rendered, &Policies::new()).unwrap(), original); assert_eq!(render_config(&format!("Include x\r\n{original}"), &policies).unwrap_err().code, "USERS_SSH_LAYOUT_UNSUPPORTED"); assert_eq!(ssh_name("Codex"), Some("codex".into())); assert_eq!(ssh_name("a b"), None); } }