//! Per-user SSH policy in one CH-J managed sshd drop-in (port of `users/sshPolicy.js`). //! The `# chj-policy` lines are the source of truth; the directives are regenerated from them, //! so any manual edit is detected. `sshd -T` output is evaluated like sshd's `allowed_user()`. use crate::core::error::CoreError; use serde::Serialize; use serde_json::{json, Map, Value}; use std::collections::BTreeMap; use std::net::IpAddr; pub const MANAGED_PATH: &str = "/etc/ssh/sshd_config.d/01-chj-users.conf"; const HEADER: [&str; 2] = [ "# CH-J Server Manager - chj.users managed SSH access policy.", "# Generated file. CH-J refuses to overwrite it after manual edits; keep custom rules in other files.", ]; pub const IPV4_ALL: &str = "0.0.0.0/0"; pub const IPV6_ALL: &str = "::/0"; /// Windows OpenSSH rewrites "/" to "\" in AllowUsers/DenyUsers, so address-family wildcards are used there. pub const WINDOWS_IPV4_PATTERN: &str = "*.*.*.*"; pub const WINDOWS_IPV6_PATTERN: &str = "*:*"; const MANAGED_HOST_PATTERNS: [&str; 4] = [IPV4_ALL, IPV6_ALL, WINDOWS_IPV4_PATTERN, WINDOWS_IPV6_PATTERN]; const MULTI_VALUE_KEYS: [&str; 5] = ["denyusers", "allowusers", "denygroups", "allowgroups", "listenaddress"]; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Policy { pub access: String, pub password: String, pub pubkey: String, pub ipv4: String, pub ipv6: String, pub permit_root_login: String, } impl Default for Policy { fn default() -> Self { Policy { access: "allow".into(), password: "inherit".into(), pubkey: "inherit".into(), ipv4: "allow".into(), ipv6: "allow".into(), permit_root_login: "inherit".into() } } } impl Policy { pub fn to_value(&self) -> Value { serde_json::to_value(self).unwrap_or_default() } fn get(&self, key: &str) -> &str { match key { "access" => &self.access, "password" => &self.password, "pubkey" => &self.pubkey, "ipv4" => &self.ipv4, "ipv6" => &self.ipv6, _ => &self.permit_root_login, } } pub fn is_default(&self) -> bool { *self == Policy::default() } } const POLICY_VALUES: [(&str, &[&str]); 6] = [ ("access", &["allow", "deny"]), ("password", &["inherit", "yes", "no"]), ("pubkey", &["inherit", "yes", "no"]), ("ipv4", &["allow", "deny"]), ("ipv6", &["allow", "deny"]), ("permitRootLogin", &["inherit", "no", "prohibit-password", "forced-commands-only", "yes"]), ]; const BASE_POLICY_KEYS: [&str; 5] = ["access", "password", "pubkey", "ipv4", "ipv6"]; fn invalid(message: String) -> CoreError { CoreError::new("USERS_SSH_POLICY_INVALID", message) } /// `username`: `None` skips the root-only check (Windows uses a placeholder name). pub fn normalize_policy(input: &Value, username: Option<&str>) -> Result { let mut values: BTreeMap<&str, String> = BTreeMap::new(); for (key, allowed) in POLICY_VALUES { let default = Policy::default().get(key).to_string(); let value = match input.get(key) { None | Some(Value::Null) => default, Some(Value::String(s)) => s.clone(), Some(other) => other.to_string(), }; if !allowed.contains(&value.as_str()) { return Err(invalid(format!("Invalid SSH policy value for {key}."))); } values.insert(key, value); } let policy = Policy { access: values["access"].clone(), password: values["password"].clone(), pubkey: values["pubkey"].clone(), ipv4: values["ipv4"].clone(), ipv6: values["ipv6"].clone(), permit_root_login: values["permitRootLogin"].clone(), }; if let Some(name) = username { if name != "root" && policy.permit_root_login != "inherit" { return Err(invalid("PermitRootLogin can only be set for the root account.".into())); } } Ok(policy) } pub fn policy_comment(username: &str, policy: &Policy) -> String { let mut keys: Vec<&str> = BASE_POLICY_KEYS.to_vec(); if policy.permit_root_login != "inherit" { keys.push("permitRootLogin"); } format!("# chj-policy {username} {}", keys.iter().map(|k| format!("{k}={}", policy.get(k))).collect::>().join(" ")) } pub type Policies = BTreeMap; /// Sorted like JavaScript `localeCompare` for the ASCII names allowed here. fn sorted(policies: &Policies) -> Vec<(&String, &Policy)> { let mut entries: Vec<(&String, &Policy)> = policies.iter().filter(|(_, p)| !p.is_default()).collect(); entries.sort_by(|a, b| locale_cmp(a.0, b.0)); entries } /// ICU-like ordering for `[a-z0-9_.-]` names: punctuation before digits before letters. pub fn locale_cmp(left: &str, right: &str) -> std::cmp::Ordering { let weight = |c: char| -> (u8, char) { match c { '_' => (0, c), '-' => (1, c), '.' => (2, c), ' ' => (0, c), '0'..='9' => (3, c), _ => (4, c.to_ascii_lowercase()), } }; let a: Vec<(u8, char)> = left.chars().map(weight).collect(); let b: Vec<(u8, char)> = right.chars().map(weight).collect(); a.cmp(&b).then_with(|| left.cmp(right)) } pub fn generate_managed_config(policies: &Policies) -> String { let entries = sorted(policies); if entries.is_empty() { return String::new(); } let mut lines: Vec = HEADER.iter().map(|s| s.to_string()).collect(); for (name, policy) in &entries { lines.push(policy_comment(name, policy)); } for (name, policy) in &entries { if policy.access == "deny" { lines.push(format!("DenyUsers {name}")); } if policy.ipv4 == "deny" { lines.push(format!("DenyUsers {name}@{IPV4_ALL}")); } if policy.ipv6 == "deny" { lines.push(format!("DenyUsers {name}@{IPV6_ALL}")); } } for (name, policy) in &entries { if policy.password == "inherit" && policy.pubkey == "inherit" && policy.permit_root_login == "inherit" { continue; } lines.push(format!("Match User {name}")); if policy.permit_root_login != "inherit" { lines.push(format!("\tPermitRootLogin {}", policy.permit_root_login)); } if policy.password != "inherit" { lines.push(format!("\tPasswordAuthentication {}", policy.password)); if policy.password == "no" { lines.push("\tKbdInteractiveAuthentication no".into()); } } if policy.pubkey != "inherit" { lines.push(format!("\tPubkeyAuthentication {}", policy.pubkey)); } } // Ends any Match context for files that sshd reads after this one. lines.push("Match all".into()); format!("{}\n", lines.join("\n")) } pub fn is_policy_name(name: &str) -> bool { let mut chars = name.chars(); matches!(chars.next(), Some(c) if c.is_ascii_lowercase() || c == '_') && name.len() <= 32 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') } /// Parses `key=value` pairs of a policy comment. pub fn policy_pairs(text: &str) -> Value { let mut map = Map::new(); for pair in text.split(' ') { let mut parts = pair.split('='); let key = parts.next().unwrap_or("").to_string(); let value = parts.next().map(|v| json!(v)).unwrap_or(Value::Null); map.insert(key, value); } Value::Object(map) } pub struct ManagedConfig { pub policies: Policies, pub consistent: bool, } pub fn parse_managed_config(text: &str) -> ManagedConfig { let mut policies = Policies::new(); for line in text.split('\n') { let line = line.strip_suffix('\r').unwrap_or(line); let Some(rest) = line.strip_prefix("# chj-policy ") else { continue }; let Some((name, values)) = rest.split_once(' ') else { continue }; if !is_policy_name(name) || values.is_empty() { continue; } match normalize_policy(&policy_pairs(values), Some(name)) { Ok(policy) => { policies.insert(name.to_string(), policy); } Err(_) => return ManagedConfig { policies: Policies::new(), consistent: false }, } } let consistent = text.is_empty() || generate_managed_config(&policies) == text; ManagedConfig { policies, consistent } } #[derive(Debug, Clone, Default, PartialEq)] pub struct Effective { /// Single-valued keys hold a string, multi-valued keys an array (as 0.1.0). pub values: BTreeMap, pub errors: Vec, } impl Effective { pub fn get(&self, key: &str) -> Option<&str> { self.values.get(key).and_then(Value::as_str) } pub fn list(&self, key: &str) -> Vec { self.values.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default() } } pub fn parse_effective(text: &str) -> Effective { let mut result = Effective::default(); for raw in text.split('\n') { let line = raw.strip_suffix('\r').unwrap_or(raw).trim(); if line.is_empty() { continue; } let parsed = line.split_once(' ').filter(|(k, _)| !k.is_empty() && k.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())); let Some((key, value)) = parsed else { result.errors.push(line.chars().take(300).collect()); continue; }; if MULTI_VALUE_KEYS.contains(&key) { let entry = result.values.entry(key.to_string()).or_insert_with(|| json!([])); if let Some(list) = entry.as_array_mut() { list.push(json!(value.trim())); } } else if !result.values.contains_key(key) { result.values.insert(key.to_string(), json!(value)); } } result } /// sshd-style wildcard match (`*`, `?`), case-sensitive. pub fn wildcard_match(pattern: &str, value: &str) -> bool { fn go(p: &[char], v: &[char]) -> bool { match (p.first(), v.first()) { (None, None) => true, (Some('*'), _) => go(&p[1..], v) || (!v.is_empty() && go(p, &v[1..])), (Some('?'), Some(_)) => go(&p[1..], &v[1..]), (Some(a), Some(b)) if a == b => go(&p[1..], &v[1..]), _ => false, } } let p: Vec = pattern.chars().collect(); let v: Vec = value.chars().collect(); go(&p, &v) } fn ip_family(value: &str) -> u8 { match value.parse::() { Ok(IpAddr::V4(_)) => 4, Ok(IpAddr::V6(_)) => 6, Err(_) => 0, } } fn in_subnet(network: IpAddr, bits: u32, address: IpAddr) -> Option { match (network, address) { (IpAddr::V4(n), IpAddr::V4(a)) => { if bits > 32 { return None; } let mask = if bits == 0 { 0 } else { u32::MAX << (32 - bits) }; Some(u32::from(n) & mask == u32::from(a) & mask) } (IpAddr::V6(n), IpAddr::V6(a)) => { if bits > 128 { return None; } let mask = if bits == 0 { 0 } else { u128::MAX << (128 - bits) }; Some(u128::from(n) & mask == u128::from(a) & mask) } _ => Some(false), } } /// `Some(bool)`, or `None` when the host part needs DNS names CH-J cannot evaluate. pub fn host_matches(pattern: &str, address: &str) -> Option { if pattern.is_empty() { return Some(true); } let family = ip_family(address); if let Some((network, bits)) = pattern.split_once('/') { let network_ip = network.parse::().ok()?; let bits: u32 = bits.parse().ok().filter(|_| bits.chars().all(|c| c.is_ascii_digit()))?; let network_family = ip_family(network); if network_family != family { return Some(false); } return in_subnet(network_ip, bits, address.parse().ok()?); } if ip_family(pattern) != 0 { return Some(ip_family(pattern) == family && pattern.to_lowercase() == address.to_lowercase()); } if pattern.chars().all(|c| c.is_ascii_hexdigit() || ":.*?".contains(c)) { return Some(wildcard_match(&pattern.to_lowercase(), &address.to_lowercase())); } None } fn user_entry_matches(entry: &str, username: &str, address: &str) -> Option { if entry.starts_with('!') { return None; } let (user_pattern, host) = match entry.find('@') { Some(at) => (&entry[..at], Some(&entry[at + 1..])), None => (entry, None), }; if !wildcard_match(user_pattern, username) { return Some(false); } match host { Some(host) => host_matches(host, address), None => Some(true), } } fn method_satisfiable(authentication_methods: Option<&str>, method: &str) -> bool { let value = authentication_methods.unwrap_or("any").trim(); if value.is_empty() || value == "any" { return true; } let accepted = |item: &str| { if method == "password" { item == "password" || item == "keyboard-interactive" || item.strip_prefix("keyboard-interactive:").is_some_and(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase())) } else { item == "publickey" } }; value.split_whitespace().any(|list| list.split(',').all(accepted)) } pub struct AccessContext<'a> { pub username: &'a str, pub groups: &'a [String], pub address: &'a str, pub method: Option<&'a str>, pub keyboard_interactive: bool, } #[derive(Debug, Clone)] pub struct Evaluation { /// `Some(true/false)` or `None` (uncertain). pub allowed: Option, pub reasons: Vec, pub methods: Value, pub method_allowed: Option, } pub fn evaluate_access(effective: &Effective, context: &AccessContext) -> Evaluation { let username = context.username; let mut reasons: Vec = Vec::new(); let mut allowed: Option = Some(true); let consider = |result: Option, reason: Value, allowed: &mut Option, reasons: &mut Vec| match result { Some(true) => { *allowed = Some(false); reasons.push(reason); } None if *allowed != Some(false) => { *allowed = None; let mut uncertain = reason; uncertain["uncertain"] = json!(true); reasons.push(uncertain); } _ => {} }; for entry in effective.list("denyusers") { let managed = entry == username || MANAGED_HOST_PATTERNS.iter().any(|p| entry == format!("{username}@{p}")); consider(user_entry_matches(&entry, username, context.address), json!({ "rule": "DenyUsers", "entry": entry, "managed": managed }), &mut allowed, &mut reasons); } let allow_users = effective.list("allowusers"); if !allow_users.is_empty() { let results: Vec> = allow_users.iter().map(|e| user_entry_matches(e, username, context.address)).collect(); if !results.contains(&Some(true)) { consider(if results.contains(&None) { None } else { Some(true) }, json!({ "rule": "AllowUsers", "entry": "not listed", "managed": false }), &mut allowed, &mut reasons); } } for pattern in effective.list("denygroups") { if context.groups.iter().any(|g| wildcard_match(&pattern, g)) { consider(Some(true), json!({ "rule": "DenyGroups", "entry": pattern, "managed": false }), &mut allowed, &mut reasons); } } let allow_groups = effective.list("allowgroups"); if !allow_groups.is_empty() && !context.groups.iter().any(|g| allow_groups.iter().any(|p| wildcard_match(p, g))) { consider(Some(true), json!({ "rule": "AllowGroups", "entry": "not a member", "managed": false }), &mut allowed, &mut reasons); } let root_login = match effective.get("permitrootlogin") { Some("without-password") => "prohibit-password", Some(v) => v, None => "yes", }; let password = effective.get("passwordauthentication") == Some("yes") || (context.keyboard_interactive && (effective.get("kbdinteractiveauthentication") == Some("yes") || effective.get("challengeresponseauthentication") == Some("yes"))); let pubkey = effective.get("pubkeyauthentication") != Some("no"); let methods_password = password && (username != "root" || root_login == "yes") && method_satisfiable(effective.get("authenticationmethods"), "password"); // forced-commands-only allows keys only with command="..." options; reported as key login with restriction. let methods_pubkey = pubkey && (username != "root" || ["yes", "prohibit-password", "forced-commands-only"].contains(&root_login)) && method_satisfiable(effective.get("authenticationmethods"), "publickey"); if username == "root" && root_login == "no" { consider(Some(true), json!({ "rule": "PermitRootLogin", "entry": "no", "managed": false }), &mut allowed, &mut reasons); } let method_allowed = context.method.map(|m| { let value = if m == "password" { methods_password } else { methods_pubkey }; match allowed { Some(true) => Some(value), Some(false) => Some(false), None => None, } }); Evaluation { allowed, reasons, methods: json!({ "password": methods_password, "publickey": methods_pubkey }), method_allowed: method_allowed.flatten() } } impl Evaluation { pub fn allowed_value(&self) -> Value { self.allowed.map(Value::from).unwrap_or(Value::Null) } } pub fn listener_families(effective: &Effective) -> Value { let family = effective.get("addressfamily").unwrap_or("any"); let addresses = effective.list("listenaddress"); let has = |version: u8| { addresses.iter().any(|entry| { let host = if let Some(rest) = entry.strip_prefix('[') { rest.split(']').next().unwrap_or("").to_string() } else { strip_port(entry) }; ip_family(&host) == version }) }; json!({ "ipv4": if family == "inet6" { false } else if addresses.is_empty() { true } else { has(4) }, "ipv6": if family == "inet" { false } else if addresses.is_empty() { true } else { has(6) } }) } /// `entry.replace(/:\d+$/, "")`. fn strip_port(entry: &str) -> String { if let Some(index) = entry.rfind(':') { let tail = &entry[index + 1..]; if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) { return entry[..index].to_string(); } } entry.to_string() } pub struct VerifyOptions<'a> { pub keyboard_interactive: bool, pub ipv4_pattern: &'a str, pub ipv6_pattern: &'a str, } impl Default for VerifyOptions<'_> { fn default() -> Self { VerifyOptions { keyboard_interactive: true, ipv4_pattern: IPV4_ALL, ipv6_pattern: IPV6_ALL } } } /// Checks that the regenerated configuration really produced the requested effective settings. pub fn verify_policy_effect(policy: &Policy, username: &str, effective: &Effective, label: &str, options: &VerifyOptions) -> Vec { let mut problems = Vec::new(); let deny = effective.list("denyusers"); let unknown = |key: &str| effective.get(key).unwrap_or("unknown").to_string(); if policy.access == "deny" && !deny.contains(&username.to_string()) { problems.push(format!("{label}: DenyUsers {username}")); } if policy.ipv4 == "deny" && !deny.contains(&format!("{username}@{}", options.ipv4_pattern)) { problems.push(format!("{label}: DenyUsers {username}@{}", options.ipv4_pattern)); } if policy.ipv6 == "deny" && !deny.contains(&format!("{username}@{}", options.ipv6_pattern)) { problems.push(format!("{label}: DenyUsers {username}@{}", options.ipv6_pattern)); } if policy.password == "no" { if effective.get("passwordauthentication") != Some("no") { problems.push(format!("{label}: PasswordAuthentication is {}", unknown("passwordauthentication"))); } let keyboard = effective.get("kbdinteractiveauthentication").or(effective.get("challengeresponseauthentication")); if options.keyboard_interactive && keyboard == Some("yes") { problems.push(format!("{label}: KbdInteractiveAuthentication is yes")); } } if policy.password == "yes" && effective.get("passwordauthentication") != Some("yes") { problems.push(format!("{label}: PasswordAuthentication is {}", unknown("passwordauthentication"))); } if policy.pubkey != "inherit" && effective.get("pubkeyauthentication").unwrap_or("unknown") != policy.pubkey { problems.push(format!("{label}: PubkeyAuthentication is {}", unknown("pubkeyauthentication"))); } if policy.permit_root_login != "inherit" { // sshd -T prints the historic alias "without-password" for prohibit-password. let current = match effective.get("permitrootlogin") { Some("without-password") => Some("prohibit-password"), other => other, }; if current != Some(policy.permit_root_login.as_str()) { problems.push(format!("{label}: PermitRootLogin is {}", unknown("permitrootlogin"))); } } problems } #[cfg(test)] mod tests { use super::*; #[test] fn generate_and_parse_roundtrip() { let mut policies = Policies::new(); policies.insert("bob".into(), normalize_policy(&json!({ "access": "deny" }), Some("bob")).unwrap()); policies.insert("alice".into(), normalize_policy(&json!({ "password": "no", "ipv6": "deny" }), Some("alice")).unwrap()); policies.insert("root".into(), normalize_policy(&json!({ "permitRootLogin": "prohibit-password" }), Some("root")).unwrap()); policies.insert("carol".into(), Policy::default()); let text = generate_managed_config(&policies); assert_eq!( text, "# CH-J Server Manager - chj.users managed SSH access policy.\n# Generated file. CH-J refuses to overwrite it after manual edits; keep custom rules in other files.\n\ # chj-policy alice access=allow password=no pubkey=inherit ipv4=allow ipv6=deny\n# chj-policy bob access=deny password=inherit pubkey=inherit ipv4=allow ipv6=allow\n\ # chj-policy root access=allow password=inherit pubkey=inherit ipv4=allow ipv6=allow permitRootLogin=prohibit-password\n\ DenyUsers alice@::/0\nDenyUsers bob\nMatch User alice\n\tPasswordAuthentication no\n\tKbdInteractiveAuthentication no\nMatch User root\n\tPermitRootLogin prohibit-password\nMatch all\n" ); let parsed = parse_managed_config(&text); assert!(parsed.consistent); assert_eq!(parsed.policies.len(), 3); assert!(!parse_managed_config(&text.replace("Match all", "Match all\n# edited")).consistent); assert!(normalize_policy(&json!({ "permitRootLogin": "yes" }), Some("bob")).is_err()); } #[test] fn evaluates_like_sshd() { let effective = parse_effective("passwordauthentication yes\npubkeyauthentication yes\ndenyusers bob@0.0.0.0/0\nallowgroups ssh users\npermitrootlogin without-password\nbroken\n"); assert_eq!(effective.errors, vec!["broken".to_string()]); let groups = vec!["ssh users".to_string()]; let bob4 = evaluate_access(&effective, &AccessContext { username: "bob", groups: &groups, address: "10.0.0.1", method: Some("password"), keyboard_interactive: true }); assert_eq!(bob4.allowed, Some(false)); let bob6 = evaluate_access(&effective, &AccessContext { username: "bob", groups: &groups, address: "2001:db8::1", method: Some("password"), keyboard_interactive: true }); assert_eq!(bob6.allowed, Some(true)); assert_eq!(bob6.method_allowed, Some(true)); let root = evaluate_access(&effective, &AccessContext { username: "root", groups: &groups, address: "10.0.0.1", method: Some("password"), keyboard_interactive: true }); assert_eq!(root.methods, json!({ "password": false, "publickey": true })); assert_eq!(host_matches("10.0.0.0/8", "10.1.2.3"), Some(true)); assert_eq!(host_matches("example.com", "10.1.2.3"), None); assert_eq!(host_matches("*:*", "2001:db8::1"), Some(true)); assert_eq!(host_matches("*.*.*.*", "2001:db8::1"), Some(false)); assert_eq!(listener_families(&parse_effective("listenaddress 0.0.0.0:22\n")), json!({ "ipv4": true, "ipv6": false })); assert!(locale_cmp("bob", "Carl").is_lt()); } }