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