LCOV - code coverage report
Current view: top level - pkg/users - users.js Coverage Total Hit
Test: cockpit Lines: 91.8 % 183 168
Test Date: 2026-07-17 12:03:54

            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           19 : import React, { useMemo, useState } from 'react';
      10           19 : import { createRoot } from 'react-dom/client';
      11              : 
      12              : import { debounce } from 'throttle-debounce';
      13              : 
      14           19 : 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           19 : superuser.reload_page_on_change();
      28              : 
      29           19 : export const admins = ['sudo', 'root', 'wheel'];
      30           19 : const sortGroups = groups => {
      31           19 :     return groups.sort((a, b) => {
      32           19 :         if (a.isAdmin)
      33           19 :             return -1;
      34           19 :         if (b.isAdmin)
      35           19 :             return 1;
      36           19 :         if (a.members === b.members)
      37           19 :             return a.name.localeCompare(b.name);
      38              :         else
      39           19 :             return b.members - a.members;
      40           19 :     });
      41           19 : };
      42              : 
      43           19 : function AccountsPage() {
      44           19 :     const [isGroupsExpanded, setIsGroupsExpanded] = useState(false);
      45           19 :     const { path } = usePageLocation();
      46           19 :     const accounts = useFile("/etc/passwd", { syntax: etc_passwd_syntax });
      47           19 :     const groups = useFile("/etc/group", { syntax: etc_group_syntax });
      48           19 :     const shells = useFile("/etc/shells", { syntax: etc_shells_syntax });
      49           19 :     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           19 :     const [min_gid, setMinGid] = useState(500);
      54           19 :     const [max_gid, setMaxGid] = useState(60000);
      55           19 :     const [min_uid, setMinUid] = useState(500);
      56           19 :     const [max_uid, setMaxUid] = useState(60000);
      57           19 :     const [details, setDetails] = useState(null);
      58              : 
      59           19 :     useInit(async () => {
      60           19 :         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           19 :         logind_client.subscribe({
      71           19 :             interface: "org.freedesktop.login1.Manager",
      72           19 :             path: "/org/freedesktop/login1",
      73           19 :         }, debouncedGetLoginDetails);
      74              : 
      75           19 :         let handleUtmp;
      76              : 
      77              :         // Watch /etc/shadow to register lock/unlock/expire changes; but avoid reading it, it's sensitive data
      78           19 :         const handleShadow = cockpit.file("/etc/shadow", { superuser: "try" });
      79           18 :         handleShadow.watch(() => debouncedGetLoginDetails(), { read: false });
      80              : 
      81           19 :         let handleLogindef;
      82           19 :         try {
      83           19 :             await fsinfo("/etc/login.defs", []);
      84           19 :             handleLogindef = cockpit.file("/etc/login.defs");
      85            4 :         } catch (ex) {
      86            4 :             handleLogindef = cockpit.file("/usr/etc/login.defs");
      87            4 :         }
      88              : 
      89           19 :         handleLogindef.watch((logindef) => {
      90           19 :             if (logindef === null)
      91           19 :                 return;
      92              : 
      93           19 :             const minGid = parseInt(logindef.match(/^GID_MIN\s+(\d+)/m)[1]);
      94           19 :             const maxGid = parseInt(logindef.match(/^GID_MAX\s+(\d+)/m)[1]);
      95           19 :             const minUid = parseInt(logindef.match(/^UID_MIN\s+(\d+)/m)[1]);
      96           19 :             const maxUid = parseInt(logindef.match(/^UID_MAX\s+(\d+)/m)[1]);
      97              : 
      98           19 :             if (minGid)
      99           19 :                 setMinGid(minGid);
     100           19 :             if (maxGid)
     101           19 :                 setMaxGid(maxGid);
     102           19 :             if (minUid)
     103           19 :                 setMinUid(minUid);
     104           19 :             if (maxUid)
     105           19 :                 setMaxUid(maxUid);
     106           19 :         });
     107              : 
     108           19 :         return [logind_client, handleUtmp, handleShadow, handleLogindef];
     109            0 :     }, [], null, handles => handles.forEach(handle => handle.close()));
     110              : 
     111           19 :     const accountsInfo = useMemo(() => {
     112           19 :         if (accounts && details)
     113           18 :             return accounts.map(account => {
     114           18 :                 return Object.assign({}, account, details[account.name]);
     115           18 :             });
     116              :         else
     117           19 :             return [];
     118           19 :     }, [accounts, details]); // FIXME: We only want to update this when details is updated, not accounts
     119              : 
     120           19 :     const groupsExtraInfo = useMemo(() => sortGroups(
     121           19 :         (groups || []).map(group => {
     122           18 :             const userlistPrimary = accountsInfo.filter(account => account.gid === group.gid).map(account => account.name);
     123           19 :             const userlist = group.userlist.filter(el => el !== "");
     124           19 :             return ({
     125           19 :                 ...group,
     126           19 :                 userlistPrimary,
     127           19 :                 userlist,
     128           19 :                 members: userlist.length + userlistPrimary.length,
     129           19 :                 isAdmin: admins.includes(group.name),
     130           19 :                 isUserCreatedGroup: group.gid >= min_gid && group.gid <= max_gid
     131           19 :             });
     132           19 :         })
     133           19 :     ), [groups, accountsInfo, min_gid, max_gid]);
     134              : 
     135           19 :     if (groupsExtraInfo.length == 0 || accountsInfo.length == 0) {
     136           19 :         return <EmptyStatePanel loading />;
     137           15 :     } else if (path.length === 0) {
     138           15 :         return (
     139           15 :             <AccountsMain
     140           15 :                 accountsInfo={accountsInfo}
     141           15 :                 current_user={current_user_info?.name}
     142            4 :                 groups={groupsExtraInfo || []}
     143           15 :                 isGroupsExpanded={isGroupsExpanded}
     144           15 :                 setIsGroupsExpanded={setIsGroupsExpanded}
     145           15 :                 min_gid={min_gid}
     146           15 :                 max_gid={max_gid}
     147           15 :                 min_uid={min_uid}
     148           15 :                 max_uid={max_uid}
     149           15 :                 shells={shells}
     150           15 :             />
     151              :         );
     152            9 :     } else if (path.length === 1) {
     153           11 :         const account = accountsInfo?.find(account => account.name === path[0]);
     154           13 :         if (!account)
     155            4 :             return <AccountNotFound />;
     156           13 :         return (
     157           13 :             <AccountDetails account={account} isLoading={accountsInfo.length === 0} groups={groupsExtraInfo}
     158           13 :                 current_user={current_user_info?.name} shells={shells} />
     159              :         );
     160            4 :     } else return null;
     161           19 : }
     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           19 : function init() {
     250           19 :     const root = createRoot(document.getElementById("page"));
     251           19 :     root.render(<AccountsPage />);
     252           19 :     document.body.removeAttribute("hidden");
     253           19 : }
     254              : 
     255           19 : document.addEventListener("DOMContentLoaded", init);
        

Generated by: LCOV version 2.0-1