Δημοσίευση πηγής
Κατεβάστε την πηγή ZIP
CH-J Tools
Περιηγηθείτε σε καταλόγους και αρχεία για μια συγκεκριμένη έκδοση εφαρμογών.
CH-J / Server Manager
1704 αρχεία
0.1.2-beta
Προστέθηκε: · Χτισμένο:
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 keep6
//! their priority. Every foreign line keeps its text and order.8
use super::policy::{locale_cmp, normalize_policy, policy_comment, policy_pairs, Policies, WINDOWS_IPV4_PATTERN, WINDOWS_IPV6_PATTERN};9
use crate::core::error::CoreError;11
pub const GLOBAL_BEGIN: &str = "# BEGIN CH-J chj.users global (managed by CH-J Server Manager, do not edit)";12
pub const GLOBAL_END: &str = "# END CH-J chj.users global";13
pub const MATCH_BEGIN: &str = "# BEGIN CH-J chj.users match (managed by CH-J Server Manager, do not edit)";14
pub const MATCH_END: &str = "# END CH-J chj.users match";15
pub const CONFIG_PATH: &str = "C:\\ProgramData\\ssh\\sshd_config";17
/// `^[a-z0-9_][a-z0-9._-]{0,19}$` after lower-casing.18
pub 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() <= 2023
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c));24
ok.then_some(value)25
}27
#[derive(Debug, Clone, Default)]28
pub 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>,33
}35
#[derive(Debug, Clone)]36
pub 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,42
}44
fn 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)51
}53
fn keyword(line: &str) -> String {54
line.split_whitespace().next().unwrap_or("").to_lowercase()55
}57
/// Locates managed sections and the structure CH-J depends on. Never fails on foreign content.58
pub 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 }105
}107
fn 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
}112
}114
pub 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)152
}154
pub struct ManagedPolicies {155
pub analysis: Analysis,156
pub policies: Policies,157
pub consistent: bool,158
}160
pub 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) == global179
&& section(&analysis.lines, &analysis.marks.match_begin, &analysis.marks.match_end) == matches;180
ManagedPolicies { analysis, policies, consistent }181
}183
/// Rebuilds sshd_config with the managed sections replaced; every foreign line keeps its text and order.184
pub 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
out220
}221
};222
output.extend(matches);223
Ok(format!("{}{eol}", output.join(eol)))224
}226
#[cfg(test)]227
mod 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
}252
}SHA-256
19b051b5e48a13e3e0c1b6f89daed2782f3ce7f105c579e75d480fc001256733CH-J Proprietary Software License 1.14 · Η πηγή παρέχεται στο CH-J Proprietary Software License 1.14. Η διαθεσιμότητά του δεν αλλάζει τους όρους της άδειας χρήσης ούτε χορηγεί πρόσθετα δικαιώματα.
Archive SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f