CH-J Server Managerserver management over SSH
Menu
Published source

CH-J Server Manager

Browse directories and files for a specific application release.

Download source ZIP
CH-J / Server Manager 377 files
core-20261019T111616Z-160a00bfcddc Added: · Built:
windowsSshPolicy.js Added: Date not recorded 7.0 KB · 145 lines
Code Download file
1"use strict";
3const { WINDOWS_IPV4_PATTERN, WINDOWS_IPV6_PATTERN, isDefaultPolicy, normalizePolicy, policyComment } = require("./sshPolicy");
5// Windows OpenSSH has no sshd_config.d by default, so CH-J keeps two marked sections inside
6// %ProgramData%\ssh\sshd_config: global DenyUsers lines placed before the first existing Match
7// block (never after one), and per-user Match blocks appended at the end, so existing Match
8// blocks such as "Match Group administrators" keep their priority. Only directives documented
9// for Windows OpenSSH are written (no KbdInteractiveAuthentication). Address families use the
10// patterns user@*.*.*.* and user@*:* because Windows OpenSSH turns "/" in DenyUsers into "\\".
11const GLOBAL_BEGIN = "# BEGIN CH-J chj.users global (managed by CH-J Server Manager, do not edit)";
12const GLOBAL_END = "# END CH-J chj.users global";
13const MATCH_BEGIN = "# BEGIN CH-J chj.users match (managed by CH-J Server Manager, do not edit)";
14const MATCH_END = "# END CH-J chj.users match";
15const SSH_NAME_PATTERN = /^[a-z0-9_][a-z0-9._-]{0,19}$/;
16const CONFIG_PATH = "C:\\ProgramData\\ssh\\sshd_config";
18class WindowsPolicyError extends Error {
19 constructor(code, message) {
20 super(message);
21 this.code = code;
22 }
25function sshName(name) {
26 const value = String(name || "").toLowerCase();
27 return SSH_NAME_PATTERN.test(value) ? value : null;
30function splitLines(text) {
31 const eol = /\r\n/.test(text) ? "\r\n" : "\n";
32 const lines = text.split(/\r?\n/);
33 const trailing = lines.length && lines[lines.length - 1] === "";
34 if (trailing) lines.pop();
35 return { eol, lines };
38// Locates managed sections and the structure CH-J depends on. Never throws on foreign content.
39function analyzeConfig(text) {
40 const { eol, lines } = splitLines(String(text || ""));
41 const marks = { globalBegin: [], globalEnd: [], matchBegin: [], matchEnd: [] };
42 lines.forEach((line, index) => {
43 if (line === GLOBAL_BEGIN) marks.globalBegin.push(index);
44 else if (line === GLOBAL_END) marks.globalEnd.push(index);
45 else if (line === MATCH_BEGIN) marks.matchBegin.push(index);
46 else if (line === MATCH_END) marks.matchEnd.push(index);
47 });
48 const problems = [];
49 for (const [begin, end] of [["globalBegin", "globalEnd"], ["matchBegin", "matchEnd"]]) {
50 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");
51 }
52 const inManaged = (index) => (marks.globalBegin.length && index >= marks.globalBegin[0] && index <= marks.globalEnd[0])
53 || (marks.matchBegin.length && index >= marks.matchBegin[0] && index <= marks.matchEnd[0]);
54 let firstMatch = -1;
55 let include = false;
56 lines.forEach((line, index) => {
57 if (inManaged(index)) return;
58 const keyword = line.trim().split(/\s+/)[0]?.toLowerCase();
59 if (keyword === "match" && firstMatch < 0) firstMatch = index;
60 if (keyword === "include") include = true;
61 });
62 if (marks.globalBegin.length && firstMatch >= 0 && marks.globalBegin[0] > firstMatch) problems.push("the CH-J global section is located after a Match block");
63 if (include) problems.push("Include directives are not supported by the CH-J Windows editor");
64 return { eol, lines, marks, firstMatch, include, problems, supported: problems.length === 0 };
67function section(lines, begin, end) {
68 return begin.length ? lines.slice(begin[0], end[0] + 1) : [];
71function renderSections(policies) {
72 const entries = [...policies.entries()].filter(([, policy]) => !isDefaultPolicy(policy)).sort(([left], [right]) => left.localeCompare(right));
73 if (!entries.length) return { global: [], match: [] };
74 const global = [GLOBAL_BEGIN];
75 for (const [name, policy] of entries) global.push(policyComment(name, policy));
76 for (const [name, policy] of entries) {
77 if (policy.access === "deny") global.push(`DenyUsers ${name}`);
78 if (policy.ipv4 === "deny") global.push(`DenyUsers ${name}@${WINDOWS_IPV4_PATTERN}`);
79 if (policy.ipv6 === "deny") global.push(`DenyUsers ${name}@${WINDOWS_IPV6_PATTERN}`);
80 }
81 global.push(GLOBAL_END);
82 const matchEntries = entries.filter(([, policy]) => policy.password !== "inherit" || policy.pubkey !== "inherit");
83 const match = [];
84 if (matchEntries.length) {
85 match.push(MATCH_BEGIN);
86 for (const [name, policy] of matchEntries) {
87 match.push(`Match User ${name}`);
88 if (policy.password !== "inherit") match.push(`\tPasswordAuthentication ${policy.password}`);
89 if (policy.pubkey !== "inherit") match.push(`\tPubkeyAuthentication ${policy.pubkey}`);
90 }
91 match.push(MATCH_END);
92 }
93 return { global, match };
96function parseManagedPolicies(text) {
97 const analysis = analyzeConfig(text);
98 const policies = new Map();
99 for (const line of section(analysis.lines, analysis.marks.globalBegin, analysis.marks.globalEnd)) {
100 const match = line.match(/^# chj-policy ([a-z0-9_][a-z0-9._-]{0,19}) (.+)$/);
101 if (!match) continue;
102 try { policies.set(match[1], normalizePolicy(Object.fromEntries(match[2].split(" ").map((pair) => pair.split("="))))); }
103 catch { return { analysis, policies: new Map(), consistent: false }; }
104 }
105 const expected = renderSections(policies);
106 const consistent = analysis.problems.length === 0
107 && JSON.stringify(section(analysis.lines, analysis.marks.globalBegin, analysis.marks.globalEnd)) === JSON.stringify(expected.global)
108 && JSON.stringify(section(analysis.lines, analysis.marks.matchBegin, analysis.marks.matchEnd)) === JSON.stringify(expected.match);
109 return { analysis, policies, consistent };
112// Rebuilds sshd_config with the managed sections replaced; every foreign line keeps its text and order.
113function renderConfig(text, policies) {
114 const { analysis, consistent } = parseManagedPolicies(text);
115 if (!analysis.supported) throw new WindowsPolicyError("USERS_SSH_LAYOUT_UNSUPPORTED", `sshd_config cannot be edited safely: ${analysis.problems.join("; ")}.`);
116 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.");
117 const { lines, marks, eol } = analysis;
118 const managed = new Set();
119 for (const [begin, end] of [[marks.globalBegin, marks.globalEnd], [marks.matchBegin, marks.matchEnd]]) {
120 if (begin.length) for (let index = begin[0]; index <= end[0]; index += 1) managed.add(index);
121 }
122 const foreign = [];
123 let firstMatch = -1;
124 lines.forEach((line, index) => {
125 if (managed.has(index)) return;
126 if (firstMatch < 0 && line.trim().split(/\s+/)[0]?.toLowerCase() === "match") firstMatch = foreign.length;
127 foreign.push(line);
128 });
129 const { global, match } = renderSections(policies);
130 let output = firstMatch < 0 ? [...foreign, ...global] : [...foreign.slice(0, firstMatch), ...global, ...foreign.slice(firstMatch)];
131 output = [...output, ...match];
132 return `${output.join(eol)}${eol}`;
135module.exports = {
136 CONFIG_PATH,
137 GLOBAL_BEGIN,
138 MATCH_BEGIN,
139 SSH_NAME_PATTERN,
140 WindowsPolicyError,
141 analyzeConfig,
142 parseManagedPolicies,
143 renderConfig,
144 sshName
145};
SHA-256b3b8de8cd49fbecd267a8de8a6794d1284eaa825b3779596d7e2e2cc029b7706

CH-J Proprietary Software License 1.14 · Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.

Archive SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Files