CH-J Server ManagerSSH를 통한 서버 관리
메뉴
공개된 소스

CH-J Server Manager

특정 애플리케이션 릴리스의 디렉터리와 파일을 살펴보세요.

소스 ZIP 다운로드
CH-J / Server Manager 377 개 파일
core-20261019T111616Z-160a00bfcddc 추가됨: · 빌드됨:
sshPolicy.js 추가됨: 날짜 기록 없음 12.8 KB · 256 줄
코드 파일 다운로드
1"use strict";
3const net = require("node:net");
5// Per-user SSH policy stored in one CH-J managed sshd drop-in. The "# chj-policy" lines are the
6// source of truth; the directives are regenerated from them, so any manual edit is detected.
7// permitRootLogin applies to the root account only (Linux). It is written into the policy comment only when
8// it differs from "inherit", so managed files created before this option stay byte-identical.
9const POLICY_DEFAULTS = Object.freeze({ access: "allow", password: "inherit", pubkey: "inherit", ipv4: "allow", ipv6: "allow", permitRootLogin: "inherit" });
10const POLICY_VALUES = Object.freeze({
11 access: ["allow", "deny"],
12 password: ["inherit", "yes", "no"],
13 pubkey: ["inherit", "yes", "no"],
14 ipv4: ["allow", "deny"],
15 ipv6: ["allow", "deny"],
16 permitRootLogin: ["inherit", "no", "prohibit-password", "forced-commands-only", "yes"]
17});
18const BASE_POLICY_KEYS = ["access", "password", "pubkey", "ipv4", "ipv6"];
20function policyComment(username, policy) {
21 const keys = policy.permitRootLogin && policy.permitRootLogin !== "inherit" ? [...BASE_POLICY_KEYS, "permitRootLogin"] : BASE_POLICY_KEYS;
22 return `# chj-policy ${username} ${keys.map((key) => `${key}=${policy[key]}`).join(" ")}`;
24const MANAGED_PATH = "/etc/ssh/sshd_config.d/01-chj-users.conf";
25const HEADER = [
26 "# CH-J Server Manager - chj.users managed SSH access policy.",
27 "# Generated file. CH-J refuses to overwrite it after manual edits; keep custom rules in other files."
28];
29const USERNAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
30const IPV4_ALL = "0.0.0.0/0";
31const IPV6_ALL = "::/0";
32// Windows OpenSSH rewrites "/" to "\\" in AllowUsers/DenyUsers (domain/user syntax), so CIDR cannot be used there;
33// address-family wildcards are used instead: dotted IPv4 strings and colon-separated IPv6 strings.
34const WINDOWS_IPV4_PATTERN = "*.*.*.*";
35const WINDOWS_IPV6_PATTERN = "*:*";
36const MANAGED_HOST_PATTERNS = [IPV4_ALL, IPV6_ALL, WINDOWS_IPV4_PATTERN, WINDOWS_IPV6_PATTERN];
37const MULTI_VALUE_KEYS = new Set(["denyusers", "allowusers", "denygroups", "allowgroups", "listenaddress"]);
39class PolicyError extends Error {
40 constructor(code, message) {
41 super(message);
42 this.code = code;
43 }
46function normalizePolicy(input = {}, username = null) {
47 const policy = {};
48 for (const [key, allowed] of Object.entries(POLICY_VALUES)) {
49 const value = input[key] === undefined || input[key] === null ? POLICY_DEFAULTS[key] : String(input[key]);
50 if (!allowed.includes(value)) throw new PolicyError("USERS_SSH_POLICY_INVALID", `Invalid SSH policy value for ${key}.`);
51 policy[key] = value;
52 }
53 if (username !== null && username !== "root" && policy.permitRootLogin !== "inherit") throw new PolicyError("USERS_SSH_POLICY_INVALID", "PermitRootLogin can only be set for the root account.");
54 return policy;
57function isDefaultPolicy(policy) {
58 return Object.keys(POLICY_DEFAULTS).every((key) => policy[key] === POLICY_DEFAULTS[key]);
61function generateManagedConfig(policies) {
62 const entries = [...policies.entries()].filter(([, policy]) => !isDefaultPolicy(policy)).sort(([left], [right]) => left.localeCompare(right));
63 if (!entries.length) return "";
64 const lines = [...HEADER];
65 for (const [username, policy] of entries) {
66 lines.push(policyComment(username, policy));
67 }
68 for (const [username, policy] of entries) {
69 if (policy.access === "deny") lines.push(`DenyUsers ${username}`);
70 if (policy.ipv4 === "deny") lines.push(`DenyUsers ${username}@${IPV4_ALL}`);
71 if (policy.ipv6 === "deny") lines.push(`DenyUsers ${username}@${IPV6_ALL}`);
72 }
73 for (const [username, policy] of entries) {
74 if (policy.password === "inherit" && policy.pubkey === "inherit" && policy.permitRootLogin === "inherit") continue;
75 lines.push(`Match User ${username}`);
76 if (policy.permitRootLogin !== "inherit") lines.push(`\tPermitRootLogin ${policy.permitRootLogin}`);
77 if (policy.password !== "inherit") {
78 lines.push(`\tPasswordAuthentication ${policy.password}`);
79 if (policy.password === "no") lines.push("\tKbdInteractiveAuthentication no");
80 }
81 if (policy.pubkey !== "inherit") lines.push(`\tPubkeyAuthentication ${policy.pubkey}`);
82 }
83 // Ends any Match context for files that sshd reads after this one.
84 lines.push("Match all");
85 return `${lines.join("\n")}\n`;
88function parseManagedConfig(text) {
89 const source = String(text || "");
90 const policies = new Map();
91 for (const line of source.split(/\r?\n/)) {
92 const match = line.match(/^# chj-policy ([a-z_][a-z0-9_-]{0,31}) (.+)$/);
93 if (!match) continue;
94 const values = Object.fromEntries(match[2].split(" ").map((pair) => pair.split("=")));
95 try { policies.set(match[1], normalizePolicy(values, match[1])); }
96 catch { return { policies: new Map(), consistent: false }; }
97 }
98 const consistent = source === "" ? true : generateManagedConfig(policies) === source;
99 return { policies, consistent };
102function parseEffective(text) {
103 const result = { values: {}, errors: [] };
104 for (const raw of String(text || "").split(/\r?\n/)) {
105 const line = raw.trim();
106 if (!line) continue;
107 const match = line.match(/^([a-z0-9]+) (.*)$/);
108 if (!match) { result.errors.push(line.slice(0, 300)); continue; }
109 const [, key, value] = match;
110 // sshd -T prints one entry per line; an entry may contain spaces (AllowGroups "openssh users").
111 if (MULTI_VALUE_KEYS.has(key)) (result.values[key] ||= []).push(value.trim());
112 else if (!(key in result.values)) result.values[key] = value;
113 }
114 return result;
117function wildcardMatch(pattern, value) {
118 const expression = String(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
119 return new RegExp(`^${expression}$`).test(String(value));
122// Returns true/false, or null when the host part needs DNS names CH-J cannot evaluate.
123function hostMatches(pattern, address) {
124 if (!pattern) return true;
125 const family = net.isIP(address);
126 if (pattern.includes("/")) {
127 const [network, bitsText] = pattern.split("/");
128 const networkFamily = net.isIP(network);
129 const bits = Number(bitsText);
130 if (!networkFamily || !Number.isInteger(bits)) return null;
131 if (networkFamily !== family) return false;
132 const list = new net.BlockList();
133 try { list.addSubnet(network, bits, networkFamily === 6 ? "ipv6" : "ipv4"); }
134 catch { return null; }
135 return list.check(address, family === 6 ? "ipv6" : "ipv4");
136 }
137 if (net.isIP(pattern)) return net.isIP(pattern) === family && pattern.toLowerCase() === address.toLowerCase();
138 if (/^[0-9a-fA-F:.*?]+$/.test(pattern)) return wildcardMatch(pattern.toLowerCase(), address.toLowerCase());
139 return null;
142function userEntryMatches(entry, username, address) {
143 if (entry.startsWith("!")) return null;
144 const at = entry.indexOf("@");
145 const userPattern = at >= 0 ? entry.slice(0, at) : entry;
146 if (!wildcardMatch(userPattern, username)) return false;
147 return at >= 0 ? hostMatches(entry.slice(at + 1), address) : true;
150function methodSatisfiable(authenticationMethods, method) {
151 const value = String(authenticationMethods || "any").trim();
152 if (!value || value === "any") return true;
153 const accepted = method === "password" ? /^(password|keyboard-interactive(:[a-z]+)?)$/ : /^publickey$/;
154 return value.split(/\s+/).some((list) => list.split(",").every((item) => accepted.test(item)));
157// Evaluates sshd -T output the way sshd's allowed_user() does for user and address rules.
158// keyboardInteractive=false for Windows OpenSSH, which does not support KbdInteractiveAuthentication.
159function evaluateAccess(effective, { username, groups = [], address, method = null, keyboardInteractive = true }) {
160 const values = effective.values || {};
161 const reasons = [];
162 let allowed = true;
163 const consider = (result, reason) => {
164 if (result === true) { allowed = false; reasons.push(reason); }
165 else if (result === null && allowed !== false) { allowed = null; reasons.push({ ...reason, uncertain: true }); }
166 };
167 for (const entry of values.denyusers || []) {
168 const managed = entry === username || MANAGED_HOST_PATTERNS.some((pattern) => entry === `${username}@${pattern}`);
169 consider(userEntryMatches(entry, username, address), { rule: "DenyUsers", entry, managed });
170 }
171 if ((values.allowusers || []).length) {
172 const results = values.allowusers.map((entry) => userEntryMatches(entry, username, address));
173 if (!results.includes(true)) consider(results.includes(null) ? null : true, { rule: "AllowUsers", entry: "not listed", managed: false });
174 }
175 for (const pattern of values.denygroups || []) {
176 if (groups.some((group) => wildcardMatch(pattern, group))) consider(true, { rule: "DenyGroups", entry: pattern, managed: false });
177 }
178 if ((values.allowgroups || []).length && !groups.some((group) => values.allowgroups.some((pattern) => wildcardMatch(pattern, group)))) {
179 consider(true, { rule: "AllowGroups", entry: "not a member", managed: false });
180 }
181 const rootLogin = values.permitrootlogin === "without-password" ? "prohibit-password" : (values.permitrootlogin || "yes");
182 const password = values.passwordauthentication === "yes" || (keyboardInteractive && (values.kbdinteractiveauthentication === "yes" || values.challengeresponseauthentication === "yes"));
183 const pubkey = values.pubkeyauthentication !== "no";
184 const methods = {
185 password: password && (username !== "root" || rootLogin === "yes") && methodSatisfiable(values.authenticationmethods, "password"),
186 // forced-commands-only allows keys only with command="..." options; reported as key login with restriction.
187 publickey: pubkey && (username !== "root" || ["yes", "prohibit-password", "forced-commands-only"].includes(rootLogin)) && methodSatisfiable(values.authenticationmethods, "publickey")
188 };
189 if (username === "root" && rootLogin === "no") consider(true, { rule: "PermitRootLogin", entry: "no", managed: false });
190 return {
191 allowed,
192 reasons,
193 methods,
194 methodAllowed: method ? (allowed === true ? methods[method] : allowed === false ? false : null) : null
195 };
198function listenerFamilies(effective) {
199 const values = effective.values || {};
200 const family = values.addressfamily || "any";
201 const addresses = values.listenaddress || [];
202 const has = (version) => addresses.some((entry) => {
203 const host = entry.startsWith("[") ? entry.slice(1, entry.indexOf("]")) : entry.replace(/:\d+$/, "");
204 return net.isIP(host) === version;
205 });
206 return {
207 ipv4: family === "inet6" ? false : (addresses.length ? has(4) : true),
208 ipv6: family === "inet" ? false : (addresses.length ? has(6) : true)
209 };
212// Checks that the regenerated drop-in really produced the requested effective settings.
213function verifyPolicyEffect(policy, username, effective, familyLabel, { keyboardInteractive = true, ipv4Pattern = IPV4_ALL, ipv6Pattern = IPV6_ALL } = {}) {
214 const values = effective.values || {};
215 const problems = [];
216 const deny = values.denyusers || [];
217 if (policy.access === "deny" && !deny.includes(username)) problems.push(`${familyLabel}: DenyUsers ${username}`);
218 if (policy.ipv4 === "deny" && !deny.includes(`${username}@${ipv4Pattern}`)) problems.push(`${familyLabel}: DenyUsers ${username}@${ipv4Pattern}`);
219 if (policy.ipv6 === "deny" && !deny.includes(`${username}@${ipv6Pattern}`)) problems.push(`${familyLabel}: DenyUsers ${username}@${ipv6Pattern}`);
220 if (policy.password === "no") {
221 if (values.passwordauthentication !== "no") problems.push(`${familyLabel}: PasswordAuthentication is ${values.passwordauthentication || "unknown"}`);
222 const keyboard = values.kbdinteractiveauthentication ?? values.challengeresponseauthentication;
223 if (keyboardInteractive && keyboard === "yes") problems.push(`${familyLabel}: KbdInteractiveAuthentication is yes`);
224 }
225 if (policy.password === "yes" && values.passwordauthentication !== "yes") problems.push(`${familyLabel}: PasswordAuthentication is ${values.passwordauthentication || "unknown"}`);
226 if (policy.pubkey !== "inherit" && (values.pubkeyauthentication || "unknown") !== policy.pubkey) problems.push(`${familyLabel}: PubkeyAuthentication is ${values.pubkeyauthentication || "unknown"}`);
227 if (policy.permitRootLogin && policy.permitRootLogin !== "inherit") {
228 // sshd -T prints the historic alias "without-password" for prohibit-password.
229 const effective = values.permitrootlogin === "without-password" ? "prohibit-password" : values.permitrootlogin;
230 if (effective !== policy.permitRootLogin) problems.push(`${familyLabel}: PermitRootLogin is ${values.permitrootlogin || "unknown"}`);
231 }
232 return problems;
235module.exports = {
236 IPV4_ALL,
237 IPV6_ALL,
238 MANAGED_PATH,
239 WINDOWS_IPV4_PATTERN,
240 WINDOWS_IPV6_PATTERN,
241 POLICY_DEFAULTS,
242 POLICY_VALUES,
243 PolicyError,
244 USERNAME_PATTERN,
245 evaluateAccess,
246 generateManagedConfig,
247 hostMatches,
248 isDefaultPolicy,
249 listenerFamilies,
250 normalizePolicy,
251 parseEffective,
252 policyComment,
253 parseManagedConfig,
254 verifyPolicyEffect,
255 wildcardMatch
256};
SHA-256f3196545856e2aec95693c2d34dc6233e386ae32a24fc1d74364a34b9e726ba9

CH-J Proprietary Software License 1.14 · 소스는 CH-J Proprietary Software License 1.14에 따라 제공됩니다. 소스를 공개한다고 해서 라이선스 조항이 바뀌거나 추가 권리가 부여되지는 않습니다.

압축 파일 SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
파일