/* * Copyright (C) 2013 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import '../lib/patternfly/patternfly-6-cockpit.scss'; import 'polyfills'; // once per application import 'cockpit-dark-theme'; // once per page import React, { useMemo, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { debounce } from 'throttle-debounce'; import cockpit from 'cockpit'; import { superuser } from "superuser"; import { getLastlog2 } from "logins"; import { usePageLocation, useLoggedInUser, useFile, useInit } from "hooks.js"; import { etc_passwd_syntax, etc_group_syntax, etc_shells_syntax } from "pam_user_parser.js"; import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; import { AccountsMain } from "./accounts-list.js"; import { AccountDetails, AccountNotFound } from "./account-details.js"; import "./users.scss"; import { fsinfo } from 'cockpit/fsinfo'; superuser.reload_page_on_change(); export const admins = ['sudo', 'root', 'wheel']; const sortGroups = groups => { return groups.sort((a, b) => { if (a.isAdmin && !b.isAdmin) return -1; if (!a.isAdmin && b.isAdmin) return 1; return a.name.localeCompare(b.name); }); }; function AccountsPage() { const [isGroupsExpanded, setIsGroupsExpanded] = useState(false); const { path } = usePageLocation(); const accounts = useFile("/etc/passwd", { syntax: etc_passwd_syntax }); const groups = useFile("/etc/group", { syntax: etc_group_syntax }); const shells = useFile("/etc/shells", { syntax: etc_shells_syntax }); const current_user_info = useLoggedInUser(); // Handle the case where logindef == null, i.e. the file does not exist. // While that's unusual, "empty /etc" is a goal, and it shouldn't crash the page. const [min_gid, setMinGid] = useState(500); const [max_gid, setMaxGid] = useState(60000); const [min_uid, setMinUid] = useState(500); const [max_uid, setMaxUid] = useState(60000); const [details, setDetails] = useState(null); useInit(async () => { const logind_client = cockpit.dbus("org.freedesktop.login1"); const debouncedGetLoginDetails = debounce(100, () => { getLoginDetails(logind_client).then(setDetails); }); /* We are mostly interested in UserNew/UserRemoved. But SessionRemoved happens immediately after logout, * while UserRemoved lags behind due to the "State: closing" period when the user's systemd instance * etc. are being cleaned up. Also, there's not that many signals and this is debounced, so just react to all * of them. See https://www.freedesktop.org/wiki/Software/systemd/logind/ */ logind_client.subscribe({ interface: "org.freedesktop.login1.Manager", path: "/org/freedesktop/login1", }, debouncedGetLoginDetails); let handleUtmp; // Watch /etc/shadow to register lock/unlock/expire changes; but avoid reading it, it's sensitive data const handleShadow = cockpit.file("/etc/shadow", { superuser: "try" }); handleShadow.watch(() => debouncedGetLoginDetails(), { read: false }); let handleLogindef; try { await fsinfo("/etc/login.defs", []); handleLogindef = cockpit.file("/etc/login.defs"); } catch (ex) { handleLogindef = cockpit.file("/usr/etc/login.defs"); } handleLogindef.watch((logindef) => { if (logindef === null) return; const minGid = parseInt(logindef.match(/^GID_MIN\s+(\d+)/m)[1]); const maxGid = parseInt(logindef.match(/^GID_MAX\s+(\d+)/m)[1]); const minUid = parseInt(logindef.match(/^UID_MIN\s+(\d+)/m)[1]); const maxUid = parseInt(logindef.match(/^UID_MAX\s+(\d+)/m)[1]); if (minGid) setMinGid(minGid); if (maxGid) setMaxGid(maxGid); if (minUid) setMinUid(minUid); if (maxUid) setMaxUid(maxUid); }); return [logind_client, handleUtmp, handleShadow, handleLogindef]; }, [], null, handles => handles.forEach(handle => handle.close())); const accountsInfo = useMemo(() => { if (accounts && details) return accounts.map(account => { return Object.assign({}, account, details[account.name]); }); else return []; }, [accounts, details]); // FIXME: We only want to update this when details is updated, not accounts const groupsExtraInfo = useMemo(() => sortGroups( (groups || []).map(group => { const userlistPrimary = accountsInfo.filter(account => account.gid === group.gid).map(account => account.name); const userlist = group.userlist.filter(el => el !== ""); return ({ ...group, userlistPrimary, userlist, members: userlist.length + userlistPrimary.length, isAdmin: admins.includes(group.name), isUserCreatedGroup: group.gid >= min_gid && group.gid <= max_gid }); }) ), [groups, accountsInfo, min_gid, max_gid]); if (groupsExtraInfo.length === 0 || accountsInfo.length === 0) { return ; } else if (path.length === 0) { return ( ); } else if (path.length === 1) { const account = accountsInfo?.find(account => account.name === path[0]); if (!account) return ; return ( ); } else return null; } async function getLoginDetails(logind_client) { const details = {}; // currently logged in try { // out args: uso (uid, name, logind object) const [users] = await logind_client.call( "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "ListUsers", null, { type: "", flags: "", timeout: 5000 }); await Promise.all(users.map(async ([_, name, objpath]) => { const [active] = await logind_client.call( objpath, "org.freedesktop.DBus.Properties", "Get", ["org.freedesktop.login1.User", "State"], { type: "ss", flags: "", timeout: 5000 }); if (active.v !== "closing") details[name] = { ...details[name], loggedIn: true }; })); } catch (err) { console.warn("Unexpected error when getting logged in accounts", err); } // locked password // shadow-utils passwd supports an --all flag which is lacking on RHEL and // stable Fedora releases. Available at least on Fedora since // shadow-utils-4.14.0-5.fc40 (currently known as rawhide). try { const locked_statuses = await cockpit.spawn(["passwd", "-S", "--all"], { superuser: "require", err: "message", environ: ["LC_ALL=C"] }); // Slice off the last empty line for (const line of locked_statuses.trim().split('\n')) { const name = line.split(" ")[0]; const status = line.split(" ")[1]; details[name] = { ...details[name], isLocked: status === "L" }; } } catch (err) { if (err.message?.includes("bad argument --all")) { // Fallback for old passwd try { const shadow = await cockpit.file("/etc/shadow", { superuser: "require", err: "message" }).read(); for (const line of shadow.split('\n')) { const [name, hash] = line.split(":"); if (name && hash) details[name] = { ...details[name], isLocked: hash.startsWith("!") }; } } catch (err) { console.warn("Unexpected error when getting locked accounts from /etc/shadow:", err.toString()); } } else { console.warn("Unexpected error when getting locked account information:", err.toString()); } } // last logged in try { // merge lastlog2 into details for (const [name, last] of Object.entries(await getLastlog2())) details[name] = { ...details[name], lastLogin: last.time * 1000 }; } catch (err) { // fall back to legacy lastlog try { const out = await cockpit.spawn(["lastlog"], { environ: ["LC_ALL=C"] }); await Promise.all(out.split('\n').slice(1, -1).map(async line => { if (line.includes('**Never logged in**')) return; const splitLine = line.trim().split(/[ \t]+/); const name = splitLine[0]; const date_fields = splitLine.slice(-5); // this is impossible to parse with Date() (e.g. Firefox does not work with all time zones), so call `date` to parse it try { const out = await cockpit.spawn(["date", "+%s", "-d", date_fields.join(' ')], { environ: ["LC_ALL=C"], err: "out" }); details[name] = { ...details[name], lastLogin: parseInt(out) * 1000 }; } catch (e) { console.warn(`Failed to parse date from lastlog line '${line}': ${e.toString()}`); } })); } catch (ex) { console.warn(`Failed to run lastlog: ${ex.toString()}`); } } return details; } function init() { const root = createRoot(document.getElementById("page")); root.render(); document.body.removeAttribute("hidden"); } document.addEventListener("DOMContentLoaded", init);