CH-J ToolsPalvelimen hallinta yli SSH
Valikko
Julkaistu lähde

CH-J Tools

Selaile hakemistoja ja tiedostoja tietyn sovelluksen julkaisua varten.

Lataa lähde ZIP
CH-J / Server Manager 1704 tiedostot
0.1.2-beta Lisätty: · Rakennettu:
windows_policy.rs Lisätty: Kirjaamaton päivä 10,4 KB · 252 rivit
Koodi Lataa tiedosto
1//! Windows OpenSSH policy sections inside `%ProgramData%\ssh\sshd_config`
2//! (port of `users/windowsSshPolicy.js`).
3//!
4//! Global DenyUsers lines go into a marked section before the first existing Match block,
5//! per-user Match blocks into a marked section at the end, so existing Match blocks keep
6//! their priority. Every foreign line keeps its text and order.
8use super::policy::{locale_cmp, normalize_policy, policy_comment, policy_pairs, Policies, WINDOWS_IPV4_PATTERN, WINDOWS_IPV6_PATTERN};
9use crate::core::error::CoreError;
11pub const GLOBAL_BEGIN: &str = "# BEGIN CH-J chj.users global (managed by CH-J Server Manager, do not edit)";
12pub const GLOBAL_END: &str = "# END CH-J chj.users global";
13pub const MATCH_BEGIN: &str = "# BEGIN CH-J chj.users match (managed by CH-J Server Manager, do not edit)";
14pub const MATCH_END: &str = "# END CH-J chj.users match";
15pub const CONFIG_PATH: &str = "C:\\ProgramData\\ssh\\sshd_config";
17/// `^[a-z0-9_][a-z0-9._-]{0,19}$` after lower-casing.
18pub fn ssh_name(name: &str) -> Option<String> {
19 let value = name.to_lowercase();
20 let mut chars = value.chars();
21 let ok = matches!(chars.next(), Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
22 && value.chars().count() <= 20
23 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c));
24 ok.then_some(value)
27#[derive(Debug, Clone, Default)]
28pub struct Marks {
29 pub global_begin: Vec<usize>,
30 pub global_end: Vec<usize>,
31 pub match_begin: Vec<usize>,
32 pub match_end: Vec<usize>,
35#[derive(Debug, Clone)]
36pub struct Analysis {
37 pub eol: &'static str,
38 pub lines: Vec<String>,
39 pub marks: Marks,
40 pub problems: Vec<String>,
41 pub supported: bool,
44fn split_lines(text: &str) -> (&'static str, Vec<String>) {
45 let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
46 let mut lines: Vec<String> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_string()).collect();
47 if lines.last().is_some_and(|l| l.is_empty()) {
48 lines.pop();
49 }
50 (eol, lines)
53fn keyword(line: &str) -> String {
54 line.split_whitespace().next().unwrap_or("").to_lowercase()
57/// Locates managed sections and the structure CH-J depends on. Never fails on foreign content.
58pub fn analyze_config(text: &str) -> Analysis {
59 let (eol, lines) = split_lines(text);
60 let mut marks = Marks::default();
61 for (index, line) in lines.iter().enumerate() {
62 match line.as_str() {
63 GLOBAL_BEGIN => marks.global_begin.push(index),
64 GLOBAL_END => marks.global_end.push(index),
65 MATCH_BEGIN => marks.match_begin.push(index),
66 MATCH_END => marks.match_end.push(index),
67 _ => {}
68 }
69 }
70 let mut problems = Vec::new();
71 for (begin, end) in [(&marks.global_begin, &marks.global_end), (&marks.match_begin, &marks.match_end)] {
72 let reversed = begin.first().zip(end.first()).is_some_and(|(b, e)| b > e);
73 if begin.len() > 1 || end.len() > 1 || begin.len() != end.len() || reversed {
74 problems.push("managed markers are damaged".to_string());
75 }
76 }
77 let in_managed = |index: usize| {
78 (marks.global_begin.first().zip(marks.global_end.first()).is_some_and(|(b, e)| index >= *b && index <= *e))
79 || (marks.match_begin.first().zip(marks.match_end.first()).is_some_and(|(b, e)| index >= *b && index <= *e))
80 };
81 let mut first_match: Option<usize> = None;
82 let mut include = false;
83 for (index, line) in lines.iter().enumerate() {
84 if in_managed(index) {
85 continue;
86 }
87 let word = keyword(line);
88 if word == "match" && first_match.is_none() {
89 first_match = Some(index);
90 }
91 if word == "include" {
92 include = true;
93 }
94 }
95 if let (Some(begin), Some(first)) = (marks.global_begin.first(), first_match) {
96 if *begin > first {
97 problems.push("the CH-J global section is located after a Match block".into());
98 }
99 }
100 if include {
101 problems.push("Include directives are not supported by the CH-J Windows editor".into());
102 }
103 let supported = problems.is_empty();
104 Analysis { eol, lines, marks, problems, supported }
107fn section(lines: &[String], begin: &[usize], end: &[usize]) -> Vec<String> {
108 match (begin.first(), end.first()) {
109 (Some(b), Some(e)) if b <= e => lines[*b..=*e].to_vec(),
110 _ => Vec::new(),
111 }
114pub fn render_sections(policies: &Policies) -> (Vec<String>, Vec<String>) {
115 let mut entries: Vec<_> = policies.iter().filter(|(_, p)| !p.is_default()).collect();
116 entries.sort_by(|a, b| locale_cmp(a.0, b.0));
117 if entries.is_empty() {
118 return (Vec::new(), Vec::new());
119 }
120 let mut global = vec![GLOBAL_BEGIN.to_string()];
121 for (name, policy) in &entries {
122 global.push(policy_comment(name, policy));
123 }
124 for (name, policy) in &entries {
125 if policy.access == "deny" {
126 global.push(format!("DenyUsers {name}"));
127 }
128 if policy.ipv4 == "deny" {
129 global.push(format!("DenyUsers {name}@{WINDOWS_IPV4_PATTERN}"));
130 }
131 if policy.ipv6 == "deny" {
132 global.push(format!("DenyUsers {name}@{WINDOWS_IPV6_PATTERN}"));
133 }
134 }
135 global.push(GLOBAL_END.into());
136 let match_entries: Vec<_> = entries.iter().filter(|(_, p)| p.password != "inherit" || p.pubkey != "inherit").collect();
137 let mut matches = Vec::new();
138 if !match_entries.is_empty() {
139 matches.push(MATCH_BEGIN.to_string());
140 for (name, policy) in match_entries {
141 matches.push(format!("Match User {name}"));
142 if policy.password != "inherit" {
143 matches.push(format!("\tPasswordAuthentication {}", policy.password));
144 }
145 if policy.pubkey != "inherit" {
146 matches.push(format!("\tPubkeyAuthentication {}", policy.pubkey));
147 }
148 }
149 matches.push(MATCH_END.into());
150 }
151 (global, matches)
154pub struct ManagedPolicies {
155 pub analysis: Analysis,
156 pub policies: Policies,
157 pub consistent: bool,
160pub fn parse_managed_policies(text: &str) -> ManagedPolicies {
161 let analysis = analyze_config(text);
162 let mut policies = Policies::new();
163 for line in section(&analysis.lines, &analysis.marks.global_begin, &analysis.marks.global_end) {
164 let Some(rest) = line.strip_prefix("# chj-policy ") else { continue };
165 let Some((name, values)) = rest.split_once(' ') else { continue };
166 if ssh_name(name).as_deref() != Some(name) || values.is_empty() {
167 continue;
168 }
169 match normalize_policy(&policy_pairs(values), None) {
170 Ok(policy) => {
171 policies.insert(name.to_string(), policy);
172 }
173 Err(_) => return ManagedPolicies { analysis, policies: Policies::new(), consistent: false },
174 }
175 }
176 let (global, matches) = render_sections(&policies);
177 let consistent = analysis.problems.is_empty()
178 && section(&analysis.lines, &analysis.marks.global_begin, &analysis.marks.global_end) == global
179 && section(&analysis.lines, &analysis.marks.match_begin, &analysis.marks.match_end) == matches;
180 ManagedPolicies { analysis, policies, consistent }
183/// Rebuilds sshd_config with the managed sections replaced; every foreign line keeps its text and order.
184pub fn render_config(text: &str, policies: &Policies) -> Result<String, CoreError> {
185 let managed = parse_managed_policies(text);
186 if !managed.analysis.supported {
187 return Err(CoreError::new("USERS_SSH_LAYOUT_UNSUPPORTED", format!("sshd_config cannot be edited safely: {}.", managed.analysis.problems.join("; "))));
188 }
189 if !managed.consistent {
190 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."));
191 }
192 let Analysis { lines, marks, eol, .. } = managed.analysis;
193 let mut managed_lines = std::collections::HashSet::new();
194 for (begin, end) in [(&marks.global_begin, &marks.global_end), (&marks.match_begin, &marks.match_end)] {
195 if let (Some(b), Some(e)) = (begin.first(), end.first()) {
196 for index in *b..=*e {
197 managed_lines.insert(index);
198 }
199 }
200 }
201 let mut foreign: Vec<String> = Vec::new();
202 let mut first_match: Option<usize> = None;
203 for (index, line) in lines.into_iter().enumerate() {
204 if managed_lines.contains(&index) {
205 continue;
206 }
207 if first_match.is_none() && keyword(&line) == "match" {
208 first_match = Some(foreign.len());
209 }
210 foreign.push(line);
211 }
212 let (global, matches) = render_sections(policies);
213 let mut output: Vec<String> = match first_match {
214 None => foreign.into_iter().chain(global).collect(),
215 Some(at) => {
216 let mut out: Vec<String> = foreign[..at].to_vec();
217 out.extend(global);
218 out.extend_from_slice(&foreign[at..]);
219 out
220 }
221 };
222 output.extend(matches);
223 Ok(format!("{}{eol}", output.join(eol)))
226#[cfg(test)]
227mod tests {
228 use super::super::policy::normalize_policy;
229 use super::*;
230 use serde_json::json;
232 #[test]
233 fn render_keeps_foreign_lines_and_priority() {
234 let original = "Port 22\r\nPasswordAuthentication yes\r\nMatch Group administrators\r\n AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys\r\n";
235 let mut policies = Policies::new();
236 policies.insert("codex".into(), normalize_policy(&json!({ "ipv6": "deny", "password": "no" }), None).unwrap());
237 let rendered = render_config(original, &policies).unwrap();
238 let lines: Vec<&str> = rendered.split("\r\n").collect();
239 assert_eq!(lines[0], "Port 22");
240 assert_eq!(lines[2], GLOBAL_BEGIN);
241 assert!(rendered.contains("DenyUsers codex@*:*\r\n"));
242 assert!(rendered.contains("Match Group administrators"));
243 assert!(rendered.trim_end().ends_with(MATCH_END));
244 let parsed = parse_managed_policies(&rendered);
245 assert!(parsed.consistent);
246 // Removing the only policy restores the original text exactly.
247 assert_eq!(render_config(&rendered, &Policies::new()).unwrap(), original);
248 assert_eq!(render_config(&format!("Include x\r\n{original}"), &policies).unwrap_err().code, "USERS_SSH_LAYOUT_UNSUPPORTED");
249 assert_eq!(ssh_name("Codex"), Some("codex".into()));
250 assert_eq!(ssh_name("a b"), None);
251 }
SHA-25619b051b5e48a13e3e0c1b6f89daed2782f3ce7f105c579e75d480fc001256733

CH-J Proprietary Software License 1.14 · Lähde ilmoitetaan CH-J Proprietary Software License 1.14-koodilla. Sen saatavuus ei muuta lisenssiehtoja eikä myönnä lisäoikeuksia.

Arkisto SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Tiedostot