CH-J Server Managergestion de serveur sur SSH
Menu
Source publiée

CH-J Server Manager

Parcourez les répertoires et les fichiers pour une version spécifique de l'application.

Télécharger la source ZIP
CH-J / Server Manager 368 fichiers
core-20261017T201717Z-057348c4a70f Ajouté: Date non enregistrée · Construit:
index.js Ajouté: Date non enregistrée 84.2 KB · 1228 lignes
Code Télécharger le fichier
1"use strict";
3const api = window.chjPlugin;
4const el = new Proxy({}, { get: (_target, id) => document.getElementById(id) });
6// Every asynchronous result is checked against these tokens, so data of a previous session or
7// account is never rendered after the user switched.
8const state = {
9 sessions: [],
10 sessionId: "",
11 generation: 0,
12 detailToken: 0,
13 privilege: null,
14 inventory: null,
15 selected: null,
16 detail: null,
17 detailLoading: false,
18 tab: "account",
19 loading: false,
20 mutating: false
21};
23const PRIVILEGE_TEXT = {
24 root: ["root", "ok"],
25 "sudo-nopasswd": ["sudo bez hesla", "ok"],
26 "sudo-password": ["sudo s heslem", "warn"],
27 none: ["bez sudo – jen čtení", "danger"],
28 unknown: ["neověřeno", "muted"],
29 "windows-elevated": ["správce Windows (zvýšený token)", "ok"],
30 "windows-admin-unelevated": ["člen Administrators bez zvýšeného tokenu – jen čtení", "warn"],
31 "windows-standard": ["standardní účet Windows – jen čtení", "danger"]
32};
33const SUDO_TEXT = {
34 full: "plné sudo",
35 "full-nopasswd": "plné sudo bez hesla",
36 limited: "omezené sudo",
37 none: "bez sudo",
38 unavailable: "sudo není nainstalováno",
39 unknown: "neznámé"
40};
41const PASSWORD_TEXT = {
42 set: ["heslo nastaveno", "ok"],
43 locked: ["heslo zamčeno", "warn"],
44 none: ["bez hesla", "muted"],
45 empty: ["prázdné heslo!", "danger"],
46 unknown: ["stav hesla neznámý", "muted"]
47};
48const PROTECTION_TEXT = {
49 root: "Chráněný systémový účet root: nelze ho smazat ani měnit UID/GID, skupiny, shell či domovský adresář. SSH přístup (včetně PermitRootLogin) a SSH klíče lze spravovat s bezpečnostními kontrolami.",
50 system: "Systémový účet je chráněný. CH-J ho zobrazuje jen pro čtení.",
51 builtin: "Vestavěný účet Windows (Administrator, Guest, DefaultAccount, WDAGUtilityAccount) je chráněný a zobrazuje se jen pro čtení.",
52 session: "Tímto účtem je CH-J právě připojeno. Změny, které by mohly přerušit přístup, jsou zablokované; SSH klíče a SSH přístup lze měnit s ochranou proti odříznutí."
53};
54const ERROR_TEXT = {
55 SUDO_PASSWORD_REQUIRED: "Operace vyžaduje heslo pro sudo.",
56 SUDO_PASSWORD_INCORRECT: "Heslo pro sudo bylo odmítnuto.",
57 SUDO_NOT_PERMITTED: "Účet SSH relace nemá pro tuto operaci oprávnění sudo. Zadání hesla to nezmění.",
58 SUDO_TTY_REQUIRED: "Politika sudo na serveru vyžaduje terminál (requiretty). CH-J ji neobchází.",
59 SUDO_AUTH_FAILED: "Ověření sudo selhalo nebo je účet dočasně zablokován.",
60 SUDO_UNAVAILABLE: "Na serveru není sudo a relace neběží jako root.",
61 SUDO_FAILED: "sudo operaci nespustilo.",
62 SUDO_PASSWORD_INVALID: "Heslo pro sudo má neplatný formát.",
63 SESSION_NOT_CONNECTED: "SSH relace byla odpojena. Připojte se znovu v hlavním okně.",
64 REMOTE_COMMAND_TIMEOUT: "Server neodpověděl včas.",
65 REMOTE_COMMAND_FAILED: "Vzdálený příkaz se nepodařilo spustit.",
66 REMOTE_OUTPUT_TOO_LARGE: "Odpověď serveru je příliš velká.",
67 PLUGIN_PERMISSION_DENIED: "Plugin nemá potřebné oprávnění.",
68 USERS_NAME_INVALID: "Neplatný název. Povolené jsou malé znaky a-z, číslice, _ a - (max. 32, nezačíná číslicí ani pomlčkou).",
69 USERS_GROUP_INVALID: "Neplatný název skupiny.",
70 USERS_SHELL_INVALID: "Neplatný shell (musí existovat a být v /etc/shells, případně nologin/false).",
71 USERS_HOME_INVALID: "Neplatný domovský adresář. Použijte absolutní cestu mimo systémové adresáře.",
72 USERS_HOME_EXISTS: "Cílový domovský adresář už existuje.",
73 USERS_COMMENT_INVALID: "Celé jméno může mít nejvýše 100 znaků bez dvojtečky a řídicích znaků.",
74 USERS_PASSWORD_INVALID: "Heslo musí mít 1–256 znaků a nesmí obsahovat zalomení řádku.",
75 USERS_USER_EXISTS: "Účet s tímto jménem už existuje.",
76 USERS_GROUP_EXISTS: "Skupina se stejným názvem už existuje; zvolte primární skupinu.",
77 USERS_USER_NOT_FOUND: "Účet na serveru neexistuje.",
78 USERS_GROUP_NOT_FOUND: "Skupina na serveru neexistuje.",
79 USERS_GROUP_NOT_MEMBER: "Účet není doplňkovým členem skupiny.",
80 USERS_GROUP_PRIMARY: "Primární skupinu nelze odebrat; nejprve změňte primární skupinu.",
81 USERS_PROTECTED_USER: "Účet je chráněný.",
82 USERS_LAST_ADMIN: "Jde o posledního funkčního administrátora; změna byla odmítnuta.",
83 USERS_USER_HAS_PROCESSES: "Účet má spuštěné procesy. Ukončete je a zkuste to znovu.",
84 USERS_TOOL_MISSING: "Na serveru chybí potřebný systémový nástroj.",
85 USERS_SUDO_GROUP_UNAVAILABLE: "Na serveru není skupina sudo/wheel/admin, na kterou by odkazoval sudoers. CH-J sudoers nemění.",
86 USERS_USERADD_FAILED: "useradd selhal:",
87 USERS_USERMOD_FAILED: "usermod selhal:",
88 USERS_USERDEL_FAILED: "userdel selhal:",
89 USERS_PASSWORD_FAILED: "Nastavení hesla selhalo:",
90 USERS_GROUP_CHANGE_FAILED: "Změna skupiny selhala:",
91 USERS_KEY_INVALID: "Neplatný veřejný klíč. Vložte přesně jeden klíč OpenSSH bez voleb.",
92 USERS_KEY_PRIVATE: "Vložili jste soukromý klíč! Na server se smí nahrát jen veřejný klíč (.pub).",
93 USERS_KEY_TYPE_UNSUPPORTED: "Přidat lze jen klíče Ed25519.",
94 USERS_KEY_DUPLICATE: "Tento klíč už je pro účet povolený.",
95 USERS_KEY_NOT_FOUND: "Klíč se mezitím změnil nebo neexistuje. Obnovte seznam.",
96 USERS_KEY_IN_USE: "Tento klíč používá aktivní relace CH-J, proto nebyl odebrán.",
97 USERS_KEY_LAST: "Poslední klíč účtu aktivní relace nelze odebrat.",
98 USERS_KEYS_FILE_UNMANAGEABLE: "Soubor authorized_keys nelze bezpečně upravit:",
99 USERS_KEYS_FILE_TOO_LARGE: "Soubor authorized_keys by přesáhl 256 KiB.",
100 USERS_KEYS_SYMLINK: "authorized_keys nebo .ssh je symbolický odkaz; soubor nebyl změněn.",
101 USERS_KEYS_CHANGED: "Soubor s klíči se mezitím změnil. Obnovte seznam a zkuste to znovu.",
102 USERS_KEYS_PATH_UNSUPPORTED: "Cesta k authorized_keys mimo domovský adresář není podporovaná.",
103 USERS_SSH_POLICY_INVALID: "Neplatné nastavení SSH.",
104 USERS_SSH_LAYOUT_UNSUPPORTED: "sshd_config nenačítá sshd_config.d před prvním blokem Match. CH-J hlavní konfiguraci neupravuje.",
105 USERS_SSH_MANAGED_FILE_MODIFIED: "Soubor spravovaný CH-J byl ručně upraven; CH-J ho nepřepíše.",
106 USERS_SSH_MANAGED_FILE_UNSAFE: "Soubor spravovaný CH-J není běžný čitelný soubor.",
107 USERS_SSH_CONFIG_ALREADY_INVALID: "Současná konfigurace SSH je neplatná, změna nebyla provedena:",
108 USERS_SSH_CONFIG_INVALID: "Nová konfigurace neprošla sshd -t a nebyla použita:",
109 USERS_SSH_POLICY_NOT_EFFECTIVE: "Nastavení by se neprojevilo kvůli existujícím pravidlům s vyšší prioritou; vráceno zpět.",
110 USERS_SSH_LOCKOUT_PREVENTED: "Změna byla zablokována, aby nepřerušila přístup aktivní relace:",
111 USERS_SSH_PROBE_FAILED: "Nové testovací přihlášení selhalo; konfigurace byla vrácena:",
112 USERS_SSH_PROBE_CREDENTIALS_REQUIRED: "Pro ověření nového přihlášení je potřeba heslo nebo passphrase profilu.",
113 USERS_SSH_RELOAD_FAILED: "Reload SSH služby selhal; konfigurace byla vrácena:",
114 USERS_SSH_POLICY_CHANGED: "Spravovaná SSH konfigurace se mezitím změnila. Obnovte detail a zkuste to znovu.",
115 USERS_SSH_CLIENT_UNKNOWN: "Adresu klienta SSH relace nelze zjistit, ochranu proti odříznutí proto nelze ověřit.",
116 USERS_SSHD_MISSING: "Na serveru nebyl nalezen OpenSSH server (sshd).",
117 USERS_PRIVILEGE_REQUIRED: "Operace vyžaduje oprávnění root.",
118 USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED: "Povolení hesla pro root vyžaduje dodatečné potvrzení.",
119 USERS_NAME_INVALID_WINDOWS: "Neplatný název účtu Windows: písmena, číslice, mezera, tečka, _ nebo - (max. 20), nesmí končit tečkou ani mezerou.",
120 USERS_SID_INVALID: "Neplatný identifikátor účtu (SID).",
121 USERS_WINDOWS_ELEVATION_REQUIRED: "Operace vyžaduje SSH relaci správce se zvýšeným tokenem. Windows nemá sudo a CH-J neobchází UAC:",
122 USERS_ACCESS_DENIED: "Windows operaci zamítl (přístup odepřen bezpečnostní politikou):",
123 USERS_POWERSHELL_UNAVAILABLE: "Na serveru nelze spustit Windows PowerShell (powershell.exe).",
124 USERS_POWERSHELL_RESTRICTED: "PowerShell je omezen politikou (Constrained Language Mode / blokování skriptů); CH-J ji neobchází.",
125 USERS_LOCALACCOUNTS_UNAVAILABLE: "Modul Microsoft.PowerShell.LocalAccounts není v PowerShellu na serveru dostupný.",
126 USERS_DOMAIN_READ_ONLY: "Doménové a cloudové identity jsou jen pro čtení.",
127 USERS_NOT_SUPPORTED_ON_PLATFORM: "Tato operace na Windows neexistuje (shell, HOME, primární skupina, zamknutí hesla).",
128 USERS_PASSWORD_POLICY: "Heslo nesplňuje politiku hesel Windows.",
129 USERS_PROFILE_MISSING: "Profil účtu zatím neexistuje (účet se ještě nepřihlásil), soubor klíčů nelze vytvořit.",
130 USERS_SSH_NAME_UNSUPPORTED: "Název účtu nelze bezpečně použít v sshd_config.",
131 USERS_SSH_APPLY_IN_PROGRESS: "Jiná změna SSH konfigurace ještě probíhá nebo čeká na potvrzení.",
132 USERS_SSH_APPLY_NOT_STARTED: "Úloha s automatickým rollbackem se nespustila; nic nebylo změněno.",
133 USERS_SSH_ROLLED_BACK: "Změna SSH nebyla ponechána, původní konfigurace byla obnovena:",
134 USERS_SSHD_QUERY_FAILED: "Vyhodnocení sshd -T pod účtem SYSTEM se nepodařilo.",
135 USERS_PLATFORM_UNSUPPORTED: "Operační systém serveru nelze rozpoznat nebo není podporován:",
136 USERS_GROUP_MEMBER_EXISTS: "Účet už je členem skupiny.",
137 USERS_PROFILE_IN_USE: "Profil účtu je stále načten (uživatel je přihlášen nebo ho Windows po SSH přihlášení ještě neuvolnil). Zkuste to později, nebo odstraňte účet bez profilu.",
138 USERS_WINDOWS_OPERATION_FAILED: "Operace Windows selhala:"
139};
140const DETAIL_CODES = new Set(["USERS_USERADD_FAILED", "USERS_USERMOD_FAILED", "USERS_USERDEL_FAILED", "USERS_PASSWORD_FAILED", "USERS_GROUP_CHANGE_FAILED",
141 "USERS_KEYS_FILE_UNMANAGEABLE", "USERS_SSH_CONFIG_ALREADY_INVALID", "USERS_SSH_CONFIG_INVALID", "USERS_SSH_POLICY_NOT_EFFECTIVE", "USERS_SSH_LOCKOUT_PREVENTED",
142 "USERS_SSH_PROBE_FAILED", "USERS_SSH_RELOAD_FAILED", "USERS_PROTECTED_USER", "USERS_TOOL_MISSING", "USERS_WINDOWS_ELEVATION_REQUIRED", "USERS_ACCESS_DENIED",
143 "USERS_SSH_ROLLED_BACK", "USERS_PLATFORM_UNSUPPORTED", "USERS_WINDOWS_OPERATION_FAILED", "USERS_PASSWORD_POLICY"]);
144const POLICY_ROWS = [
145 { key: "access", label: "SSH přístup", hint: "Úplné povolení nebo zákaz SSH bez zrušení systémového účtu.", options: [["allow", "Povolit"], ["deny", "Zakázat"]] },
146 { key: "password", label: "Přihlášení heslem", hint: "Dědit = platí globální nastavení serveru.", options: [["inherit", "Dědit"], ["yes", "Povolit"], ["no", "Zakázat"]] },
147 { key: "pubkey", label: "Přihlášení veřejným klíčem", hint: "Zákaz zablokuje i Ed25519 klíče.", options: [["inherit", "Dědit"], ["yes", "Povolit"], ["no", "Zakázat"]] },
148 { key: "ipv4", label: "Přístup z IPv4", hint: "Podle IP rodiny zdrojové adresy klienta.", options: [["allow", "Povolit"], ["deny", "Zakázat"]] },
149 { key: "ipv6", label: "Přístup z IPv6", hint: "Podle IP rodiny zdrojové adresy klienta.", options: [["allow", "Povolit"], ["deny", "Zakázat"]] }
150];
151const ROOT_LOGIN_ROW = { key: "permitRootLogin", label: "PermitRootLogin (root)", hint: "Doporučeno: Jen klíč (prohibit-password). „Povolit“ dovolí i heslo podle ostatních pravidel.",
152 options: [["inherit", "Dědit"], ["no", "Zakázat"], ["prohibit-password", "Jen klíč (doporučeno)"], ["forced-commands-only", "Klíč s vynuceným příkazem"], ["yes", "Povolit"]] };
153const CANCELLED = "CHJ_UI_CANCELLED";
155// ---------- small DOM helpers (text only, never innerHTML with server data) ----------
156function h(tag, props = {}, children = []) {
157 const node = document.createElement(tag);
158 for (const [key, value] of Object.entries(props)) {
159 if (value === undefined || value === null || value === false) continue;
160 if (key === "className") node.className = value;
161 else if (key === "text") node.textContent = String(value);
162 else if (key === "dataset") Object.assign(node.dataset, value);
163 else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2), value);
164 else if (typeof value === "boolean") node[key] = value;
165 else node.setAttribute(key, String(value));
166 }
167 for (const child of [].concat(children)) {
168 if (child === null || child === undefined || child === false) continue;
169 node.append(child instanceof Node ? child : String(child));
170 }
171 return node;
173function badge(text, tone = "muted") { return h("span", { className: `badge ${tone}`, text }); }
174function setStatus(message, error = false) { el.status.textContent = message; el.status.classList.toggle("error", error); }
175function connected() { return Boolean(state.sessionId); }
176function isWindows() { return state.inventory?.platform === "windows" || state.privilege?.platform === "windows"; }
177// Windows accounts are addressed by SID; Linux accounts by name.
178function identFor(username) {
179 const user = state.inventory?.users.find((candidate) => candidate.username === username) || (state.detail?.username === username ? state.detail : null);
180 return user?.sid && isWindows() ? { sid: user.sid, username } : { username };
183function parseError(error) {
184 const raw = String(error?.message || error || "");
185 const match = raw.match(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+): ([\s\S]*)$/);
186 if (match) return { code: match[1], detail: match[2].trim() };
187 return { code: error?.code || "UNKNOWN", detail: raw.replace(/^Error invoking remote method '[^']+': (?:\w*Error: )?/, "") };
189function describeError(error) {
190 const { code, detail } = parseError(error);
191 if (code === CANCELLED) return "Operace byla zrušena.";
192 const base = ERROR_TEXT[code];
193 if (!base) return detail || code;
194 return DETAIL_CODES.has(code) && detail ? `${base} ${detail}` : base;
196function cancelled() { const error = new Error(`${CANCELLED}: cancelled`); error.code = CANCELLED; return error; }
198// ---------- dialogs ----------
199function runDialog(dialog, { init, submit }) {
200 return new Promise((resolve) => {
201 const form = dialog.querySelector("form");
202 let finished = false;
203 const finish = (value) => {
204 if (finished) return;
205 finished = true;
206 form.removeEventListener("submit", onSubmit);
207 dialog.removeEventListener("close", onClose);
208 for (const button of dialog.querySelectorAll("[data-close]")) button.removeEventListener("click", onClose);
209 if (dialog.open) dialog.close();
210 resolve(value);
211 };
212 const onSubmit = async (event) => {
213 event.preventDefault();
214 const value = await submit();
215 if (value !== undefined) finish(value);
216 };
217 const onClose = () => finish(null);
218 form.addEventListener("submit", onSubmit);
219 dialog.addEventListener("close", onClose);
220 for (const button of dialog.querySelectorAll("[data-close]")) button.addEventListener("click", onClose);
221 init?.();
222 dialog.showModal();
223 });
225function showError(node, message) { node.textContent = message || ""; node.hidden = !message; }
227function askSudo(label, error) {
228 return runDialog(el.sudoDialog, {
229 init: () => {
230 el.sudoMessage.textContent = `Operace „${label}“ vyžaduje oprávnění správce.`;
231 el.sudoUser.textContent = state.privilege?.username || "";
232 el.sudoPassword.value = "";
233 showError(el.sudoError, error);
234 setTimeout(() => el.sudoPassword.focus(), 0);
235 },
236 submit: () => { const value = el.sudoPassword.value; el.sudoPassword.value = ""; return value || undefined; }
237 }).finally(() => { el.sudoPassword.value = ""; });
240function confirmAction({ title, message, details = [], confirmLabel = "Potvrdit", danger = false, typed = "", option = "" }) {
241 return runDialog(el.confirmDialog, {
242 init: () => {
243 el.confirmTitle.textContent = title;
244 el.confirmMessage.textContent = message;
245 el.confirmDetails.replaceChildren(...details.map((item) => h("li", { text: item })));
246 el.confirmDetails.hidden = !details.length;
247 el.confirmTypedLabel.hidden = !typed;
248 el.confirmTypedValue.textContent = typed;
249 el.confirmTyped.value = "";
250 el.confirmOptionLabel.hidden = !option;
251 el.confirmOptionText.textContent = option;
252 el.confirmOption.checked = false;
253 el.confirmSubmit.textContent = confirmLabel;
254 el.confirmSubmit.className = `button ${danger ? "danger" : "primary"}`;
255 },
256 submit: () => {
257 if (typed && el.confirmTyped.value.trim() !== typed) { el.confirmTyped.focus(); return undefined; }
258 return { option: el.confirmOption.checked };
259 }
260 });
263function askValue({ title, message = "", label, value = "", type = "text", label2 = "", option = "", validate }) {
264 return runDialog(el.valueDialog, {
265 init: () => {
266 el.valueTitle.textContent = title;
267 el.valueMessage.textContent = message;
268 el.valueLabelText.textContent = label;
269 el.valueInput.type = type; el.valueInput.value = value;
270 el.valueLabel2.hidden = !label2;
271 el.valueLabelText2.textContent = label2;
272 el.valueInput2.type = type; el.valueInput2.value = "";
273 el.valueOptionLabel.hidden = !option;
274 el.valueOptionText.textContent = option;
275 el.valueOption.checked = false;
276 showError(el.valueError, "");
277 setTimeout(() => el.valueInput.focus(), 0);
278 },
279 submit: () => {
280 const result = { value: el.valueInput.value, value2: el.valueInput2.value, option: el.valueOption.checked };
281 const problem = validate?.(result);
282 if (problem) { showError(el.valueError, problem); return undefined; }
283 return result;
284 }
285 }).finally(() => { el.valueInput.value = ""; el.valueInput2.value = ""; });
288function askProbeSecret(missing) {
289 const passphrase = missing === "passphrase";
290 return runDialog(el.probeDialog, {
291 init: () => {
292 el.probeLabel.textContent = passphrase ? "Passphrase soukromého klíče profilu" : "Heslo SSH profilu";
293 el.probeSecret.value = "";
294 setTimeout(() => el.probeSecret.focus(), 0);
295 },
296 submit: () => { const value = el.probeSecret.value; el.probeSecret.value = ""; return value ? (passphrase ? { passphrase: value } : { password: value }) : undefined; }
297 });
300// Calls Core without a password first. Only if Core reports that sudo needs one, the dialog is shown.
301async function withSudo(label, invoke) {
302 let password = "";
303 let attempts = 0;
304 try {
305 for (;;) {
306 try { return await invoke(password ? { sudoPassword: password } : {}); }
307 catch (error) {
308 const { code } = parseError(error);
309 if ((code === "SUDO_PASSWORD_REQUIRED" || code === "SUDO_PASSWORD_INCORRECT") && attempts < 3) {
310 attempts += 1;
311 password = await askSudo(label, code === "SUDO_PASSWORD_INCORRECT" && password ? "Heslo bylo odmítnuto. Zkuste to znovu." : "");
312 if (!password) throw cancelled();
313 continue;
314 }
315 if (code === "SUDO_NOT_PERMITTED" || code === "SUDO_UNAVAILABLE") void refreshPrivilege();
316 if (code === "SESSION_NOT_CONNECTED") void loadSessions();
317 throw error;
318 }
319 }
320 } finally { password = ""; }
323// ---------- sessions and privileges ----------
324async function loadSessions() {
325 const previous = state.sessionId;
326 try {
327 state.sessions = (await api.sessions.list()).filter((session) => session.state === "connected");
328 } catch (error) {
329 state.sessions = [];
330 setStatus(`Relace nelze načíst: ${describeError(error)}`, true);
331 }
332 el.sessionSelect.replaceChildren(new Option(state.sessions.length ? "Vyberte relaci…" : "Žádná připojená relace", ""));
333 for (const session of state.sessions) el.sessionSelect.add(new Option(`${session.label} — ${session.username}@${session.host}`, session.sessionId));
334 const next = state.sessions.some((session) => session.sessionId === previous) ? previous : (state.sessions[0]?.sessionId || "");
335 el.sessionSelect.value = next;
336 if (next !== previous) await selectSession(next);
337 else updateControls();
338 if (!state.sessions.length) setStatus("Nejprve se v hlavním okně připojte k serveru přes SSH.");
341async function selectSession(sessionId) {
342 state.sessionId = sessionId;
343 state.generation += 1;
344 state.detailToken += 1;
345 Object.assign(state, { privilege: null, inventory: null, selected: null, detail: null, detailLoading: false });
346 renderPrivilege(); renderUsers(); renderDetail(); updateControls();
347 if (!sessionId) return;
348 await refreshPrivilege();
349 await loadInventory();
352async function refreshPrivilege() {
353 const token = state.generation;
354 if (!connected()) return;
355 try {
356 const privilege = await api.users.privileges(state.sessionId, true);
357 if (token !== state.generation) return;
358 state.privilege = privilege;
359 } catch (error) {
360 if (token === state.generation) setStatus(`Oprávnění nelze zjistit: ${describeError(error)}`, true);
361 }
362 renderPrivilege(); updateControls();
365function renderPrivilege() {
366 const mode = state.privilege?.mode || "unknown";
367 const [text, tone] = PRIVILEGE_TEXT[mode] || PRIVILEGE_TEXT.unknown;
368 const suffix = mode === "sudo-password" && !state.privilege?.verified ? " (neověřeno)" : "";
369 el.privilegeBadge.textContent = state.privilege ? `${state.privilege.username}: ${text}${suffix}` : "neověřeno";
370 el.privilegeBadge.className = `badge ${tone}`;
371 if (mode.startsWith("windows-")) {
372 el.privilegeBadge.title = mode === "windows-elevated" ? "Relace má zvýšený token; administrativní operace jsou dostupné." : "Windows nemá sudo. Administrativní operace vyžadují zvýšený token správce; CH-J neobchází UAC.";
373 return;
374 }
375 el.privilegeBadge.title = mode === "none" ? "Účet nemá sudo. Plugin funguje v režimu čtení." : mode === "sudo-password" ? "Administrativní operace si vyžádají heslo pro sudo." : "";
378function updateControls() {
379 const busy = state.loading || state.mutating;
380 el.sessionSelect.disabled = state.mutating;
381 el.refreshSessionsButton.disabled = state.mutating;
382 el.reloadButton.disabled = !connected() || busy;
383 const readOnlyWindows = isWindows() && state.privilege?.mode !== "windows-elevated";
384 el.createButton.disabled = !connected() || busy || !state.inventory || state.privilege?.mode === "none" || readOnlyWindows;
385 const canElevate = !isWindows() && connected() && state.inventory && !state.inventory.privileged && ["sudo-password", "unknown"].includes(state.privilege?.mode || "unknown");
386 el.elevatedButton.hidden = !canElevate;
387 el.elevatedButton.disabled = busy;
388 for (const button of document.querySelectorAll("[data-mutation]")) button.disabled = busy || !connected();
391// ---------- inventory ----------
392async function loadInventory({ elevated = false } = {}) {
393 if (!connected()) return;
394 const token = state.generation;
395 const sessionId = state.sessionId;
396 state.loading = true; updateControls();
397 setStatus(elevated ? "Načítám rozšířený stav účtů…" : "Načítám účty…");
398 try {
399 const run = (auth) => api.users.inventory(sessionId, { ...auth, privileged: elevated });
400 const inventory = elevated ? await withSudo("Načtení rozšířeného stavu účtů", run) : await run({});
401 if (token !== state.generation) return;
402 applyInventory(inventory);
403 const regular = inventory.users.filter((user) => !user.system).length;
404 setStatus(`Načteno ${inventory.users.length} účtů (${regular} běžných)${inventory.privileged ? "" : " – stav hesel a sudo vyžaduje sudo"}.`);
405 } catch (error) {
406 if (token === state.generation) setStatus(describeError(error), parseError(error).code !== CANCELLED);
407 } finally {
408 if (token === state.generation) { state.loading = false; updateControls(); }
409 }
412function applyInventory(inventory) {
413 state.inventory = inventory;
414 if (inventory.privilege) state.privilege = inventory.privilege;
415 if (state.selected && !inventory.users.some((user) => user.username === state.selected)) {
416 state.selected = null; state.detail = null; state.detailToken += 1;
417 } else if (state.detail) {
418 const user = inventory.users.find((candidate) => candidate.username === state.detail.username);
419 if (user) mergeAccount(state.detail, user, inventory.privileged);
420 }
421 renderPrivilege(); renderUsers(); renderDetail(); updateControls();
424function mergeAccount(detail, user, privileged) {
425 const account = detail.account;
426 Object.assign(account, { shell: user.shell, home: user.home, comment: user.comment, primaryGroup: user.primaryGroup, groups: user.groups, loginShell: user.loginShell, uid: user.uid, gid: user.gid });
427 if (privileged) Object.assign(account, { password: user.password, expired: user.expired, expireDays: user.expireDays, sudo: user.sudo });
430function filteredUsers() {
431 const users = state.inventory?.users || [];
432 const filter = el.filterSelect.value;
433 const query = el.searchInput.value.trim().toLowerCase();
434 return users.filter((user) => (filter === "all" || (filter === "system" ? user.system : !user.system))
435 && (!query || user.username.includes(query) || user.comment.toLowerCase().includes(query) || String(user.uid) === query));
438function policySummary(policy) {
439 if (!policy) return badge("dědí server", "muted");
440 if (policy.access === "deny") return badge("SSH zakázáno", "danger");
441 const parts = [];
442 if (policy.password !== "inherit") parts.push(`heslo ${policy.password === "no" ? "ne" : "ano"}`);
443 if (policy.pubkey !== "inherit") parts.push(`klíč ${policy.pubkey === "no" ? "ne" : "ano"}`);
444 if (policy.ipv4 === "deny") parts.push("bez IPv4");
445 if (policy.ipv6 === "deny") parts.push("bez IPv6");
446 return badge(parts.join(" · ") || "dědí server", parts.length ? "warn" : "muted");
449function accountState(user) {
450 if (user.platform === "windows") {
451 const items = [badge(user.enabled ? "povolen" : "zakázán", user.enabled ? "ok" : "danger")];
452 if (user.source && user.source !== "Local") items.push(badge(user.source === "MicrosoftAccount" ? "účet Microsoft" : user.source, "muted"));
453 if (user.passwordRequired === false && !user.passwordLastSet) items.push(badge("bez hesla", "muted"));
454 return items;
455 }
456 const items = [];
457 if (user.expired) items.push(badge("deaktivován", "danger"));
458 const [text, tone] = PASSWORD_TEXT[user.password] || PASSWORD_TEXT.unknown;
459 items.push(badge(text, tone));
460 if (!user.loginShell) items.push(badge("bez shellu", "muted"));
461 return items;
464function renderTableHead() {
465 const labels = isWindows() ? ["Účet", "SID (RID)", "Stav", "Administrators", "SSH (CH-J)"] : ["Účet", "UID", "Stav", "Sudo", "SSH (CH-J)"];
466 el.usersBody.closest("table").querySelector("thead").replaceChildren(h("tr", {}, labels.map((text) => h("th", { scope: "col", text }))));
469function renderUsers() {
470 renderTableHead();
471 const users = filteredUsers();
472 if (!state.inventory) {
473 el.usersBody.replaceChildren(h("tr", {}, h("td", { colspan: 5, className: "empty", text: connected() ? "Načítám…" : "Nejsou načtena žádná data." })));
474 el.inventoryNote.textContent = "";
475 return;
476 }
477 if (!users.length) {
478 el.usersBody.replaceChildren(h("tr", {}, h("td", { colspan: 5, className: "empty", text: "Žádný účet neodpovídá filtru." })));
479 } else {
480 el.usersBody.replaceChildren(...users.map((user) => h("tr", { className: user.username === state.selected ? "selected" : "" }, [
481 h("td", {}, h("button", { type: "button", className: "row-button", dataset: { username: user.username }, "aria-pressed": String(user.username === state.selected) }, [
482 h("strong", { text: user.username }),
483 user.protection ? h("small", { text: user.protection === "session" ? " (tato relace)" : user.protection === "root" ? " (root)" : user.protection === "builtin" ? " (vestavěný)" : "" }) : null,
484 user.comment ? h("span", { className: "sub", text: user.comment }) : null
485 ])),
486 h("td", { title: user.sid || "", text: user.platform === "windows" ? `…-${user.rid}` : String(user.uid) }),
487 h("td", {}, h("div", { className: "badges" }, accountState(user))),
488 user.platform === "windows" ? h("td", { text: user.admin ? "člen" : "—" }) : h("td", { text: user.sudo === "unknown" ? (user.adminGroups.length ? `skupina ${user.adminGroups.join(", ")}` : "—") : SUDO_TEXT[user.sudo] || user.sudo }),
489 h("td", {}, policySummary(user.sshPolicy))
490 ])));
491 }
492 const managed = state.inventory.managedPolicy;
493 const notes = [];
494 if (isWindows()) {
495 if (!state.inventory.privileged) notes.push("Režim čtení: správa účtů, SSH a cizích klíčů vyžaduje SSH relaci správce se zvýšeným tokenem (Windows nemá sudo).");
496 if (state.inventory.server.serverCore) notes.push("Windows Server Core (bez GUI).");
497 if (state.inventory.server.partOfDomain) notes.push(`Počítač je v doméně ${state.inventory.server.domain}; doménové identity jsou jen pro čtení (${state.inventory.externalIdentities.length} v lokálních skupinách).`);
498 } else if (!state.inventory.privileged) notes.push("Režim čtení: stav hesel, expirace a skutečná sudo práva vyžadují sudo.");
499 if (state.inventory.server.sshLayout !== "ok" && isWindows()) notes.push("SSH politiku CH-J nelze v tomto sshd_config bezpečně spravovat (viz Diagnostika).");
500 else if (state.inventory.server.sshLayout !== "ok") notes.push("SSH politiku CH-J nelze na tomto serveru spravovat (sshd_config nenačítá sshd_config.d před Match).");
501 if (managed.state === "present" && !managed.consistent) notes.push("Soubor SSH politiky CH-J byl ručně upraven – CH-J ho nepřepíše.");
502 if (state.inventory.server.os) notes.push(`Server: ${state.inventory.server.os}.`);
503 el.inventoryNote.textContent = notes.join(" ");
506// ---------- detail ----------
507async function loadDetail(username, { elevated = false } = {}) {
508 if (!connected()) return;
509 const token = state.generation;
510 const detailToken = ++state.detailToken;
511 const sessionId = state.sessionId;
512 if (state.selected !== username) { state.detail = null; state.tab = state.tab || "account"; }
513 state.selected = username;
514 state.detailLoading = true;
515 renderUsers(); renderDetail();
516 try {
517 const run = (auth) => api.users.detail(sessionId, username, { ...auth, ...identFor(username), privileged: elevated });
518 const detail = elevated ? await withSudo(`Detail účtu ${username}`, run) : await run({});
519 if (token !== state.generation || detailToken !== state.detailToken) return;
520 state.detail = detail;
521 setStatus(`Detail účtu ${username} načten${detail.privileged ? " včetně údajů vyžadujících sudo" : " (režim čtení)"}.`);
522 } catch (error) {
523 if (token !== state.generation || detailToken !== state.detailToken) return;
524 setStatus(describeError(error), parseError(error).code !== CANCELLED);
525 } finally {
526 if (token === state.generation && detailToken === state.detailToken) { state.detailLoading = false; renderDetail(); updateControls(); }
527 }
530function renderDetail() {
531 const detail = state.detail;
532 el.detailEmpty.hidden = Boolean(state.selected);
533 el.detailContent.hidden = !state.selected;
534 el.detailEmpty.textContent = connected() ? "Vyberte účet v seznamu." : "Nejprve vyberte připojenou relaci.";
535 if (!state.selected) return;
536 el.detailTitle.textContent = state.selected;
537 if (!detail) {
538 el.detailSubtitle.textContent = state.detailLoading ? "Načítám detail…" : "Detail se nepodařilo načíst.";
539 el.detailBadges.replaceChildren();
540 el.detailNotice.hidden = true;
541 for (const panel of ["account", "groups", "ssh", "keys", "diagnostics"]) el[`panel-${panel}`].replaceChildren();
542 return;
543 }
544 const account = detail.account;
545 el.detailSubtitle.textContent = (account.platform === "windows" ? [account.fullName, account.sid, account.profilePath] : [account.comment, `UID ${account.uid}`, account.home]).filter(Boolean).join(" · ") + (state.detailLoading ? " · obnovuji…" : "");
546 el["tab-groups"].textContent = account.platform === "windows" ? "Skupiny a správci" : "Skupiny a sudo";
547 el.detailBadges.replaceChildren(...[
548 account.protection ? badge(account.protection === "session" ? "aktivní relace" : account.protection === "root" ? "root" : account.protection === "builtin" ? "vestavěný" : "systémový", "warn") : null,
549 badge(detail.privileged ? (detail.platform === "windows" ? "ověřeno se zvýšeným tokenem" : "ověřeno se sudo") : "režim čtení", detail.privileged ? "ok" : "muted"),
550 ...accountState(account)
551 ].filter(Boolean));
552 const notices = [];
553 if (account.protection) notices.push(PROTECTION_TEXT[account.protection]);
554 if (!detail.privileged) notices.push(detail.platform === "windows"
555 ? "Skutečná SSH konfigurace (sshd -T), klíče jiných účtů a změny vyžadují SSH relaci správce se zvýšeným tokenem. Windows nemá sudo a CH-J neobchází UAC."
556 : "Část údajů (stav hesla, sudo, skutečná SSH konfigurace, klíče jiných účtů) vyžaduje sudo.");
557 el.detailNotice.replaceChildren(...notices.map((text) => h("p", { text })));
558 if (!detail.privileged && detail.platform !== "windows") el.detailNotice.append(h("button", { type: "button", className: "button", text: "Načíst údaje se sudo", onclick: () => loadDetail(detail.username, { elevated: true }) }));
559 el.detailNotice.hidden = !notices.length;
560 renderAccountTab(detail); renderGroupsTab(detail); renderSshTab(detail); renderKeysTab(detail); renderDiagnosticsTab(detail);
561 selectTab(state.tab, false);
562 updateControls();
565function infoGrid(rows) {
566 return h("dl", { className: "info-grid" }, rows.flatMap(([label, value]) => [h("dt", { text: label }), h("dd", {}, value instanceof Node ? value : String(value ?? "—"))]));
568function actionButton(label, onClick, { danger = false, sudo = true, title = "" } = {}) {
569 return h("button", { type: "button", className: `button${danger ? " danger" : ""}`, dataset: { mutation: "1" }, title: title || (sudo ? "Vyžaduje oprávnění správce (sudo)" : ""), onclick: onClick }, [label, sudo ? h("span", { className: "sudo-mark", text: " sudo" }) : null]);
571function section(title, children, hint = "") {
572 return h("section", { className: "sub-panel" }, [h("h4", { text: title }), hint ? h("p", { className: "hint", text: hint }) : null, ...[].concat(children)]);
575function renderAccountTab(detail) {
576 if (detail.platform === "windows") { renderWindowsAccountTab(detail); return; }
577 const account = detail.account;
578 const [passwordText] = PASSWORD_TEXT[account.password] || PASSWORD_TEXT.unknown;
579 const expiry = account.expired ? "deaktivován (expirovaný účet)" : account.expireDays ? `platný do ${new Date(account.expireDays * 86400000).toISOString().slice(0, 10)}` : account.password === "unknown" ? "neznámý (vyžaduje sudo)" : "aktivní";
580 const children = [infoGrid([
581 ["Uživatelské jméno", account.username],
582 ["Celé jméno", account.comment || "—"],
583 ["UID / GID", `${account.uid} / ${account.gid}`],
584 ["Primární skupina", account.primaryGroup],
585 ["Domovský adresář", `${account.home}${account.homeMeta && account.homeMeta !== "absent" ? ` (${account.homeMeta})` : account.homeMeta === "absent" ? " (neexistuje)" : ""}`],
586 ["Shell", `${account.shell}${account.loginShell ? "" : " – interaktivní přihlášení vypnuto"}`],
587 ["Heslo", passwordText],
588 ["Stav účtu", expiry],
589 ["Typ", account.uid === 0 ? "root" : account.system ? "systémový účet" : "běžný účet"],
590 ["Spuštěné procesy", account.processes === null ? "—" : String(account.processes)]
591 ]), h("p", { className: "hint", text: "Zamknutí hesla (passwd -l) nebrání přihlášení SSH klíčem. Úplné zablokování: Deaktivovat účet, nebo SSH přístup → Zakázat." })];
592 if (!account.protection) {
593 const actions = [actionButton("Změnit heslo…", () => changePassword(account.username))];
594 if (account.password === "locked") actions.push(actionButton("Odemknout heslo", () => simpleAction("unlock", account.username, "Odemknutí hesla")));
595 else if (account.password === "set") actions.push(actionButton("Zamknout heslo", () => simpleAction("lock", account.username, "Zamknutí hesla")));
596 actions.push(account.expired
597 ? actionButton("Aktivovat účet", () => simpleAction("unexpire", account.username, "Aktivace účtu"))
598 : actionButton("Deaktivovat účet", () => simpleAction("expire", account.username, "Deaktivace účtu", "Účet se nebude moci přihlásit žádnou metodou (ani SSH klíčem). Data zůstanou zachována.")));
599 actions.push(actionButton("Upravit celé jméno…", () => changeComment(account)));
600 actions.push(actionButton("Změnit domovský adresář…", () => changeHome(account)));
601 actions.push(actionButton("Odstranit účet…", () => deleteAccount(account), { danger: true }));
602 children.push(h("div", { className: "inline-actions" }, actions));
603 const shells = [...new Set([...(state.inventory?.shells || []), "/usr/sbin/nologin", "/bin/false", account.shell])].filter(Boolean);
604 const shellSelect = h("select", { "aria-label": "Shell" }, shells.map((shell) => h("option", { value: shell, text: shell, selected: shell === account.shell })));
605 children.push(section("Shell", h("div", { className: "inline-actions" }, [shellSelect, actionButton("Uložit shell", () => {
606 if (shellSelect.value === account.shell) return;
607 void runMutation("Změna shellu", (sessionId, auth) => api.users.manage(sessionId, { action: "setShell", ...identFor(account.username), shell: shellSelect.value, ...auth }));
608 })]), "nologin/false zablokuje interaktivní přihlášení, ale ne nutně všechny SSH funkce (např. přesměrování portů)."));
609 }
610 el["panel-account"].replaceChildren(...children);
613function renderGroupsTab(detail) {
614 if (detail.platform === "windows") { renderWindowsGroupsTab(detail); return; }
615 const account = detail.account;
616 const groups = state.inventory?.groups || [];
617 const editable = !account.protection;
618 const children = [];
619 const memberRows = account.groups.length
620 ? account.groups.map((group) => h("li", {}, [h("span", { text: group }), editable && group !== account.primaryGroup ? h("button", { type: "button", className: "mini", dataset: { mutation: "1" }, text: "Odebrat", onclick: () => removeGroup(account.username, group) }) : null]))
621 : [h("li", { className: "muted", text: "Žádné doplňkové skupiny." })];
622 children.push(section("Skupiny", [
623 infoGrid([["Primární skupina", account.primaryGroup]]),
624 h("ul", { className: "chip-list" }, memberRows)
625 ]));
626 if (editable && groups.length) {
627 const candidates = groups.filter((group) => !account.groups.includes(group.name) && group.name !== account.primaryGroup).sort((left, right) => left.name.localeCompare(right.name));
628 const addSelect = h("select", { "aria-label": "Přidat do skupiny" }, candidates.map((group) => h("option", { value: group.name, text: `${group.name} (${group.gid})` })));
629 const primarySelect = h("select", { "aria-label": "Primární skupina" }, groups.slice().sort((left, right) => left.name.localeCompare(right.name)).map((group) => h("option", { value: group.name, text: `${group.name} (${group.gid})`, selected: group.name === account.primaryGroup })));
630 children.push(section("Změnit členství", [
631 h("div", { className: "inline-actions" }, [addSelect, actionButton("Přidat do skupiny", () => addGroup(account.username, addSelect.value))]),
632 h("div", { className: "inline-actions" }, [primarySelect, actionButton("Nastavit primární skupinu", () => setPrimary(account.username, primarySelect.value, account.primaryGroup))])
633 ], "Ostatní skupiny a nesouvisející oprávnění zůstávají zachované."));
634 }
635 const sudoState = account.sudo || "unknown";
636 const adminGroups = state.inventory?.adminGroups || [];
637 const sudoChildren = [infoGrid([
638 ["Skutečný stav (sudo -l)", sudoState === "unknown" ? "neznámý – vyžaduje sudo" : SUDO_TEXT[sudoState] || sudoState],
639 ["Členství ve skupinách správců", ["sudo", "wheel", "admin"].filter((name) => account.groups.includes(name) || account.primaryGroup === name).join(", ") || "žádné"],
640 ["Skupiny s pravidlem v sudoers", adminGroups.length ? adminGroups.join(", ") : state.inventory?.privileged ? "žádná" : "neznámé (vyžaduje sudo)"]
641 ]), h("p", { className: "hint", text: "Členství ve skupině samo nezaručuje funkční sudo. CH-J nikdy nemění sudoers a nenastavuje NOPASSWD." })];
642 if (editable) {
643 sudoChildren.push(h("div", { className: "inline-actions" }, [
644 actionButton("Přidat sudo", () => simpleAction("grantAdmin", account.username, "Přidání sudo", "Účet bude přidán do skupiny správců, na kterou odkazuje sudoers.")),
645 actionButton("Odebrat sudo", () => simpleAction("revokeAdmin", account.username, "Odebrání sudo", "Účet bude odebrán ze skupin sudo/wheel/admin. Pravidla sudoers pro konkrétní účet zůstanou beze změny."), { danger: true })
646 ]));
647 }
648 children.push(section("Sudo", sudoChildren));
649 el["panel-groups"].replaceChildren(...children);
652function effectiveCell(detail, family, key) {
653 const effective = detail.ssh.effective[family];
654 if (!effective) return h("span", { className: "muted", text: detail.privileged ? "nedostupné" : "vyžaduje sudo" });
655 const yesNo = (value) => value === "yes" ? badge("povoleno", "ok") : value === "no" ? badge("zakázáno", "danger") : badge(value, "muted");
656 if (key === "password") return yesNo(effective.methods.password ? "yes" : "no");
657 if (key === "pubkey") return yesNo(effective.methods.publickey ? "yes" : "no");
658 const listener = detail.ssh.effective.listener;
659 if (listener && listener[family] === false) return badge("sshd nenaslouchá", "danger");
660 if (effective.allowed === true) return badge("povoleno", "ok");
661 if (effective.allowed === null) return badge("nejisté", "warn");
662 const managedOnly = effective.reasons.every((reason) => reason.managed);
663 return h("span", { title: effective.reasons.map((reason) => `${reason.rule} ${reason.entry}`).join(", ") }, badge(managedOnly ? "zakázáno (CH-J)" : "zakázáno (pravidla serveru)", "danger"));
666function renderSshTab(detail) {
667 const ssh = detail.ssh;
668 const account = detail.account;
669 const children = [];
670 if (!ssh.available && detail.privileged) {
671 el["panel-ssh"].replaceChildren(h("p", { className: "notice", text: "Na serveru nebyl nalezen OpenSSH server (sshd)." }));
672 return;
673 }
674 const windows = detail.platform === "windows";
675 const blocked = windows ? account.protection === "builtin" : account.system;
676 const rows = !windows && account.uid === 0 ? [...POLICY_ROWS, ROOT_LOGIN_ROW] : POLICY_ROWS;
677 const reason = blocked ? (windows ? "SSH přístup vestavěných účtů Windows CH-J nespravuje." : "SSH přístup systémových účtů CH-J nespravuje.")
678 : !detail.privileged ? (windows ? "Skutečný stav a změny SSH vyžadují SSH relaci správce se zvýšeným tokenem." : "Skutečný stav a změny SSH vyžadují sudo.")
679 : windows && !ssh.sshName ? "Název účtu obsahuje znaky, které nelze bezpečně použít v sshd_config."
680 : !ssh.layoutSupported ? (windows ? `sshd_config nelze bezpečně upravit: ${(ssh.layoutProblems || []).join("; ")}` : "sshd_config nenačítá sshd_config.d před prvním blokem Match – CH-J hlavní konfiguraci neupravuje.")
681 : ssh.managed.state === "present" && !ssh.managed.consistent ? `${ssh.managed.path} byl ručně upraven – CH-J ho nepřepíše.`
682 : ssh.syntaxOk === false ? `Současná konfigurace SSH je neplatná: ${ssh.syntaxMessage}` : "";
683 const form = h("form", { className: "policy-form" });
684 const table = h("table", { className: "policy-table" }, [
685 h("thead", {}, h("tr", {}, [h("th", { text: "Nastavení" }), h("th", { text: "Požadováno (CH-J)" }), h("th", { text: `Skutečně IPv4${ssh.effective.ipv4?.sample ? " (vzorová adresa)" : ""}` }), h("th", { text: `Skutečně IPv6${ssh.effective.ipv6?.sample ? " (vzorová adresa)" : ""}` })])),
686 h("tbody", {}, rows.map((row) => {
687 const current = ssh.policy[row.key];
688 const options = h("div", { className: "segmented", role: "radiogroup", "aria-label": row.label }, row.options.map(([value, label]) => h("label", {}, [
689 h("input", { type: "radio", name: `policy-${row.key}`, value, checked: current === value, disabled: Boolean(reason) }), h("span", { text: label })
690 ])));
691 const family = row.key === "ipv4" ? ["ipv4"] : row.key === "ipv6" ? ["ipv6"] : ["ipv4", "ipv6"];
692 const effectiveKey = row.key === "ipv4" || row.key === "ipv6" ? "access" : row.key;
693 if (row.key === "permitRootLogin") {
694 const shown = (family) => {
695 const value = detail.ssh.effective[family]?.permitRootLogin;
696 const text = value === "without-password" ? "prohibit-password" : value;
697 return text ? badge({ no: "zakázáno", "prohibit-password": "jen klíč", "forced-commands-only": "klíč s vynuceným příkazem", yes: "heslo i klíč" }[text] || text, text === "no" ? "danger" : text === "yes" ? "warn" : "ok") : h("span", { className: "muted", text: detail.privileged ? "nedostupné" : "vyžaduje sudo" });
698 };
699 return h("tr", {}, [h("th", { scope: "row" }, [h("span", { text: row.label }), h("small", { className: "sub", text: row.hint })]), h("td", {}, options), h("td", {}, shown("ipv4")), h("td", {}, shown("ipv6"))]);
700 }
701 return h("tr", {}, [
702 h("th", { scope: "row" }, [h("span", { text: row.label }), h("small", { className: "sub", text: row.hint })]),
703 h("td", {}, options),
704 h("td", {}, family.includes("ipv4") ? effectiveCell(detail, "ipv4", effectiveKey) : h("span", { className: "muted", text: "—" })),
705 h("td", {}, family.includes("ipv6") ? effectiveCell(detail, "ipv6", effectiveKey) : h("span", { className: "muted", text: "—" }))
706 ]);
707 }))
708 ]);
709 form.append(table);
710 if (reason) form.append(h("p", { className: "notice", text: reason }));
711 else {
712 if (windows) form.append(h("p", { className: "notice warn", text: "Windows OpenSSH vyžaduje po změně restart služby sshd. Před restartem se na serveru aktivuje nezávislá úloha (SYSTEM), která do 120 s vrátí původní sshd_config, pokud změnu nepotvrdí nové SSH přihlášení. Původní relace se může přerušit." }));
713 if (detail.self) form.append(h("p", { className: "notice warn", text: "Měníte účet aktivní relace. Core ověří přes sshd -T, že vaše současné přihlášení zůstane povolené, a po reloadu zkusí nové SSH přihlášení. Při selhání se konfigurace automaticky vrátí." }));
714 form.append(h("div", { className: "inline-actions" }, [h("button", { type: "submit", className: "button primary", dataset: { mutation: "1" }, text: windows ? "Uložit, restartovat sshd a ověřit" : "Uložit a ověřit (sudo)" })]));
715 form.addEventListener("submit", (event) => { event.preventDefault(); void saveSshPolicy(detail, form); });
716 }
717 children.push(form);
718 if (windows) children.push(h("p", { className: "hint", text: "Na Windows zapisuje CH-J dvě označené sekce přímo do C:\\ProgramData\\ssh\\sshd_config: DenyUsers před první existující blok Match a vlastní Match User na konec. KbdInteractiveAuthentication se nepoužívá (Windows OpenSSH ji nepodporuje). Před restartem proběhne sshd -t a sshd -T -C nad kandidátní konfigurací." }));
719 else children.push(h("p", { className: "hint", text: "Pravidla zapisuje CH-J do vlastního souboru sshd_config.d (DenyUsers user@0.0.0.0/0, user@::/0 a Match User). Před reloadem proběhne sshd -t a kontrola účinného nastavení sshd -T -C; neplatná nebo neúčinná změna se vrátí. Používá se reload, ne restart." }));
720 for (const warning of ssh.warnings) children.push(h("p", { className: "notice warn", text: translateWarning(warning) }));
721 const reasons = ["ipv4", "ipv6"].flatMap((family) => (ssh.effective[family]?.reasons || []).map((item) => `${family.toUpperCase()}: ${item.rule} ${item.entry}${item.managed ? " (CH-J)" : ""}${item.uncertain ? " – nelze vyhodnotit bez DNS" : ""}`));
722 if (reasons.length) children.push(section("Pravidla omezující přístup", h("ul", { className: "details-list" }, [...new Set(reasons)].map((text) => h("li", { text })))));
723 el["panel-ssh"].replaceChildren(...children);
726function translateWarning(text) {
727 if (/does not listen on IPv6/.test(text)) return "sshd nenaslouchá na IPv6 – přihlášení přes IPv6 není možné bez ohledu na nastavení účtu.";
728 if (/does not listen on IPv4/.test(text)) return "sshd nenaslouchá na IPv4 – přihlášení přes IPv4 není možné bez ohledu na nastavení účtu.";
729 if (/AuthorizedKeysCommand/.test(text)) return "Je nastaven AuthorizedKeysCommand – klíče mohou pocházet i z externího zdroje, který CH-J nespravuje.";
730 if (/edited manually/.test(text)) return "Soubor SSH politiky CH-J byl ručně upraven; CH-J ho nepřepíše.";
731 return text;
734function renderKeysTab(detail) {
735 const files = detail.keys.files;
736 const account = detail.account;
737 const children = [];
738 if (files.some((file) => file.shared)) children.push(h("p", { className: "notice warn", text: "Účet je správce: Windows OpenSSH používá sdílený soubor administrators_authorized_keys. Každý klíč v něm umožní přihlášení ke všem účtům ze skupiny Administrators; klíče proto nelze přiřadit jednomu účtu." }));
739 if (!files.length || (!detail.privileged && !detail.self)) {
740 children.push(h("p", { className: "notice", text: detail.privileged || detail.self ? "Soubor authorized_keys nebyl nalezen." : detail.platform === "windows" ? "Klíče jiného účtu lze číst jen ze SSH relace správce se zvýšeným tokenem." : "Klíče jiného účtu lze číst jen se sudo (soubor má práva 0600)." }));
741 }
742 const reasons = { "needs-elevation": "vyžaduje zvýšený token správce", "profile-missing": "profil účtu zatím neexistuje (účet se ještě nepřihlásil)", "outside-home": "mimo domovský adresář", symlink: "symbolický odkaz", "not-regular": "není běžný soubor", unreadable: "nelze přečíst", "unsupported-token": "nepodporovaný token v AuthorizedKeysFile", "too-large": "příliš velký", unknown: "neznámý stav" };
743 for (const [index, file] of files.entries()) {
744 if (!detail.privileged && !detail.self) break;
745 const status = file.type === "file" ? `počet klíčů: ${file.entries.filter((entry) => entry.valid).length}` : file.type === "absent" || file.type === "absent-dir" ? "zatím neexistuje" : file.type;
746 const rows = file.entries.map((entry) => entry.valid
747 ? h("tr", {}, [
748 h("td", { text: entry.type }), h("td", { className: "mono", text: entry.fingerprint }), h("td", { text: entry.comment || "—" }),
749 h("td", { className: "mono", text: entry.options || "—" }), h("td", { text: String(entry.line) }),
750 h("td", {}, account.protection === "system" || account.protection === "builtin" || !file.manageable ? null
751 : h("button", { type: "button", className: "mini danger", dataset: { mutation: "1" }, text: "Odebrat", onclick: () => removeKey(detail, file, entry) }))
752 ])
753 : h("tr", { className: "invalid" }, [h("td", { colspan: 6, text: `Řádek ${entry.line}: nerozpoznaný záznam (${entry.type || entry.reason}) – zůstane beze změny.` })]));
754 children.push(section(`${index === 0 ? "Primární" : "Další"} soubor: ${file.path}`, [
755 h("p", { className: "hint", text: `${status} · ${file.meta || ""}${file.dirMeta ? ` · adresář ${file.dirMeta}` : ""}${file.manageable ? "" : ` · nelze spravovat: ${reasons[file.reason] || file.reason}`}` }),
756 rows.length ? h("div", { className: "table-wrap" }, h("table", {}, [h("thead", {}, h("tr", {}, ["Typ", "Otisk SHA256", "Komentář", "Volby", "Řádek", ""].map((text) => h("th", { text })))), h("tbody", {}, rows)])) : null
757 ]));
758 }
759 const primary = files[0];
760 if (!account.protection || account.protection === "session" || account.protection === "root") {
761 if ((detail.platform === "windows" ? account.protection !== "builtin" : !account.system) && (detail.privileged || detail.self)) {
762 children.push(h("div", { className: "inline-actions" }, [
763 h("button", { type: "button", className: "button primary", dataset: { mutation: "1" }, disabled: !primary?.manageable, text: "Přidat Ed25519 klíč…", onclick: () => addKey(detail) })
764 ]));
765 }
766 }
767 if (detail.platform === "windows") children.push(h("p", { className: "hint", text: "Soukromé klíče se nikdy neodesílají na server. Úprava zachová ostatní řádky beze změny a zachová existující ACL souboru (ReplaceFile). Nový soubor dostane ACL jen pro SYSTEM, Administrators a u běžného účtu jeho vlastníka." }));
768 else children.push(h("p", { className: "hint", text: "Soukromé klíče se nikdy neodesílají na server. Úprava zachová všechny ostatní řádky, komentáře a volby klíčů beze změny; zápis probíhá atomicky pod identitou cílového účtu (0700 .ssh, 0600 authorized_keys)." }));
769 el["panel-keys"].replaceChildren(...children);
772function renderDiagnosticsTab(detail) {
773 if (detail.platform === "windows") { renderWindowsDiagnosticsTab(detail); return; }
774 const ssh = detail.ssh;
775 const privilege = state.privilege;
776 const listener = ssh.effective.listener;
777 const files = detail.keys.files;
778 const rows = [
779 ["Oprávnění relace", privilege ? `${privilege.username}: ${(PRIVILEGE_TEXT[privilege.mode] || PRIVILEGE_TEXT.unknown)[0]}${privilege.verified ? "" : " (neověřeno)"}` : "neověřeno"],
780 ["Adresa klienta (SSH_CLIENT)", privilege?.clientAddress || "neznámá"],
781 ["OpenSSH", ssh.version || (ssh.available ? "neznámá verze" : "nenalezen")],
782 ["Služba SSH", ssh.service || "neznámý stav"],
783 ["Kontrola sshd -t", ssh.syntaxOk === null ? "vyžaduje sudo" : ssh.syntaxOk ? "v pořádku" : `CHYBA: ${ssh.syntaxMessage}`],
784 ["Struktura konfigurace", { ok: "Include sshd_config.d před Match – podporováno", "include-missing": "sshd_config.d se nenačítá – nepodporováno", "include-after-match": "Include až za blokem Match – nepodporováno" }[ssh.layout] || ssh.layout],
785 ["Soubor spravovaný CH-J", `${ssh.managed.path} – ${{ absent: "neexistuje", present: ssh.managed.consistent ? "v pořádku" : "ručně upraven", unsafe: "není běžný soubor", unreadable: "nečitelný" }[ssh.managed.state] || ssh.managed.state}`],
786 ["Naslouchání sshd", listener ? `IPv4: ${listener.ipv4 ? "ano" : "ne"}, IPv6: ${listener.ipv6 ? "ano" : "ne"}` : "vyžaduje sudo"],
787 ["AuthorizedKeysCommand", ssh.effective.authorizedKeysCommand || (detail.privileged ? "není nastaven" : "vyžaduje sudo")],
788 ["Domovský adresář", detail.account.homeMeta || "—"],
789 ...files.map((file) => [`Klíče ${file.spec || file.path}`, `${file.path} – ${file.type}${file.meta ? `, ${file.meta}` : ""}${file.dirMeta ? `, adresář ${file.dirMeta}` : ""}`]),
790 ["Načteno", new Date(detail.collectedAt).toLocaleString("cs-CZ")]
791 ];
792 const strict = [];
793 for (const file of files) {
794 const [owner, , mode] = String(file.meta || "").split(":");
795 if (file.type === "file" && owner && owner !== detail.username && owner !== "root") strict.push(`${file.path} nevlastní ${detail.username} (StrictModes může klíče ignorovat).`);
796 if (file.type === "file" && mode && /[2367]$|[2367].$/.test(mode)) strict.push(`${file.path} je zapisovatelný pro skupinu/ostatní (${mode}); sshd s StrictModes klíče odmítne.`);
797 }
798 el["panel-diagnostics"].replaceChildren(infoGrid(rows), ...strict.map((text) => h("p", { className: "notice warn", text })), ...ssh.warnings.map((text) => h("p", { className: "notice warn", text: translateWarning(text) })));
801function selectTab(name, focus = true) {
802 state.tab = name;
803 for (const tab of document.querySelectorAll("[role=tab]")) {
804 const selected = tab.id === `tab-${name}`;
805 tab.setAttribute("aria-selected", String(selected));
806 tab.tabIndex = selected ? 0 : -1;
807 el[tab.getAttribute("aria-controls")].hidden = !selected;
808 if (selected && focus) tab.focus();
809 }
812// ---------- Windows variants (SID, Administrators, account state; no UID/shell/sudo) ----------
813function formatDate(value) { return value ? new Date(value).toLocaleString("cs-CZ") : "—"; }
815function renderWindowsAccountTab(detail) {
816 const account = detail.account;
817 const children = [infoGrid([
818 ["Uživatelské jméno", account.username],
819 ["Celé jméno", account.fullName || "—"],
820 ["Popis", account.description || "—"],
821 ["SID", account.sid],
822 ["Zdroj", account.source === "MicrosoftAccount" ? "lokální účet propojený s účtem Microsoft" : account.source || "—"],
823 ["Stav účtu", account.enabled ? "povolen" : "zakázán"],
824 ["Heslo naposledy nastaveno", formatDate(account.passwordLastSet)],
825 ["Platnost hesla", account.passwordExpires ? formatDate(account.passwordExpires) : "neomezená"],
826 ["Platnost účtu", account.accountExpires ? formatDate(account.accountExpires) : "neomezená"],
827 ["Poslední přihlášení", formatDate(account.lastLogon)],
828 ["Profil", account.profilePath || "zatím nevytvořen"],
829 ["Typ", account.protection === "builtin" ? "vestavěný účet" : "běžný lokální účet"],
830 ["Administrators (S-1-5-32-544)", account.admin ? "člen" : "není člen"]
831 ]), h("p", { className: "hint", text: "Windows účty se identifikují podle SID. UID/GID, shell ani sudo se na Windows nepoužívají. Zakázaný účet se nepřihlásí žádnou metodou včetně SSH klíče." })];
832 if (!account.protection && detail.privileged) {
833 children.push(h("div", { className: "inline-actions" }, [
834 actionButton("Změnit heslo…", () => changePassword(account.username), { sudo: false }),
835 account.enabled
836 ? actionButton("Zakázat účet", () => simpleAction("disable", account.username, "Zakázání účtu", "Účet se nebude moci přihlásit žádnou metodou. Data a profil zůstanou zachovány."), { sudo: false, danger: true })
837 : actionButton("Povolit účet", () => simpleAction("enable", account.username, "Povolení účtu"), { sudo: false }),
838 actionButton("Upravit celé jméno…", () => changeWindowsText(account, "setFullName", "Celé jméno", account.fullName, 256), { sudo: false }),
839 actionButton("Upravit popis…", () => changeWindowsText(account, "setDescription", "Popis", account.description, 48), { sudo: false }),
840 actionButton("Odstranit účet…", () => deleteAccount(account), { danger: true, sudo: false })
841 ]));
842 } else if (!account.protection) {
843 children.push(h("p", { className: "notice", text: "Změny účtu vyžadují SSH relaci správce se zvýšeným tokenem." }));
844 }
845 el["panel-account"].replaceChildren(...children);
848function renderWindowsGroupsTab(detail) {
849 const account = detail.account;
850 const editable = !account.protection && detail.privileged;
851 const groups = state.inventory?.groups || [];
852 const memberSids = new Set(account.groups.map((group) => group.sid));
853 const children = [section("Lokální skupiny", [
854 h("ul", { className: "chip-list" }, account.groups.length ? account.groups.map((group) => h("li", { title: group.sid }, [
855 h("span", { text: group.name }),
856 editable ? h("button", { type: "button", className: "mini", dataset: { mutation: "1" }, text: "Odebrat", onclick: () => removeWindowsGroup(account, group) }) : null
857 ])) : [h("li", { className: "muted", text: "Účet není členem žádné lokální skupiny." })])
858 ], "Názvy skupin jsou lokalizované; CH-J pracuje se skupinami podle SID.")];
859 if (editable) {
860 const candidates = groups.filter((group) => !memberSids.has(group.sid) && group.sid !== "S-1-5-32-544").sort((left, right) => left.name.localeCompare(right.name));
861 const select = h("select", { "aria-label": "Přidat do skupiny" }, candidates.map((group) => h("option", { value: group.sid, text: group.name })));
862 children.push(section("Přidat do skupiny", h("div", { className: "inline-actions" }, [select, actionButton("Přidat", () => {
863 const group = candidates.find((candidate) => candidate.sid === select.value);
864 if (group) void runMutation(`Přidání do skupiny ${group.name}`, (sessionId, auth) => api.users.manage(sessionId, { action: "addGroup", ...identFor(account.username), group: group.sid, ...auth }));
865 }, { sudo: false })])));
866 }
867 const tokenMode = (PRIVILEGE_TEXT[detail.privilege?.mode] || PRIVILEGE_TEXT.unknown)[0];
868 const adminChildren = [infoGrid([
869 ["Členství v Administrators", account.admin ? "člen (S-1-5-32-544)" : "není člen"],
870 ["Token této SSH relace", tokenMode]
871 ]), h("p", { className: "hint", text: "Členství ve skupině Administrators neznamená automaticky zvýšený token procesu (UAC). Na Windows se nepoužívá sudo a CH-J neobchází UAC ani bezpečnostní politiku." })];
872 if (editable) {
873 adminChildren.push(h("div", { className: "inline-actions" }, [
874 account.admin
875 ? actionButton("Odebrat z Administrators", () => simpleAction("revokeAdmin", account.username, "Odebrání administrátorských oprávnění", "Účet bude odebrán ze skupiny Administrators (S-1-5-32-544). Posledního povoleného správce CH-J neodebere."), { danger: true, sudo: false })
876 : actionButton("Přidat do Administrators", () => simpleAction("grantAdmin", account.username, "Přidání administrátorských oprávnění", "Účet bude přidán do skupiny Administrators (S-1-5-32-544). Jeho SSH klíče se pak čtou ze sdíleného administrators_authorized_keys."), { sudo: false })
877 ]));
878 }
879 children.push(section("Správci", adminChildren));
880 const external = state.inventory?.externalIdentities || [];
881 if (external.length) {
882 children.push(section("Doménové a cloudové identity v lokálních skupinách (jen pro čtení)", h("ul", { className: "details-list" }, external.map((item) => h("li", { text: `${item.name} (${item.sid}, ${item.source}) – ${item.groups.join(", ")}` })))));
883 }
884 el["panel-groups"].replaceChildren(...children);
887function renderWindowsDiagnosticsTab(detail) {
888 const ssh = detail.ssh;
889 const system = detail.system || {};
890 const privilege = detail.privilege || state.privilege || {};
891 const listener = ssh.effective.listener;
892 const rows = [
893 ["Systém", `${system.caption || "Windows"} ${system.version || ""} (build ${system.build || "?"})`],
894 ["Typ instalace", system.installationType ? `${system.installationType}${system.serverCore ? " – bez GUI" : ""}` : "—"],
895 ["Detekce platformy", `${system.detectedBy || "?"}${system.unameOutput ? `, uname: ${system.unameOutput}` : ""}`],
896 ["Výchozí SSH shell", `${system.defaultShell || "?"}${system.defaultShellPath ? ` (${system.defaultShellPath})` : " (výchozí cmd.exe)"}`],
897 ["PowerShell pro CH-J", `${system.powershell || "?"}${system.is64BitProcess ? ", 64bit" : ", 32bit!"}, ${system.languageMode || "?"}${system.pwsh ? `; PowerShell 7: ${system.pwsh}` : "; PowerShell 7 nenalezen"}`],
898 ["LocalAccounts", system.localAccounts ? "dostupný" : "nedostupný"],
899 ["Token relace", `${privilege.username || "?"} – ${(PRIVILEGE_TEXT[privilege.mode] || PRIVILEGE_TEXT.unknown)[0]}, integrita ${privilege.integrity || "?"}`],
900 ["Adresa klienta (SSH_CLIENT)", privilege.clientAddress || "neznámá"],
901 ["OpenSSH Server", ssh.version || (ssh.available ? "neznámá verze" : "nenainstalován")],
902 ["Služba sshd", ssh.service || "—"],
903 ["sshd_config", ssh.configPath || "—"],
904 ["Kontrola sshd -t", ssh.syntaxOk === null ? "vyžaduje zvýšený token" : ssh.syntaxOk ? "v pořádku" : `CHYBA: ${ssh.syntaxMessage}`],
905 ["Spravované sekce CH-J", ssh.layoutSupported ? (ssh.managed.state === "present" ? (ssh.managed.consistent ? "přítomny, v pořádku" : "ručně upraveny") : "zatím nejsou") : `nepodporováno: ${(ssh.layoutProblems || []).join("; ")}`],
906 ["Naslouchání sshd", listener ? `IPv4: ${listener.ipv4 ? "ano" : "ne"}, IPv6: ${listener.ipv6 ? "ano" : "ne"}` : "vyžaduje zvýšený token"],
907 ["Aplikace změn", "restart služby sshd s nezávislým rollbackem (naplánovaná úloha SYSTEM, 120 s)"],
908 ...detail.keys.files.map((file) => [`Klíče ${file.spec || ""}`, `${file.path || "nevyřešeno"} – ${file.type}${file.shared ? " (sdílený)" : ""}${file.acl?.length ? `; ACL: ${file.acl.map((entry) => `${entry.sid}:${entry.rights}`).join(", ")}` : ""}`]),
909 ["Načteno", new Date(detail.collectedAt).toLocaleString("cs-CZ")]
910 ];
911 el["panel-diagnostics"].replaceChildren(infoGrid(rows), ...ssh.warnings.map((text) => h("p", { className: "notice warn", text: translateWarning(text) })));
914async function changeWindowsText(account, action, label, value, limit) {
915 const answer = await askValue({ title: label, label, value: value || "", validate: ({ value: text }) => text.length > limit || /[\x00-\x1f]/.test(text) ? `Max. ${limit} znaků bez řídicích znaků.` : "" });
916 if (!answer) return;
917 const field = action === "setDescription" ? "description" : "fullName";
918 await runMutation(`Úprava: ${label}`, (sessionId, auth) => api.users.manage(sessionId, { action, ...identFor(account.username), [field]: answer.value.trim(), ...auth }));
921async function removeWindowsGroup(account, group) {
922 const answer = await confirmAction({ title: `Odebrat ze skupiny ${group.name}`, message: `Účet ${account.username} přestane být členem skupiny ${group.name} (${group.sid}). Ostatní skupiny zůstanou.`, confirmLabel: "Odebrat", danger: true });
923 if (!answer) return;
924 await runMutation(`Odebrání ze skupiny ${group.name}`, (sessionId, auth) => api.users.manage(sessionId, { action: "removeGroup", ...identFor(account.username), group: group.sid, ...auth }));
927// ---------- mutations ----------
928async function runMutation(label, invoke, { successMessage } = {}) {
929 if (state.mutating || !connected()) return null;
930 const token = state.generation;
931 const sessionId = state.sessionId;
932 state.mutating = true; updateControls();
933 setStatus(`${label}…`);
934 try {
935 const result = await withSudo(label, (auth) => invoke(sessionId, auth));
936 if (token !== state.generation) return null;
937 if (result?.inventory) applyInventory(result.inventory);
938 if (result?.sessionLost) { setStatus("Změna byla potvrzena, ale původní SSH relace se restartem sshd ukončila. Připojte se znovu v hlavním okně.", true); void loadSessions(); return result; }
939 if (result?.detail) {
940 // Keep the list column in sync with the verified policy returned by Core.
941 const listed = state.inventory?.users.find((user) => user.username === result.detail.username);
942 if (listed) {
943 const current = result.detail.ssh.policy;
944 listed.sshPolicy = POLICY_ROWS.every((row) => current[row.key] === row.options[0][0]) ? null : { ...current };
945 renderUsers();
946 }
947 if (state.selected === result.detail.username) { state.detailToken += 1; state.detail = result.detail; state.detailLoading = false; renderDetail(); }
948 }
949 const failedSteps = (result?.steps || []).filter((step) => step.status === "failed");
950 const warnings = [...(result?.warnings || []), ...failedSteps.map((step) => `Krok ${step.step} selhal: ${step.message}`)];
951 const partial = result?.result === "partial" || failedSteps.length > 0;
952 setStatus(`${successMessage || `${label}: hotovo, stav byl znovu načten ze serveru.`}${partial ? " Pouze částečně!" : ""}${warnings.length ? ` ${warnings.join(" ")}` : ""}`, partial);
953 return result;
954 } catch (error) {
955 if (token === state.generation) setStatus(describeError(error), parseError(error).code !== CANCELLED);
956 return null;
957 } finally {
958 state.mutating = false; updateControls();
959 }
962async function simpleAction(action, username, label, message = "") {
963 const answer = await confirmAction({ title: label, message: message || `Provést „${label}“ pro účet ${username}?`, confirmLabel: label, danger: ["expire", "revokeAdmin", "lock"].includes(action) });
964 if (!answer) return;
965 await runMutation(label, (sessionId, auth) => api.users.manage(sessionId, { action, ...identFor(username), ...auth }));
968async function changePassword(username) {
969 const answer = await askValue({
970 title: `Nové heslo pro ${username}`, label: "Nové heslo", label2: "Heslo znovu", type: "password",
971 message: "Heslo se přenese přes stdin SSH kanálu do chpasswd, nikdy v argumentech příkazu.",
972 validate: ({ value, value2 }) => !value ? "Zadejte heslo." : value !== value2 ? "Hesla se neshodují." : value.length > 256 || /[\r\n]/.test(value) ? "Heslo má neplatný formát." : ""
973 });
974 if (!answer) return;
975 let password = answer.value;
976 answer.value = ""; answer.value2 = "";
977 await runMutation("Změna hesla", (sessionId, auth) => api.users.manage(sessionId, { action: "setPassword", ...identFor(username), password, ...auth }));
978 password = "";
981async function changeComment(account) {
982 const answer = await askValue({ title: "Celé jméno", label: "Celé jméno (GECOS)", value: account.comment, validate: ({ value }) => value.length > 100 || /[:\\]/.test(value) ? "Max. 100 znaků bez dvojtečky." : "" });
983 if (!answer) return;
984 await runMutation("Úprava celého jména", (sessionId, auth) => api.users.manage(sessionId, { action: "setComment", ...identFor(account.username), comment: answer.value.trim(), ...auth }));
987async function changeHome(account) {
988 const answer = await askValue({
989 title: "Domovský adresář", label: "Nová cesta", value: account.home, option: "přesunout obsah (usermod -m, cíl nesmí existovat)",
990 validate: ({ value }) => /^\/[A-Za-z0-9._/@+-]+$/.test(value.trim()) ? "" : "Zadejte absolutní cestu bez mezer a speciálních znaků."
991 });
992 if (!answer || answer.value.trim() === account.home) return;
993 await runMutation("Změna domovského adresáře", (sessionId, auth) => api.users.manage(sessionId, { action: "setHome", ...identFor(account.username), home: answer.value.trim(), moveHome: answer.option, ...auth }));
996async function deleteAccount(account) {
997 const answer = await confirmAction({
998 title: `Odstranit účet ${account.username}`,
999 message: "Účet bude odstraněn ze serveru. Operaci nelze vrátit.",
1000 details: account.platform === "windows"
1001 ? [`SID ${account.sid}`, `Profil ${account.profilePath || "neexistuje"}`, "Pravidla SSH spravovaná CH-J pro tento účet budou také odstraněna (vyžaduje restart sshd a testovací přihlášení)."]
1002 : [`UID ${account.uid}, domovský adresář ${account.home}`, account.processes ? `Účet má ${account.processes} spuštěných procesů – odstranění bude odmítnuto.` : "Pravidla SSH spravovaná CH-J pro tento účet budou také odstraněna."],
1003 option: account.platform === "windows" ? (account.profilePath ? `odstranit také profil ${account.profilePath} (samostatné potvrzení)` : "") : `odstranit také domovský adresář ${account.home} (samostatné potvrzení)`,
1004 typed: account.username, confirmLabel: "Odstranit účet", danger: true
1005 });
1006 if (!answer) return;
1007 let removeHome = false;
1008 if (answer.option) {
1009 const second = await confirmAction({ title: account.platform === "windows" ? "Odstranit profil?" : "Odstranit domovský adresář?", message: `Obsah ${account.platform === "windows" ? account.profilePath : account.home} bude nevratně smazán.`, confirmLabel: "Ano, smazat i data", danger: true });
1010 if (!second) { setStatus("Odstranění bylo zrušeno."); return; }
1011 removeHome = true;
1013 const result = await runMutation("Odstranění účtu", (sessionId, auth) => api.users.manage(sessionId, { action: "delete", ...identFor(account.username), removeHome, removeProfile: removeHome, ...auth }));
1014 if (result && state.selected === account.username) { state.selected = null; state.detail = null; renderUsers(); renderDetail(); }
1017async function addGroup(username, group) {
1018 if (!group) return;
1019 await runMutation(`Přidání do skupiny ${group}`, (sessionId, auth) => api.users.manage(sessionId, { action: "addGroup", ...identFor(username), group, ...auth }));
1022async function removeGroup(username, group) {
1023 const answer = await confirmAction({ title: `Odebrat ze skupiny ${group}`, message: `Účet ${username} přestane být členem skupiny ${group}. Ostatní skupiny zůstanou.`, confirmLabel: "Odebrat", danger: true });
1024 if (!answer) return;
1025 await runMutation(`Odebrání ze skupiny ${group}`, (sessionId, auth) => api.users.manage(sessionId, { action: "removeGroup", ...identFor(username), group, ...auth }));
1028async function setPrimary(username, group, current) {
1029 if (!group || group === current) return;
1030 const answer = await confirmAction({ title: "Změna primární skupiny", message: `Primární skupina účtu ${username} bude ${group}. Vlastnictví existujících souborů se nemění.`, confirmLabel: "Změnit" });
1031 if (!answer) return;
1032 await runMutation("Změna primární skupiny", (sessionId, auth) => api.users.manage(sessionId, { action: "setPrimaryGroup", username, group, ...auth }));
1035async function saveSshPolicy(detail, form) {
1036 const rows = detail.platform !== "windows" && detail.account.uid === 0 ? [...POLICY_ROWS, ROOT_LOGIN_ROW] : POLICY_ROWS;
1037 const policy = Object.fromEntries(rows.map((row) => [row.key, form.querySelector(`input[name="policy-${row.key}"]:checked`)?.value || detail.ssh.policy[row.key] || "inherit"]));
1038 const changes = rows.filter((row) => policy[row.key] !== detail.ssh.policy[row.key]).map((row) => `${row.label}: ${row.options.find(([value]) => value === detail.ssh.policy[row.key])?.[1]} → ${row.options.find(([value]) => value === policy[row.key])?.[1]}`);
1039 if (!changes.length) { setStatus("Nastavení SSH se nezměnilo."); return; }
1040 let confirmRootPassword = false;
1041 if (detail.account.uid === 0 && detail.platform !== "windows" && (policy.password === "yes" || policy.permitRootLogin === "yes")) {
1042 const warning = await confirmAction({
1043 title: "Povolit přihlášení root heslem?",
1044 message: "Přihlášení root heslem výrazně zvyšuje riziko útoků hádáním hesel. Doporučená volba je „Jen klíč“ (prohibit-password). Pro přihlášení Ed25519 klíčem není heslo root potřeba.",
1045 typed: "root", confirmLabel: "Rozumím, povolit heslo", danger: true
1046 });
1047 if (!warning) { setStatus("Povolení hesla pro root nebylo potvrzeno; nic nebylo změněno."); return; }
1048 confirmRootPassword = true;
1050 const risky = detail.self || policy.access === "deny" || policy.pubkey === "no" || policy.password === "no";
1051 const answer = await confirmAction({
1052 title: `SSH přístup účtu ${detail.username}`,
1053 message: detail.platform === "windows" ? "sshd bude restartován. Změna se ponechá jen tehdy, když ji do 120 s potvrdí nové SSH přihlášení; jinak ji úloha na serveru sama vrátí." : detail.self ? "Měníte SSH přístup účtu této relace. Nechte relaci otevřenou; při neúspěšném testu nového přihlášení se změna vrátí." : "Změna se ověří (sshd -t, sshd -T -C) a aplikuje reloadem SSH služby.",
1054 details: changes, confirmLabel: "Aplikovat a ověřit", danger: risky
1055 });
1056 if (!answer) return;
1057 let probeCredentials = null;
1058 await runMutation("Změna SSH přístupu", async (sessionId, auth) => {
1059 for (;;) {
1060 try { return await api.users.sshPolicy(sessionId, { ...identFor(detail.username), policy, confirmRootPassword, ...auth, ...(probeCredentials ? { probeCredentials } : {}) }); }
1061 catch (error) {
1062 const { code, detail: message } = parseError(error);
1063 if (code !== "USERS_SSH_PROBE_CREDENTIALS_REQUIRED" || probeCredentials) throw error;
1064 probeCredentials = await askProbeSecret(/passphrase/.test(message) ? "passphrase" : "password");
1065 if (!probeCredentials) throw cancelled();
1068 }, { successMessage: detail.platform === "windows" ? "SSH přístup byl změněn, ověřen přes sshd -T, sshd byl restartován a změnu potvrdilo nové SSH přihlášení." : "SSH přístup byl změněn, ověřen přes sshd -T a SSH služba byla znovu načtena." });
1069 probeCredentials = null;
1072async function removeKey(detail, file, entry) {
1073 const answer = await confirmAction({
1074 title: "Odebrat SSH klíč", message: `Z ${file.path} bude odebrán pouze tento klíč. Ostatní řádky zůstanou beze změny.`,
1075 details: [`${entry.type} ${entry.fingerprint}`, entry.comment ? `Komentář: ${entry.comment}` : "", `Řádek ${entry.line}`].filter(Boolean), confirmLabel: "Odebrat klíč", danger: true
1076 });
1077 if (!answer) return;
1078 await runMutation("Odebrání SSH klíče", (sessionId, auth) => api.users.keys(sessionId, { action: "remove", ...identFor(detail.username), path: file.path, line: entry.line, fingerprint: entry.fingerprint, ...auth }));
1081async function addKey(detail) {
1082 const publicKey = await runDialog(el.keyDialog, {
1083 init: () => {
1084 el.keyText.value = ""; el.keyGenComment.value = `${detail.username}@${state.sessions.find((session) => session.sessionId === state.sessionId)?.host || "server"}`;
1085 el.keyGenPassphrase.value = ""; el.keyGenerateFields.hidden = true;
1086 showError(el.keyError, ""); el.keyInfo.hidden = true;
1087 },
1088 submit: () => {
1089 const text = el.keyText.value.trim();
1090 if (!/^(ssh-ed25519|sk-ssh-ed25519@openssh\.com) [A-Za-z0-9+/=]+( .*)?$/.test(text)) {
1091 showError(el.keyError, /PRIVATE KEY/.test(text) ? ERROR_TEXT.USERS_KEY_PRIVATE : "Vložte jeden veřejný klíč Ed25519 ve formátu OpenSSH.");
1092 return undefined;
1094 return text;
1096 }).finally(() => { el.keyGenPassphrase.value = ""; });
1097 if (!publicKey) return;
1098 await runMutation("Přidání SSH klíče", (sessionId, auth) => api.users.keys(sessionId, { action: "add", ...identFor(detail.username), publicKey, ...auth }));
1101async function generateKeyPair() {
1102 showError(el.keyError, ""); el.keyInfo.hidden = true;
1103 el.keyGenRun.disabled = true;
1104 let passphrase = el.keyGenPassphrase.value;
1105 el.keyGenPassphrase.value = "";
1106 try {
1107 const generated = await api.keys.generate({ type: "ed25519", comment: el.keyGenComment.value, passphrase, privateKeyFormat: "openssh" });
1108 passphrase = "";
1109 for (const field of ["privateKey", "privateKeyPem", "privateKeyOpenSSH", "privateKeyPpk"]) generated[field] = "";
1110 const saved = await api.keys.save(generated.generationId);
1111 if (!saved || saved.canceled) { showError(el.keyError, "Soukromý klíč nebyl uložen, proto se veřejný klíč nepřidá."); return; }
1112 el.keyText.value = generated.publicKey;
1113 el.keyInfo.textContent = `Soukromý klíč byl uložen lokálně: ${saved.privatePath} (${saved.privateKeyFormat}). ${generated.notice || ""} Potvrďte přidání veřejného klíče na server.`;
1114 el.keyInfo.hidden = false;
1115 } catch (error) {
1116 showError(el.keyError, `Generování selhalo: ${describeError(error)}`);
1117 } finally {
1118 passphrase = "";
1119 el.keyGenRun.disabled = false;
1123async function openCreateDialog() {
1124 const inventory = state.inventory;
1125 if (!inventory) return;
1126 const windows = inventory.platform === "windows";
1127 const groups = windows
1128 ? inventory.groups.filter((group) => !["S-1-5-32-544", "S-1-5-32-545"].includes(group.sid)).sort((left, right) => left.name.localeCompare(right.name))
1129 : inventory.groups.filter((group) => group.gid >= (inventory.server.uidMin || 1000) || ["sudo", "wheel", "adm", "users", "docker", "www-data"].includes(group.name)).sort((left, right) => left.name.localeCompare(right.name));
1130 const payload = await runDialog(el.createDialog, {
1131 init: () => {
1132 for (const id of ["createUsername", "createComment", "createHome", "createPassword", "createPassword2"]) el[id].value = "";
1133 el.createMakeHome.checked = true; el.createAdmin.checked = false; el.createDescription.value = "";
1134 for (const id of ["createShell", "createHome", "createPrimary"]) el[id].closest("label").hidden = windows;
1135 el.createMakeHomeLabel.hidden = windows;
1136 el.createDescriptionLabel.hidden = !windows;
1137 el.createUsername.pattern = windows ? "[A-Za-z0-9_][A-Za-z0-9._ \\-]{0,19}" : "[a-z_][a-z0-9_\\-]{0,31}";
1138 el.createUsername.maxLength = windows ? 20 : 32;
1139 el.createAdminText.textContent = windows ? "přidat do skupiny Administrators (S-1-5-32-544)" : "přidat do administrátorské skupiny (sudo/wheel podle serveru)";
1140 el.createHint.textContent = windows ? "Účet se přidá do skupiny Users. Bez hesla se lze přihlásit jen SSH klíčem (je-li to povoleno politikou). Profil vznikne při prvním přihlášení." : "Bez hesla se účet může přihlásit jen SSH klíčem. Sudo práva se po vytvoření ověří pomocí sudo -l.";
1141 const shells = inventory.shells.length ? inventory.shells : ["/bin/sh"];
1142 el.createShell.replaceChildren(...shells.map((shell) => h("option", { value: shell, text: shell, selected: shell === "/bin/bash" })));
1143 el.createPrimary.replaceChildren(h("option", { value: "", text: "Vlastní skupina se jménem účtu" }), ...groups.map((group) => h("option", { value: group.name, text: group.name })));
1144 el.createGroups.replaceChildren(...groups.map((group) => h("option", { value: windows ? group.sid : group.name, text: group.name })));
1145 showError(el.createError, "");
1146 setTimeout(() => el.createUsername.focus(), 0);
1147 },
1148 submit: () => {
1149 const username = el.createUsername.value.trim();
1150 const password = el.createPassword.value;
1151 const home = el.createHome.value.trim();
1152 let problem = "";
1153 if (windows ? !/^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$/.test(username) || /[. ]$/.test(username) : !/^[a-z_][a-z0-9_-]{0,31}$/.test(username)) problem = windows ? ERROR_TEXT.USERS_NAME_INVALID_WINDOWS : ERROR_TEXT.USERS_NAME_INVALID;
1154 else if (inventory.users.some((user) => user.username === username)) problem = ERROR_TEXT.USERS_USER_EXISTS;
1155 else if (home && !/^\/[A-Za-z0-9._/@+-]+$/.test(home)) problem = ERROR_TEXT.USERS_HOME_INVALID;
1156 else if (password !== el.createPassword2.value) problem = "Hesla se neshodují.";
1157 else if (/[\r\n]/.test(password)) problem = ERROR_TEXT.USERS_PASSWORD_INVALID;
1158 if (problem) { showError(el.createError, problem); return undefined; }
1159 if (windows) {
1160 return { action: "create", username, fullName: el.createComment.value.trim(), description: el.createDescription.value.trim(),
1161 groups: [...el.createGroups.selectedOptions].map((option) => option.value), admin: el.createAdmin.checked, ...(password ? { password } : {}) };
1163 return {
1164 action: "create", username, comment: el.createComment.value.trim(), shell: el.createShell.value, home, createHome: el.createMakeHome.checked,
1165 primaryGroup: el.createPrimary.value, groups: [...el.createGroups.selectedOptions].map((option) => option.value), admin: el.createAdmin.checked,
1166 ...(password ? { password } : {})
1167 };
1169 }).finally(() => { el.createPassword.value = ""; el.createPassword2.value = ""; });
1170 if (!payload) return;
1171 const result = await runMutation(`Vytvoření účtu ${payload.username}`, (sessionId, auth) => api.users.manage(sessionId, { ...payload, ...auth }));
1172 payload.password = "";
1173 if (result) {
1174 el.filterSelect.value = "regular"; renderUsers();
1175 void loadDetail(result.username);
1179// ---------- events ----------
1180el.closeButton.addEventListener("click", () => api.close());
1181el.refreshSessionsButton.addEventListener("click", () => { void loadSessions(); });
1182el.sessionSelect.addEventListener("change", () => { void selectSession(el.sessionSelect.value); });
1183el.reloadButton.addEventListener("click", async () => {
1184 await refreshPrivilege();
1185 await loadInventory();
1186 if (state.selected) void loadDetail(state.selected);
1187});
1188el.elevatedButton.addEventListener("click", () => { void loadInventory({ elevated: true }); });
1189el.searchInput.addEventListener("input", renderUsers);
1190el.filterSelect.addEventListener("change", renderUsers);
1191el.createButton.addEventListener("click", () => { void openCreateDialog(); });
1192el.usersBody.addEventListener("click", (event) => {
1193 const button = event.target.closest("button[data-username]");
1194 if (button) void loadDetail(button.dataset.username);
1195});
1196document.querySelector("[role=tablist]").addEventListener("click", (event) => {
1197 const tab = event.target.closest("[role=tab]");
1198 if (tab) selectTab(tab.id.replace("tab-", ""));
1199});
1200document.querySelector("[role=tablist]").addEventListener("keydown", (event) => {
1201 if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
1202 const tabs = [...document.querySelectorAll("[role=tab]")];
1203 const index = tabs.findIndex((tab) => tab.id === `tab-${state.tab}`);
1204 const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
1205 event.preventDefault();
1206 selectTab(tabs[next].id.replace("tab-", ""));
1207});
1208el.keyImportButton.addEventListener("click", () => el.keyFile.click());
1209el.keyFile.addEventListener("change", async () => {
1210 const file = el.keyFile.files?.[0];
1211 el.keyFile.value = "";
1212 if (!file) return;
1213 if (file.size > 16 * 1024) { showError(el.keyError, "Soubor je příliš velký pro veřejný klíč."); return; }
1214 const text = await file.text();
1215 if (/PRIVATE KEY/.test(text)) { showError(el.keyError, ERROR_TEXT.USERS_KEY_PRIVATE); return; }
1216 el.keyText.value = text.trim();
1217 showError(el.keyError, "");
1218});
1219el.keyGenerateButton.addEventListener("click", () => { el.keyGenerateFields.hidden = !el.keyGenerateFields.hidden; });
1220el.keyGenRun.addEventListener("click", () => { void generateKeyPair(); });
1222(async () => {
1223 try {
1224 const info = await api.getInfo();
1225 el.pluginVersion.textContent = `${info.name} v${info.version} · ${info.publisher}`;
1226 } catch {}
1227 await loadSessions();
1228})().catch((error) => setStatus(describeError(error), true));
SHA-2565bce947716cbcd3e6315bcdef90bf3207271e9b782b1ff80e254646e79f77df0

CH-J Proprietary Software License 1.14 · La source est fournie sous CH-J Proprietary Software License 1.14. Sa disponibilité ne modifie pas les termes de la licence ni n'accorde de droits supplémentaires.

Archive SHA-256cf4455b698f0f28e456a37b1834976a0194d8486acb7101052d59a838745cca3
Fichiers