"use strict"; const { WINDOWS_IPV4_PATTERN, WINDOWS_IPV6_PATTERN, isDefaultPolicy, normalizePolicy, policyComment } = require("./sshPolicy"); // Windows OpenSSH has no sshd_config.d by default, so CH-J keeps two marked sections inside // %ProgramData%\ssh\sshd_config: global DenyUsers lines placed before the first existing Match // block (never after one), and per-user Match blocks appended at the end, so existing Match // blocks such as "Match Group administrators" keep their priority. Only directives documented // for Windows OpenSSH are written (no KbdInteractiveAuthentication). Address families use the // patterns user@*.*.*.* and user@*:* because Windows OpenSSH turns "/" in DenyUsers into "\\". const GLOBAL_BEGIN = "# BEGIN CH-J chj.users global (managed by CH-J Server Manager, do not edit)"; const GLOBAL_END = "# END CH-J chj.users global"; const MATCH_BEGIN = "# BEGIN CH-J chj.users match (managed by CH-J Server Manager, do not edit)"; const MATCH_END = "# END CH-J chj.users match"; const SSH_NAME_PATTERN = /^[a-z0-9_][a-z0-9._-]{0,19}$/; const CONFIG_PATH = "C:\\ProgramData\\ssh\\sshd_config"; class WindowsPolicyError extends Error { constructor(code, message) { super(message); this.code = code; } } function sshName(name) { const value = String(name || "").toLowerCase(); return SSH_NAME_PATTERN.test(value) ? value : null; } function splitLines(text) { const eol = /\r\n/.test(text) ? "\r\n" : "\n"; const lines = text.split(/\r?\n/); const trailing = lines.length && lines[lines.length - 1] === ""; if (trailing) lines.pop(); return { eol, lines }; } // Locates managed sections and the structure CH-J depends on. Never throws on foreign content. function analyzeConfig(text) { const { eol, lines } = splitLines(String(text || "")); const marks = { globalBegin: [], globalEnd: [], matchBegin: [], matchEnd: [] }; lines.forEach((line, index) => { if (line === GLOBAL_BEGIN) marks.globalBegin.push(index); else if (line === GLOBAL_END) marks.globalEnd.push(index); else if (line === MATCH_BEGIN) marks.matchBegin.push(index); else if (line === MATCH_END) marks.matchEnd.push(index); }); const problems = []; for (const [begin, end] of [["globalBegin", "globalEnd"], ["matchBegin", "matchEnd"]]) { if (marks[begin].length > 1 || marks[end].length > 1 || marks[begin].length !== marks[end].length || (marks[begin][0] > marks[end][0])) problems.push("managed markers are damaged"); } const inManaged = (index) => (marks.globalBegin.length && index >= marks.globalBegin[0] && index <= marks.globalEnd[0]) || (marks.matchBegin.length && index >= marks.matchBegin[0] && index <= marks.matchEnd[0]); let firstMatch = -1; let include = false; lines.forEach((line, index) => { if (inManaged(index)) return; const keyword = line.trim().split(/\s+/)[0]?.toLowerCase(); if (keyword === "match" && firstMatch < 0) firstMatch = index; if (keyword === "include") include = true; }); if (marks.globalBegin.length && firstMatch >= 0 && marks.globalBegin[0] > firstMatch) problems.push("the CH-J global section is located after a Match block"); if (include) problems.push("Include directives are not supported by the CH-J Windows editor"); return { eol, lines, marks, firstMatch, include, problems, supported: problems.length === 0 }; } function section(lines, begin, end) { return begin.length ? lines.slice(begin[0], end[0] + 1) : []; } function renderSections(policies) { const entries = [...policies.entries()].filter(([, policy]) => !isDefaultPolicy(policy)).sort(([left], [right]) => left.localeCompare(right)); if (!entries.length) return { global: [], match: [] }; const global = [GLOBAL_BEGIN]; for (const [name, policy] of entries) global.push(policyComment(name, policy)); for (const [name, policy] of entries) { if (policy.access === "deny") global.push(`DenyUsers ${name}`); if (policy.ipv4 === "deny") global.push(`DenyUsers ${name}@${WINDOWS_IPV4_PATTERN}`); if (policy.ipv6 === "deny") global.push(`DenyUsers ${name}@${WINDOWS_IPV6_PATTERN}`); } global.push(GLOBAL_END); const matchEntries = entries.filter(([, policy]) => policy.password !== "inherit" || policy.pubkey !== "inherit"); const match = []; if (matchEntries.length) { match.push(MATCH_BEGIN); for (const [name, policy] of matchEntries) { match.push(`Match User ${name}`); if (policy.password !== "inherit") match.push(`\tPasswordAuthentication ${policy.password}`); if (policy.pubkey !== "inherit") match.push(`\tPubkeyAuthentication ${policy.pubkey}`); } match.push(MATCH_END); } return { global, match }; } function parseManagedPolicies(text) { const analysis = analyzeConfig(text); const policies = new Map(); for (const line of section(analysis.lines, analysis.marks.globalBegin, analysis.marks.globalEnd)) { const match = line.match(/^# chj-policy ([a-z0-9_][a-z0-9._-]{0,19}) (.+)$/); if (!match) continue; try { policies.set(match[1], normalizePolicy(Object.fromEntries(match[2].split(" ").map((pair) => pair.split("="))))); } catch { return { analysis, policies: new Map(), consistent: false }; } } const expected = renderSections(policies); const consistent = analysis.problems.length === 0 && JSON.stringify(section(analysis.lines, analysis.marks.globalBegin, analysis.marks.globalEnd)) === JSON.stringify(expected.global) && JSON.stringify(section(analysis.lines, analysis.marks.matchBegin, analysis.marks.matchEnd)) === JSON.stringify(expected.match); return { analysis, policies, consistent }; } // Rebuilds sshd_config with the managed sections replaced; every foreign line keeps its text and order. function renderConfig(text, policies) { const { analysis, consistent } = parseManagedPolicies(text); if (!analysis.supported) throw new WindowsPolicyError("USERS_SSH_LAYOUT_UNSUPPORTED", `sshd_config cannot be edited safely: ${analysis.problems.join("; ")}.`); if (!consistent) throw new WindowsPolicyError("USERS_SSH_MANAGED_FILE_MODIFIED", "The CH-J sections in sshd_config were edited manually; CH-J will not overwrite them."); const { lines, marks, eol } = analysis; const managed = new Set(); for (const [begin, end] of [[marks.globalBegin, marks.globalEnd], [marks.matchBegin, marks.matchEnd]]) { if (begin.length) for (let index = begin[0]; index <= end[0]; index += 1) managed.add(index); } const foreign = []; let firstMatch = -1; lines.forEach((line, index) => { if (managed.has(index)) return; if (firstMatch < 0 && line.trim().split(/\s+/)[0]?.toLowerCase() === "match") firstMatch = foreign.length; foreign.push(line); }); const { global, match } = renderSections(policies); let output = firstMatch < 0 ? [...foreign, ...global] : [...foreign.slice(0, firstMatch), ...global, ...foreign.slice(firstMatch)]; output = [...output, ...match]; return `${output.join(eol)}${eol}`; } module.exports = { CONFIG_PATH, GLOBAL_BEGIN, MATCH_BEGIN, SSH_NAME_PATTERN, WindowsPolicyError, analyzeConfig, parseManagedPolicies, renderConfig, sshName };