"use strict"; const net = require("node:net"); // Per-user SSH policy stored in one CH-J managed sshd drop-in. The "# chj-policy" lines are the // source of truth; the directives are regenerated from them, so any manual edit is detected. // permitRootLogin applies to the root account only (Linux). It is written into the policy comment only when // it differs from "inherit", so managed files created before this option stay byte-identical. const POLICY_DEFAULTS = Object.freeze({ access: "allow", password: "inherit", pubkey: "inherit", ipv4: "allow", ipv6: "allow", permitRootLogin: "inherit" }); const POLICY_VALUES = Object.freeze({ 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 = ["access", "password", "pubkey", "ipv4", "ipv6"]; function policyComment(username, policy) { const keys = policy.permitRootLogin && policy.permitRootLogin !== "inherit" ? [...BASE_POLICY_KEYS, "permitRootLogin"] : BASE_POLICY_KEYS; return `# chj-policy ${username} ${keys.map((key) => `${key}=${policy[key]}`).join(" ")}`; } const MANAGED_PATH = "/etc/ssh/sshd_config.d/01-chj-users.conf"; const HEADER = [ "# 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." ]; const USERNAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/; const IPV4_ALL = "0.0.0.0/0"; const IPV6_ALL = "::/0"; // Windows OpenSSH rewrites "/" to "\\" in AllowUsers/DenyUsers (domain/user syntax), so CIDR cannot be used there; // address-family wildcards are used instead: dotted IPv4 strings and colon-separated IPv6 strings. const WINDOWS_IPV4_PATTERN = "*.*.*.*"; const WINDOWS_IPV6_PATTERN = "*:*"; const MANAGED_HOST_PATTERNS = [IPV4_ALL, IPV6_ALL, WINDOWS_IPV4_PATTERN, WINDOWS_IPV6_PATTERN]; const MULTI_VALUE_KEYS = new Set(["denyusers", "allowusers", "denygroups", "allowgroups", "listenaddress"]); class PolicyError extends Error { constructor(code, message) { super(message); this.code = code; } } function normalizePolicy(input = {}, username = null) { const policy = {}; for (const [key, allowed] of Object.entries(POLICY_VALUES)) { const value = input[key] === undefined || input[key] === null ? POLICY_DEFAULTS[key] : String(input[key]); if (!allowed.includes(value)) throw new PolicyError("USERS_SSH_POLICY_INVALID", `Invalid SSH policy value for ${key}.`); policy[key] = value; } if (username !== null && username !== "root" && policy.permitRootLogin !== "inherit") throw new PolicyError("USERS_SSH_POLICY_INVALID", "PermitRootLogin can only be set for the root account."); return policy; } function isDefaultPolicy(policy) { return Object.keys(POLICY_DEFAULTS).every((key) => policy[key] === POLICY_DEFAULTS[key]); } function generateManagedConfig(policies) { const entries = [...policies.entries()].filter(([, policy]) => !isDefaultPolicy(policy)).sort(([left], [right]) => left.localeCompare(right)); if (!entries.length) return ""; const lines = [...HEADER]; for (const [username, policy] of entries) { lines.push(policyComment(username, policy)); } for (const [username, policy] of entries) { if (policy.access === "deny") lines.push(`DenyUsers ${username}`); if (policy.ipv4 === "deny") lines.push(`DenyUsers ${username}@${IPV4_ALL}`); if (policy.ipv6 === "deny") lines.push(`DenyUsers ${username}@${IPV6_ALL}`); } for (const [username, policy] of entries) { if (policy.password === "inherit" && policy.pubkey === "inherit" && policy.permitRootLogin === "inherit") continue; lines.push(`Match User ${username}`); if (policy.permitRootLogin !== "inherit") lines.push(`\tPermitRootLogin ${policy.permitRootLogin}`); if (policy.password !== "inherit") { lines.push(`\tPasswordAuthentication ${policy.password}`); if (policy.password === "no") lines.push("\tKbdInteractiveAuthentication no"); } if (policy.pubkey !== "inherit") lines.push(`\tPubkeyAuthentication ${policy.pubkey}`); } // Ends any Match context for files that sshd reads after this one. lines.push("Match all"); return `${lines.join("\n")}\n`; } function parseManagedConfig(text) { const source = String(text || ""); const policies = new Map(); for (const line of source.split(/\r?\n/)) { const match = line.match(/^# chj-policy ([a-z_][a-z0-9_-]{0,31}) (.+)$/); if (!match) continue; const values = Object.fromEntries(match[2].split(" ").map((pair) => pair.split("="))); try { policies.set(match[1], normalizePolicy(values, match[1])); } catch { return { policies: new Map(), consistent: false }; } } const consistent = source === "" ? true : generateManagedConfig(policies) === source; return { policies, consistent }; } function parseEffective(text) { const result = { values: {}, errors: [] }; for (const raw of String(text || "").split(/\r?\n/)) { const line = raw.trim(); if (!line) continue; const match = line.match(/^([a-z0-9]+) (.*)$/); if (!match) { result.errors.push(line.slice(0, 300)); continue; } const [, key, value] = match; // sshd -T prints one entry per line; an entry may contain spaces (AllowGroups "openssh users"). if (MULTI_VALUE_KEYS.has(key)) (result.values[key] ||= []).push(value.trim()); else if (!(key in result.values)) result.values[key] = value; } return result; } function wildcardMatch(pattern, value) { const expression = String(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, "."); return new RegExp(`^${expression}$`).test(String(value)); } // Returns true/false, or null when the host part needs DNS names CH-J cannot evaluate. function hostMatches(pattern, address) { if (!pattern) return true; const family = net.isIP(address); if (pattern.includes("/")) { const [network, bitsText] = pattern.split("/"); const networkFamily = net.isIP(network); const bits = Number(bitsText); if (!networkFamily || !Number.isInteger(bits)) return null; if (networkFamily !== family) return false; const list = new net.BlockList(); try { list.addSubnet(network, bits, networkFamily === 6 ? "ipv6" : "ipv4"); } catch { return null; } return list.check(address, family === 6 ? "ipv6" : "ipv4"); } if (net.isIP(pattern)) return net.isIP(pattern) === family && pattern.toLowerCase() === address.toLowerCase(); if (/^[0-9a-fA-F:.*?]+$/.test(pattern)) return wildcardMatch(pattern.toLowerCase(), address.toLowerCase()); return null; } function userEntryMatches(entry, username, address) { if (entry.startsWith("!")) return null; const at = entry.indexOf("@"); const userPattern = at >= 0 ? entry.slice(0, at) : entry; if (!wildcardMatch(userPattern, username)) return false; return at >= 0 ? hostMatches(entry.slice(at + 1), address) : true; } function methodSatisfiable(authenticationMethods, method) { const value = String(authenticationMethods || "any").trim(); if (!value || value === "any") return true; const accepted = method === "password" ? /^(password|keyboard-interactive(:[a-z]+)?)$/ : /^publickey$/; return value.split(/\s+/).some((list) => list.split(",").every((item) => accepted.test(item))); } // Evaluates sshd -T output the way sshd's allowed_user() does for user and address rules. // keyboardInteractive=false for Windows OpenSSH, which does not support KbdInteractiveAuthentication. function evaluateAccess(effective, { username, groups = [], address, method = null, keyboardInteractive = true }) { const values = effective.values || {}; const reasons = []; let allowed = true; const consider = (result, reason) => { if (result === true) { allowed = false; reasons.push(reason); } else if (result === null && allowed !== false) { allowed = null; reasons.push({ ...reason, uncertain: true }); } }; for (const entry of values.denyusers || []) { const managed = entry === username || MANAGED_HOST_PATTERNS.some((pattern) => entry === `${username}@${pattern}`); consider(userEntryMatches(entry, username, address), { rule: "DenyUsers", entry, managed }); } if ((values.allowusers || []).length) { const results = values.allowusers.map((entry) => userEntryMatches(entry, username, address)); if (!results.includes(true)) consider(results.includes(null) ? null : true, { rule: "AllowUsers", entry: "not listed", managed: false }); } for (const pattern of values.denygroups || []) { if (groups.some((group) => wildcardMatch(pattern, group))) consider(true, { rule: "DenyGroups", entry: pattern, managed: false }); } if ((values.allowgroups || []).length && !groups.some((group) => values.allowgroups.some((pattern) => wildcardMatch(pattern, group)))) { consider(true, { rule: "AllowGroups", entry: "not a member", managed: false }); } const rootLogin = values.permitrootlogin === "without-password" ? "prohibit-password" : (values.permitrootlogin || "yes"); const password = values.passwordauthentication === "yes" || (keyboardInteractive && (values.kbdinteractiveauthentication === "yes" || values.challengeresponseauthentication === "yes")); const pubkey = values.pubkeyauthentication !== "no"; const methods = { password: password && (username !== "root" || rootLogin === "yes") && methodSatisfiable(values.authenticationmethods, "password"), // forced-commands-only allows keys only with command="..." options; reported as key login with restriction. publickey: pubkey && (username !== "root" || ["yes", "prohibit-password", "forced-commands-only"].includes(rootLogin)) && methodSatisfiable(values.authenticationmethods, "publickey") }; if (username === "root" && rootLogin === "no") consider(true, { rule: "PermitRootLogin", entry: "no", managed: false }); return { allowed, reasons, methods, methodAllowed: method ? (allowed === true ? methods[method] : allowed === false ? false : null) : null }; } function listenerFamilies(effective) { const values = effective.values || {}; const family = values.addressfamily || "any"; const addresses = values.listenaddress || []; const has = (version) => addresses.some((entry) => { const host = entry.startsWith("[") ? entry.slice(1, entry.indexOf("]")) : entry.replace(/:\d+$/, ""); return net.isIP(host) === version; }); return { ipv4: family === "inet6" ? false : (addresses.length ? has(4) : true), ipv6: family === "inet" ? false : (addresses.length ? has(6) : true) }; } // Checks that the regenerated drop-in really produced the requested effective settings. function verifyPolicyEffect(policy, username, effective, familyLabel, { keyboardInteractive = true, ipv4Pattern = IPV4_ALL, ipv6Pattern = IPV6_ALL } = {}) { const values = effective.values || {}; const problems = []; const deny = values.denyusers || []; if (policy.access === "deny" && !deny.includes(username)) problems.push(`${familyLabel}: DenyUsers ${username}`); if (policy.ipv4 === "deny" && !deny.includes(`${username}@${ipv4Pattern}`)) problems.push(`${familyLabel}: DenyUsers ${username}@${ipv4Pattern}`); if (policy.ipv6 === "deny" && !deny.includes(`${username}@${ipv6Pattern}`)) problems.push(`${familyLabel}: DenyUsers ${username}@${ipv6Pattern}`); if (policy.password === "no") { if (values.passwordauthentication !== "no") problems.push(`${familyLabel}: PasswordAuthentication is ${values.passwordauthentication || "unknown"}`); const keyboard = values.kbdinteractiveauthentication ?? values.challengeresponseauthentication; if (keyboardInteractive && keyboard === "yes") problems.push(`${familyLabel}: KbdInteractiveAuthentication is yes`); } if (policy.password === "yes" && values.passwordauthentication !== "yes") problems.push(`${familyLabel}: PasswordAuthentication is ${values.passwordauthentication || "unknown"}`); if (policy.pubkey !== "inherit" && (values.pubkeyauthentication || "unknown") !== policy.pubkey) problems.push(`${familyLabel}: PubkeyAuthentication is ${values.pubkeyauthentication || "unknown"}`); if (policy.permitRootLogin && policy.permitRootLogin !== "inherit") { // sshd -T prints the historic alias "without-password" for prohibit-password. const effective = values.permitrootlogin === "without-password" ? "prohibit-password" : values.permitrootlogin; if (effective !== policy.permitRootLogin) problems.push(`${familyLabel}: PermitRootLogin is ${values.permitrootlogin || "unknown"}`); } return problems; } module.exports = { IPV4_ALL, IPV6_ALL, MANAGED_PATH, WINDOWS_IPV4_PATTERN, WINDOWS_IPV6_PATTERN, POLICY_DEFAULTS, POLICY_VALUES, PolicyError, USERNAME_PATTERN, evaluateAccess, generateManagedConfig, hostMatches, isDefaultPolicy, listenerFamilies, normalizePolicy, parseEffective, policyComment, parseManagedConfig, verifyPolicyEffect, wildcardMatch };