Line data Source code
1 : /*
2 : * Copyright (C) 2013 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 : import '../lib/patternfly/patternfly-6-cockpit.scss';
6 : import 'polyfills'; // once per application
7 : import 'cockpit-dark-theme'; // once per page
8 :
9 18 : import React, { useMemo, useState } from 'react';
10 18 : import { createRoot } from 'react-dom/client';
11 :
12 : import { debounce } from 'throttle-debounce';
13 :
14 18 : import cockpit from 'cockpit';
15 : import { superuser } from "superuser";
16 : import { getLastlog2 } from "logins";
17 : import { usePageLocation, useLoggedInUser, useFile, useInit } from "hooks.js";
18 : import { etc_passwd_syntax, etc_group_syntax, etc_shells_syntax } from "pam_user_parser.js";
19 : import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
20 :
21 : import { AccountsMain } from "./accounts-list.js";
22 : import { AccountDetails, AccountNotFound } from "./account-details.js";
23 :
24 : import "./users.scss";
25 : import { fsinfo } from 'cockpit/fsinfo';
26 :
27 18 : superuser.reload_page_on_change();
28 :
29 18 : export const admins = ['sudo', 'root', 'wheel'];
30 18 : const sortGroups = groups => {
31 18 : return groups.sort((a, b) => {
32 18 : if (a.isAdmin)
33 18 : return -1;
34 18 : if (b.isAdmin)
35 18 : return 1;
36 18 : if (a.members === b.members)
37 18 : return a.name.localeCompare(b.name);
38 : else
39 18 : return b.members - a.members;
40 18 : });
41 18 : };
42 :
43 18 : function AccountsPage() {
44 18 : const [isGroupsExpanded, setIsGroupsExpanded] = useState(false);
45 18 : const { path } = usePageLocation();
46 18 : const accounts = useFile("/etc/passwd", { syntax: etc_passwd_syntax });
47 18 : const groups = useFile("/etc/group", { syntax: etc_group_syntax });
48 18 : const shells = useFile("/etc/shells", { syntax: etc_shells_syntax });
49 18 : const current_user_info = useLoggedInUser();
50 :
51 : // Handle the case where logindef == null, i.e. the file does not exist.
52 : // While that's unusual, "empty /etc" is a goal, and it shouldn't crash the page.
53 18 : const [min_gid, setMinGid] = useState(500);
54 18 : const [max_gid, setMaxGid] = useState(60000);
55 18 : const [min_uid, setMinUid] = useState(500);
56 18 : const [max_uid, setMaxUid] = useState(60000);
57 18 : const [details, setDetails] = useState(null);
58 :
59 18 : useInit(async () => {
60 18 : const logind_client = cockpit.dbus("org.freedesktop.login1");
61 :
62 18 : const debouncedGetLoginDetails = debounce(100, () => {
63 18 : getLoginDetails(logind_client).then(setDetails);
64 18 : });
65 :
66 : /* We are mostly interested in UserNew/UserRemoved. But SessionRemoved happens immediately after logout,
67 : * while UserRemoved lags behind due to the "State: closing" period when the user's systemd instance
68 : * etc. are being cleaned up. Also, there's not that many signals and this is debounced, so just react to all
69 : * of them. See https://www.freedesktop.org/wiki/Software/systemd/logind/ */
70 18 : logind_client.subscribe({
71 18 : interface: "org.freedesktop.login1.Manager",
72 18 : path: "/org/freedesktop/login1",
73 18 : }, debouncedGetLoginDetails);
74 :
75 18 : let handleUtmp;
76 :
77 : // Watch /etc/shadow to register lock/unlock/expire changes; but avoid reading it, it's sensitive data
78 18 : const handleShadow = cockpit.file("/etc/shadow", { superuser: "try" });
79 18 : handleShadow.watch(() => debouncedGetLoginDetails(), { read: false });
80 :
81 18 : let handleLogindef;
82 18 : try {
83 18 : await fsinfo("/etc/login.defs", []);
84 18 : handleLogindef = cockpit.file("/etc/login.defs");
85 3 : } catch (ex) {
86 3 : handleLogindef = cockpit.file("/usr/etc/login.defs");
87 3 : }
88 :
89 18 : handleLogindef.watch((logindef) => {
90 18 : if (logindef === null)
91 18 : return;
92 :
93 18 : const minGid = parseInt(logindef.match(/^GID_MIN\s+(\d+)/m)[1]);
94 18 : const maxGid = parseInt(logindef.match(/^GID_MAX\s+(\d+)/m)[1]);
95 18 : const minUid = parseInt(logindef.match(/^UID_MIN\s+(\d+)/m)[1]);
96 18 : const maxUid = parseInt(logindef.match(/^UID_MAX\s+(\d+)/m)[1]);
97 :
98 18 : if (minGid)
99 18 : setMinGid(minGid);
100 18 : if (maxGid)
101 18 : setMaxGid(maxGid);
102 18 : if (minUid)
103 18 : setMinUid(minUid);
104 18 : if (maxUid)
105 18 : setMaxUid(maxUid);
106 18 : });
107 :
108 18 : return [logind_client, handleUtmp, handleShadow, handleLogindef];
109 0 : }, [], null, handles => handles.forEach(handle => handle.close()));
110 :
111 18 : const accountsInfo = useMemo(() => {
112 18 : if (accounts && details)
113 18 : return accounts.map(account => {
114 18 : return Object.assign({}, account, details[account.name]);
115 18 : });
116 : else
117 18 : return [];
118 18 : }, [accounts, details]); // FIXME: We only want to update this when details is updated, not accounts
119 :
120 18 : const groupsExtraInfo = useMemo(() => sortGroups(
121 18 : (groups || []).map(group => {
122 18 : const userlistPrimary = accountsInfo.filter(account => account.gid === group.gid).map(account => account.name);
123 18 : const userlist = group.userlist.filter(el => el !== "");
124 18 : return ({
125 18 : ...group,
126 18 : userlistPrimary,
127 18 : userlist,
128 18 : members: userlist.length + userlistPrimary.length,
129 18 : isAdmin: admins.includes(group.name),
130 18 : isUserCreatedGroup: group.gid >= min_gid && group.gid <= max_gid
131 18 : });
132 18 : })
133 18 : ), [groups, accountsInfo, min_gid, max_gid]);
134 :
135 18 : if (groupsExtraInfo.length == 0 || accountsInfo.length == 0) {
136 18 : return <EmptyStatePanel loading />;
137 13 : } else if (path.length === 0) {
138 13 : return (
139 13 : <AccountsMain
140 13 : accountsInfo={accountsInfo}
141 13 : current_user={current_user_info?.name}
142 3 : groups={groupsExtraInfo || []}
143 13 : isGroupsExpanded={isGroupsExpanded}
144 13 : setIsGroupsExpanded={setIsGroupsExpanded}
145 13 : min_gid={min_gid}
146 13 : max_gid={max_gid}
147 13 : min_uid={min_uid}
148 13 : max_uid={max_uid}
149 13 : shells={shells}
150 13 : />
151 : );
152 7 : } else if (path.length === 1) {
153 11 : const account = accountsInfo?.find(account => account.name === path[0]);
154 12 : if (!account)
155 3 : return <AccountNotFound />;
156 12 : return (
157 12 : <AccountDetails account={account} isLoading={accountsInfo.length === 0} groups={groupsExtraInfo}
158 12 : current_user={current_user_info?.name} shells={shells} />
159 : );
160 3 : } else return null;
161 18 : }
162 :
163 18 : async function getLoginDetails(logind_client) {
164 18 : const details = {};
165 :
166 : // currently logged in
167 18 : try {
168 : // out args: uso (uid, name, logind object)
169 18 : const [users] = await logind_client.call(
170 18 : "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "ListUsers",
171 18 : null, { type: "", flags: "", timeout: 5000 });
172 18 : await Promise.all(users.map(async ([_, name, objpath]) => {
173 18 : const [active] = await logind_client.call(
174 18 : objpath, "org.freedesktop.DBus.Properties", "Get",
175 18 : ["org.freedesktop.login1.User", "State"],
176 18 : { type: "ss", flags: "", timeout: 5000 });
177 18 : if (active.v !== "closing")
178 18 : details[name] = { ...details[name], loggedIn: true };
179 18 : }));
180 3 : } catch (err) {
181 3 : console.warn("Unexpected error when getting logged in accounts", err);
182 3 : }
183 :
184 : // locked password
185 :
186 : // shadow-utils passwd supports an --all flag which is lacking on RHEL and
187 : // stable Fedora releases. Available at least on Fedora since
188 : // shadow-utils-4.14.0-5.fc40 (currently known as rawhide).
189 18 : try {
190 18 : const locked_statuses = await cockpit.spawn(["passwd", "-S", "--all"], { superuser: "require", err: "message", environ: ["LC_ALL=C"] });
191 : // Slice off the last empty line
192 16 : for (const line of locked_statuses.trim().split('\n')) {
193 16 : const name = line.split(" ")[0];
194 16 : const status = line.split(" ")[1];
195 16 : details[name] = { ...details[name], isLocked: status === "L" };
196 16 : }
197 3 : } catch (err) {
198 3 : if (err.message?.includes("bad argument --all")) {
199 : // Fallback for old passwd
200 3 : try {
201 3 : const shadow = await cockpit.file("/etc/shadow", { superuser: "require", err: "message" }).read();
202 3 : for (const line of shadow.split('\n')) {
203 3 : const [name, hash] = line.split(":");
204 3 : if (name && hash)
205 3 : details[name] = { ...details[name], isLocked: hash.startsWith("!") };
206 3 : }
207 3 : } catch (err) {
208 3 : console.warn("Unexpected error when getting locked accounts from /etc/shadow:", err.toString());
209 3 : }
210 3 : } else {
211 5 : console.warn("Unexpected error when getting locked account information:", err.toString());
212 5 : }
213 5 : }
214 :
215 : // last logged in
216 :
217 18 : try {
218 : // merge lastlog2 into details
219 18 : for (const [name, last] of Object.entries(await getLastlog2()))
220 18 : details[name] = { ...details[name], lastLogin: last.time * 1000 };
221 3 : } catch (err) {
222 : // fall back to legacy lastlog
223 3 : try {
224 3 : const out = await cockpit.spawn(["lastlog"], { environ: ["LC_ALL=C"] });
225 0 : await Promise.all(out.split('\n').slice(1, -1).map(async line => {
226 0 : if (line.includes('**Never logged in**'))
227 0 : return;
228 :
229 0 : const splitLine = line.trim().split(/[ \t]+/);
230 0 : const name = splitLine[0];
231 0 : const date_fields = splitLine.slice(-5);
232 : // this is impossible to parse with Date() (e.g. Firefox does not work with all time zones), so call `date` to parse it
233 0 : try {
234 0 : const out = await cockpit.spawn(["date", "+%s", "-d", date_fields.join(' ')],
235 0 : { environ: ["LC_ALL=C"], err: "out" });
236 0 : details[name] = { ...details[name], lastLogin: parseInt(out) * 1000 };
237 0 : } catch (e) {
238 0 : console.warn(`Failed to parse date from lastlog line '${line}': ${e.toString()}`);
239 0 : }
240 0 : }));
241 3 : } catch (ex) {
242 3 : console.warn(`Failed to run lastlog: ${ex.toString()}`);
243 3 : }
244 3 : }
245 :
246 18 : return details;
247 18 : }
248 :
249 18 : function init() {
250 18 : const root = createRoot(document.getElementById("page"));
251 18 : root.render(<AccountsPage />);
252 18 : document.body.removeAttribute("hidden");
253 18 : }
254 :
255 18 : document.addEventListener("DOMContentLoaded", init);
|