Line data Source code
1 : /*
2 : * Copyright (C) 2016 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 3 : import cockpit from "cockpit";
7 :
8 3 : import React from "react";
9 : import { Alert, AlertActionCloseButton, AlertGroup } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
10 : import { Badge } from "@patternfly/react-core/dist/esm/components/Badge/index.js";
11 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
12 : import { Divider } from "@patternfly/react-core/dist/esm/components/Divider/index.js";
13 : import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
14 : import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
15 : import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
16 : import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
17 : import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
18 : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
19 : import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
20 : import { ExclamationCircleIcon, ExclamationTriangleIcon, InfoCircleIcon } from "@patternfly/react-icons";
21 : import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
22 : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
23 : import { CodeBlock, CodeBlockCode } from "@patternfly/react-core/dist/esm/components/CodeBlock/index.js";
24 :
25 : import { Modifications } from "cockpit-components-modifications";
26 : import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
27 : import { ListingTable } from "cockpit-components-table.jsx";
28 : import { ListingPanel } from 'cockpit-components-listing-panel.jsx';
29 : import * as timeformat from 'timeformat';
30 :
31 3 : const _ = cockpit.gettext;
32 :
33 : /* Show details for an alert, including possible solutions
34 : * Props correspond to an item in the setroubleshoot dataStore
35 : */
36 3 : class SELinuxEventDetails extends React.Component {
37 0 : runFix(itmIdx, runCommand) {
38 0 : const localId = this.props.details.localId;
39 0 : const analysisId = this.props.details.pluginAnalysis[itmIdx].analysisId;
40 0 : this.props.runFix(localId, analysisId, itmIdx, runCommand);
41 0 : }
42 :
43 2 : render() {
44 1 : if (!this.props.details) {
45 : // details should be requested by default, so we just need to wait for them
46 1 : if (this.props.details === undefined)
47 1 : return <EmptyStatePanel loading title={ _("Waiting for details...") } />;
48 : else
49 1 : return <EmptyStatePanel icon={ExclamationCircleIcon} title={ _("Unable to get alert details.") } />;
50 1 : }
51 :
52 2 : const self = this;
53 2 : const fixEntries = this.props.details.pluginAnalysis.map(function(itm, itmIdx) {
54 2 : let fixit = null;
55 2 : let fixit_command = null;
56 2 : let msg = null;
57 :
58 : /* some plugins like catchall_sebool don't report fixable as they offer multiple solutions;
59 : * we can offer to run a single setsebool command for convenience */
60 2 : let fixable = itm.fixable;
61 2 : if (!fixable && itm.doText && itm.doText.startsWith("setsebool") && itm.doText.indexOf("\n") < 0) {
62 2 : fixable = true;
63 2 : fixit_command = itm.doText;
64 2 : }
65 :
66 2 : if (fixable) {
67 1 : if ((itm.fix) && (itm.fix.plugin == itm.analysisId)) {
68 1 : if (itm.fix.running) {
69 1 : msg = (
70 1 : <div>
71 1 : <Spinner size="sm" className="setroubleshoot-progress-spinner" />
72 1 : <span className="setroubleshoot-progress-message"> { _("Applying solution...") }</span>
73 1 : </div>
74 : );
75 1 : } else {
76 1 : if (itm.fix.success) {
77 1 : msg = (
78 1 : <Alert isInline variant="success" title={ _("Solution applied successfully") }>
79 1 : {itm.fix.result}
80 1 : </Alert>
81 : );
82 1 : } else {
83 1 : msg = (
84 1 : <Alert isInline variant="danger" title={ _("Solution failed") }>
85 1 : {itm.fix.result}
86 1 : </Alert>
87 : );
88 1 : }
89 1 : }
90 1 : }
91 2 : if (!itm.fix) {
92 2 : fixit = (
93 2 : <div className="setroubleshoot-listing-action">
94 2 : <Button variant="secondary" onClick={ self.runFix.bind(self, itmIdx, fixit_command) }>
95 2 : { _("Apply this solution") }
96 2 : </Button>
97 2 : </div>
98 : );
99 2 : }
100 2 : } else {
101 2 : fixit = (
102 2 : <div className="setroubleshoot-listing-action">
103 2 : <span>{ _("Unable to apply this solution automatically") }</span>
104 2 : </div>
105 : );
106 2 : }
107 :
108 2 : function codeBlock(text, key) {
109 2 : return (
110 2 : <CodeBlock key={key} aria-label={_("solution")}>
111 2 : <CodeBlockCode>{text}</CodeBlockCode>
112 2 : </CodeBlock>
113 : );
114 2 : }
115 :
116 2 : let doElement = "";
117 :
118 : // One line usually means one command
119 2 : if (itm.doText && itm.doText.indexOf("\n") < 0)
120 2 : doElement = codeBlock(itm.doText);
121 :
122 : // There can be text with commands. Command always starts on a new line with '#'
123 : // Group subsequent commands into one `<CodeBlock>` element.
124 2 : if (itm.doText && itm.doText.indexOf("\n") >= 0) {
125 2 : const parts = [];
126 2 : const lines = itm.doText.split("\n");
127 2 : let lastCommand = false;
128 2 : lines.forEach(l => {
129 2 : if (l[0] == "#") { // command
130 2 : if (lastCommand) // When appending command remove "# ". Only the first command keeps it and it is removed later on
131 2 : parts[parts.length - 1] += ("\n" + l.substring(2));
132 : else
133 2 : parts.push(l);
134 2 : lastCommand = true;
135 2 : } else {
136 2 : parts.push(l);
137 2 : lastCommand = false;
138 2 : }
139 2 : });
140 2 : doElement = parts.map((p, index) => p[0] == "#"
141 2 : ? codeBlock(p.substring(2), index)
142 2 : : <span key={p}>{p}</span>);
143 2 : }
144 :
145 2 : return (
146 1 : <StackItem key={itm.analysisId + (itm.ifText || "") + (itm.doText || "")}>
147 2 : <div className="selinux-details" data-solution-id={itmIdx}>
148 2 : <div>
149 2 : <div>
150 2 : <span>{itm.ifText}</span>
151 2 : </div>
152 2 : <div>
153 2 : {itm.thenText}
154 2 : </div>
155 2 : <ExpandableSection toggleText={_("solution details")}>
156 2 : {doElement}
157 2 : </ExpandableSection>
158 2 : {msg}
159 2 : </div>
160 2 : {fixit}
161 2 : </div>
162 2 : {itmIdx != self.props.details.pluginAnalysis.length - 1 && <Divider />}
163 2 : </StackItem>
164 : );
165 2 : });
166 2 : return <Stack hasGutter>{fixEntries}</Stack>;
167 2 : }
168 3 : }
169 :
170 : /* Show the audit log events for an alert */
171 0 : const SELinuxEventLog = ({ details }) => {
172 0 : if (!details) {
173 : // details should be requested by default, so we just need to wait for them
174 0 : if (details === undefined)
175 0 : return <EmptyStatePanel loading title={ _("Waiting for details...") } />;
176 : else
177 0 : return <EmptyStatePanel icon={ExclamationCircleIcon} title={ _("Unable to get alert details.") } />;
178 0 : }
179 :
180 0 : const logEntries = details.auditEvent.map((itm, idx) => {
181 : // use the alert id and index in the event log array as the data key for react
182 : // if the log becomes dynamic, the entire log line might need to be considered as the key
183 0 : return <div key={ details.localId + "." + idx }>{itm}</div>;
184 0 : });
185 0 : return <div className="setroubleshoot-log">{logEntries}</div>;
186 0 : };
187 :
188 : /* Component to show a dismissable error, message as child text
189 : * dismissError callback function triggered when the close button is pressed
190 : */
191 3 : class DismissableError extends React.Component {
192 0 : constructor(props) {
193 0 : super(props);
194 0 : this.handleDismissError = this.handleDismissError.bind(this);
195 0 : }
196 :
197 0 : handleDismissError(e) {
198 : // only consider primary mouse button
199 0 : if (!e || e.button !== 0)
200 0 : return;
201 0 : if (this.props.dismissError)
202 0 : this.props.dismissError();
203 0 : e.stopPropagation();
204 0 : }
205 :
206 0 : render() {
207 0 : return (
208 0 : <Alert isInline
209 0 : variant='danger' title={this.props.children}
210 0 : actionClose={<AlertActionCloseButton onClose={this.handleDismissError} />} />
211 : );
212 0 : }
213 3 : }
214 :
215 : /* Component to show selinux status and offer an option to change it
216 : * selinuxStatus status of selinux on the system, properties as defined in selinux-client.js
217 : * selinuxStatusError error message from reading or setting selinux status/mode
218 : * changeSelinuxMode function to use for changing the selinux enforcing mode
219 : * dismissError function to dismiss the error message
220 : */
221 3 : class SELinuxStatus extends React.Component {
222 3 : render() {
223 3 : const errorMessage = this.props.selinuxStatusError
224 1 : ? <DismissableError dismissError={this.props.dismissError}>{this.props.selinuxStatusError}</DismissableError>
225 3 : : null;
226 :
227 3 : if (this.props.selinuxStatus.enabled === undefined) {
228 : // we don't know the current state
229 3 : return (
230 3 : <div>
231 3 : {errorMessage}
232 3 : <h3>{_("SELinux system status is unknown.")}</h3>
233 3 : </div>
234 : );
235 1 : } else if (!this.props.selinuxStatus.enabled) {
236 : // selinux is disabled on the system, not much we can do
237 1 : return (
238 1 : <div>
239 1 : {errorMessage}
240 1 : <h3>{_("SELinux is disabled on the system.")}</h3>
241 1 : </div>
242 : );
243 1 : }
244 3 : const configUnknown = (this.props.selinuxStatus.configEnforcing === undefined);
245 3 : let note = null;
246 3 : if (configUnknown)
247 1 : note = _("The configured state is unknown, it might change on the next boot.");
248 3 : else if (!configUnknown && this.props.selinuxStatus.enforcing !== this.props.selinuxStatus.configEnforcing)
249 2 : note = _("Setting deviates from the configured state and will revert on the next boot.");
250 :
251 : // note = _("Setting deviates from the configured state and will revert on the next boot.");
252 :
253 3 : return (
254 3 : <Stack hasGutter className="selinux-policy-ct">
255 3 : <Flex spaceItems={{ default: 'spaceItemsMd' }} alignItems={{ default: 'alignItemsCenter' }}>
256 3 : <h2>{_("SELinux policy")}</h2>
257 3 : <Switch isChecked={this.props.selinuxStatus.enforcing}
258 3 : label={_("Enforcing")}
259 3 : onChange={this.props.changeSelinuxMode} />
260 3 : </Flex>
261 3 : { note !== null &&
262 2 : <Content component={ContentVariants.p}>
263 2 : <Icon isInline status="info"><InfoCircleIcon /></Icon>
264 2 : { "\n" }
265 2 : { note }
266 2 : </Content>
267 : }
268 3 : {errorMessage}
269 3 : </Stack>
270 : );
271 3 : }
272 3 : }
273 :
274 : /* The listing only shows if we have a connection to the dbus API
275 : * Otherwise we have blank slate: trying to connect, error
276 : * Expected properties:
277 : * connected true if the client is connected to setroubleshoot-server via dbus
278 : * error error message to show (in EmptyState if not connected, as a dismissable alert otherwise
279 : * dismissError callback, triggered for the dismissable error in connected state
280 : * deleteAlert callback, triggered with an alert id as parameter to trigger deletion
281 : * entries setroubleshoot entries
282 : * - runFix function to run fix
283 : * - details fix details as provided by the setroubleshoot client
284 : * - description brief description of the error
285 : * - count how many times (>= 1) this alert occurred
286 : * selinuxStatus status of selinux on the system, properties as defined in selinux-client.js
287 : * selinuxStatusError error message from reading or setting selinux status/mode
288 : * changeSelinuxMode function to use for changing the selinux enforcing mode
289 : * dismissStatusError function that is triggered to dismiss the selinux status error
290 : */
291 3 : export class SETroubleshootPage extends React.Component {
292 3 : constructor(props) {
293 3 : super(props);
294 3 : this.state = { selected: {} };
295 3 : this.handleDismissError = this.handleDismissError.bind(this);
296 3 : this.onSelect = this.onSelect.bind(this);
297 3 : }
298 :
299 0 : handleDismissError(e) {
300 : // only consider primary mouse button
301 0 : if (!e || e.button !== 0)
302 0 : return;
303 0 : if (this.props.dismissError)
304 0 : this.props.dismissError();
305 0 : e.stopPropagation();
306 0 : }
307 :
308 0 : onSelect(_, isSelected, rowId) {
309 0 : this.setState(prevState => ({
310 0 : selected: { ...prevState.selected, [this.props.entries[rowId].key]: isSelected }
311 0 : }));
312 0 : }
313 :
314 3 : render() {
315 : // if selinux is disabled, we only show EmptyState
316 1 : if (this.props.selinuxStatus.enabled === false) {
317 1 : return <EmptyStatePanel icon={ ExclamationCircleIcon } title={ _("SELinux is disabled on the system") } />;
318 1 : }
319 3 : const self = this;
320 3 : const title = _("SELinux access control errors");
321 3 : const emptyCaption = _("No SELinux alerts.");
322 3 : let emptyState;
323 3 : let entries;
324 3 : if (!this.props.connected) {
325 3 : if (this.props.connecting) {
326 3 : emptyState = <EmptyStatePanel paragraph={ _("Connecting to SETroubleshoot daemon...") } loading />;
327 1 : } else {
328 : // if we don't have setroubleshoot-server, be more subtle about saying that
329 1 : emptyState = <EmptyStatePanel icon={ InfoCircleIcon }
330 1 : paragraph={_("Install setroubleshoot-server to troubleshoot SELinux events.")} />;
331 1 : }
332 2 : } else {
333 2 : entries = this.props.entries.map(function(itm, index) {
334 2 : itm.runFix = self.props.runFix;
335 2 : let listingDetail;
336 2 : if (itm.details && 'firstSeen' in itm.details) {
337 2 : if (itm.details.reportCount >= 2) {
338 2 : listingDetail = cockpit.format(_("Occurred between $0 and $1"),
339 2 : timeformat.dateTime(itm.details.firstSeen),
340 2 : timeformat.dateTime(itm.details.lastSeen)
341 2 : );
342 1 : } else {
343 1 : listingDetail = cockpit.format(_("Occurred $0"), timeformat.dateTime(itm.details.firstSeen));
344 1 : }
345 2 : }
346 2 : const tabRenderers = [
347 2 : {
348 2 : name: _("Solutions"),
349 2 : renderer: SELinuxEventDetails,
350 2 : data: itm,
351 2 : },
352 2 : {
353 2 : name: _("Audit log"),
354 2 : renderer: SELinuxEventLog,
355 2 : data: itm,
356 2 : },
357 2 : ];
358 : // if the alert has level "red", it's critical
359 2 : const criticalAlert = (itm.details && 'level' in itm.details && itm.details.level == "red")
360 1 : ? <ExclamationTriangleIcon className="ct-icon-exclamation-triangle pf-v6-c-icon pf-m-lg" />
361 2 : : null;
362 2 : const columns = [
363 2 : { title: criticalAlert },
364 2 : { title: itm.description }
365 2 : ];
366 2 : if (itm.count > 1) {
367 2 : columns.push({ title: <Badge isRead>{itm.count}</Badge>, props: { className: "pf-v6-c-table__action" } });
368 1 : } else {
369 1 : columns.push({ title: <span />, props: { className: "pf-v6-c-table__action" } });
370 1 : }
371 1 : const rowId = itm.details ? itm.details.localId : index;
372 2 : return ({
373 2 : props: { key: rowId, "data-row-id": rowId },
374 1 : selected: self.state.selected[itm.details ? itm.details.localId : index],
375 2 : disableSelection: !itm.details,
376 2 : columns,
377 2 : expandedContent: <ListingPanel tabRenderers={tabRenderers}
378 2 : listingDetail={listingDetail} />
379 2 : });
380 2 : });
381 2 : }
382 3 : let selectedCnt = 0;
383 1 : for (const k in this.state.selected) if (this.state.selected[k]) selectedCnt++;
384 0 : const onDeleteClick = () => {
385 0 : for (const k in this.state.selected)
386 0 : if (this.state.selected[k])
387 0 : this.props.deleteAlert(k).then(() => this.setState(prevState => ({ selected: { ...prevState.selected, [k]: false } })));
388 0 : };
389 3 : const actions = (
390 3 : !emptyState
391 2 : ? <Button className="selinux-alert-dismiss"
392 2 : variant="danger"
393 2 : onClick={onDeleteClick}
394 2 : isDisabled={ !this.props.deleteAlert || !selectedCnt}>
395 1 : {selectedCnt ? cockpit.format(cockpit.ngettext("Dismiss $0 alert", "Dismiss $0 alerts", selectedCnt), selectedCnt) : _("Dismiss selected alerts")}
396 2 : </Button>
397 3 : : null
398 : );
399 3 : const troubleshooting = (
400 3 : <Card isPlain>
401 3 : <CardHeader actions={{ actions }}>
402 3 : <CardTitle component="h2">{title}</CardTitle>
403 3 : </CardHeader>
404 3 : <CardBody className="contains-list">
405 3 : {!emptyState
406 2 : ? <ListingTable aria-label={ title }
407 2 : id="selinux-alerts"
408 2 : onSelect={this.onSelect}
409 2 : gridBreakPoint=''
410 2 : emptyCaption={ emptyCaption }
411 2 : columns={[{ title: _("Alert") }, { title: _("Error message"), header: true }, { title: _("Occurrences") }]}
412 2 : showHeader={false}
413 2 : variant="compact"
414 2 : rows={entries} />
415 3 : : emptyState}
416 3 : </CardBody>
417 3 : </Card>
418 : );
419 :
420 3 : const modifications = (
421 3 : <Modifications
422 3 : title={ _("System modifications") }
423 3 : permitted={ this.props.selinuxStatus.permitted }
424 3 : shell={ "semanage import <<EOF\n" + this.props.selinuxStatus.shell.trim() + "\nEOF" }
425 3 : ansible={ this.props.selinuxStatus.ansible }
426 3 : entries={ this.props.selinuxStatus.modifications }
427 1 : failed={this.props.selinuxStatus.failed ? _("Error running semanage to discover system modifications") : null}
428 3 : />
429 : );
430 :
431 3 : let errorMessage;
432 1 : if (this.props.error) {
433 1 : errorMessage = (
434 1 : <AlertGroup isToast>
435 1 : <Alert
436 1 : isLiveRegion
437 1 : variant='danger' title={this.props.error}
438 1 : actionClose={<AlertActionCloseButton onClose={this.handleDismissError} />} />
439 1 : </AlertGroup>
440 : );
441 1 : }
442 :
443 3 : return (
444 3 : <>
445 3 : {errorMessage}
446 3 : <Page className="pf-m-no-sidebar">
447 3 : <PageSection hasBodyWrapper={false} padding={{ default: "padding" }}>
448 3 : <SELinuxStatus
449 3 : selinuxStatus={this.props.selinuxStatus}
450 3 : selinuxStatusError={this.props.selinuxStatusError}
451 3 : changeSelinuxMode={this.props.changeSelinuxMode}
452 3 : dismissError={this.props.dismissStatusError}
453 3 : />
454 3 : </PageSection>
455 3 : <PageSection hasBodyWrapper={false}>
456 3 : <Stack hasGutter>
457 3 : <StackItem>{modifications}</StackItem>
458 3 : <StackItem>{troubleshooting}</StackItem>
459 3 : </Stack>
460 3 : </PageSection>
461 3 : </Page>
462 3 : </>
463 : );
464 3 : }
465 3 : }
|