Line data Source code
1 : /*
2 : * Copyright (C) 2018 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 : import '../lib/patternfly/patternfly-6-cockpit.scss';
7 : import 'cockpit-dark-theme'; // once per page
8 1 : import cockpit from "cockpit";
9 1 : import React from 'react';
10 1 : import { createRoot } from "react-dom/client";
11 : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
12 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
13 : import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
14 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
15 : import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
16 : import { DataList, DataListCell, DataListCheck, DataListItem, DataListItemCells, DataListItemRow } from "@patternfly/react-core/dist/esm/components/DataList/index.js";
17 : import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
18 : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
19 : import { Form, FormGroup, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form/index.js";
20 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js";
21 : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
22 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
23 : import { Title } from "@patternfly/react-core/dist/esm/components/Title/index.js";
24 : import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js";
25 : import { Page, PageBreadcrumb, PageSection, } from "@patternfly/react-core/dist/esm/components/Page/index.js";
26 : import {
27 : Modal, ModalBody, ModalFooter, ModalHeader
28 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
29 : import { ExclamationCircleIcon } from '@patternfly/react-icons';
30 :
31 : import firewall from "./firewall-client.js";
32 : import { FormHelper } from "cockpit-components-form-helper";
33 : import { ListingTable } from 'cockpit-components-table.jsx';
34 : import { ModalError } from "cockpit-components-inline-notification.jsx";
35 : import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
36 : import { FirewallSwitch } from "./firewall-switch.jsx";
37 :
38 : import { superuser } from "superuser";
39 : import { WithDialogs, DialogsContext } from "dialogs.jsx";
40 :
41 : import { KebabDropdown } from "cockpit-components-dropdown";
42 :
43 : import "./networking.scss";
44 :
45 1 : const _ = cockpit.gettext;
46 :
47 1 : superuser.reload_page_on_change();
48 :
49 1 : const upperCaseFirstLetter = text => text[0].toUpperCase() + text.slice(1);
50 :
51 1 : const DeleteDropdown = ({ items }) => {
52 1 : const dropdown_items = items.map(item => <DropdownItem key={item.text}
53 0 : className={item.danger ? "pf-m-danger" : ""}
54 1 : aria-label={item.ariaLabel}
55 1 : onClick={item.handleClick}>
56 1 : {item.text}
57 1 : </DropdownItem>);
58 :
59 1 : return <KebabDropdown dropdownItems={dropdown_items} />;
60 1 : };
61 :
62 1 : function serviceRow(props) {
63 1 : let tcp = props.service.ports.filter(p => p.protocol.toUpperCase() == 'TCP');
64 1 : let udp = props.service.ports.filter(p => p.protocol.toUpperCase() == 'UDP');
65 :
66 0 : for (const s of props.service.includes) {
67 0 : if (firewall.services[s]) {
68 0 : tcp = tcp.concat(firewall.services[s].ports.filter(p => p.protocol.toUpperCase() == 'TCP'));
69 0 : udp = udp.concat(firewall.services[s].ports.filter(p => p.protocol.toUpperCase() == 'UDP'));
70 0 : }
71 0 : }
72 :
73 0 : function onRemoveService(event) {
74 0 : props.onRemoveService(props.service.id);
75 0 : event.stopPropagation();
76 0 : }
77 :
78 0 : function onEditService(event) {
79 0 : props.onEditService(props.service.id);
80 0 : event.stopPropagation();
81 0 : }
82 :
83 1 : const columns = [
84 1 : {
85 1 : title: props.service.id, header: true
86 1 : },
87 1 : {
88 1 : title: <div key={props.service.id + "tcp"}>
89 1 : { tcp.map(p => p.port).join(', ') }
90 1 : </div>
91 1 : },
92 1 : {
93 1 : title: <div key={props.service.id + "udp"}>
94 1 : { udp.map(p => p.port).join(', ') }
95 1 : </div>
96 1 : },
97 1 : ];
98 :
99 1 : if (!props.readonly) {
100 : // Only allow editing manually created services - no name is a decent (and only) indicator
101 1 : const items = [];
102 1 : if (!props.service.name)
103 0 : items.push({ text: _("Edit"), ariaLabel: cockpit.format(_("Edit service $0"), props.service.id), handleClick: onEditService });
104 :
105 1 : items.push({ text: _("Delete"), danger: true, ariaLabel: cockpit.format(_("Remove service $0"), props.service.id), handleClick: onRemoveService });
106 :
107 1 : columns.push({
108 1 : title: <DeleteDropdown items={items} />
109 1 : });
110 1 : }
111 :
112 1 : let description;
113 1 : let includes;
114 1 : if (props.service.description)
115 1 : description = <p>{props.service.description}</p>;
116 :
117 0 : if (props.service.includes.length > 0) {
118 0 : includes = <>
119 0 : <h5>Included Services</h5>
120 0 : <ul>{props.service.includes.map(s => {
121 0 : const service = firewall.services[s];
122 0 : if (service && service.description)
123 0 : return <li key={service.id}><strong>{service.id}</strong>: {service.description}</li>;
124 : else
125 0 : return null;
126 0 : })} </ul></>;
127 0 : }
128 :
129 1 : return ({
130 1 : props: { key: props.service.id, 'data-row-id': props.service.id },
131 1 : columns,
132 1 : hasPadding: true,
133 1 : expandedContent: <>{description}{includes}</>,
134 1 : });
135 1 : }
136 :
137 0 : function portRow(props) {
138 0 : function onRemovePort(event) {
139 0 : props.onRemovePort(props.port);
140 0 : event.stopPropagation();
141 0 : }
142 :
143 0 : const columns = [
144 0 : {
145 0 : title: <i key={props.key + "-additional-port"}>{ _("Additional port") }</i>
146 0 : },
147 :
148 0 : { title: props.port.protocol === "tcp" ? props.port.port : "" },
149 0 : { title: props.port.protocol === "udp" ? props.port.port : "" },
150 :
151 0 : { title: <DeleteDropdown items={[{ text: _("Delete"), danger: true, ariaLabel: "Remove additional port", handleClick: onRemovePort }]} /> }
152 0 : ];
153 0 : return ({
154 0 : props: { key: props.key + "-port", 'data-row-id': props.key + "-port" },
155 0 : columns
156 0 : });
157 0 : }
158 :
159 1 : function ZoneSection(props) {
160 0 : function onRemoveZone(event) {
161 0 : event.stopPropagation();
162 0 : props.onRemoveZone(props.zone.id);
163 0 : }
164 :
165 1 : const deleteButton = (<DeleteDropdown items={[{ text: _("Delete"), danger: true, ariaLabel: cockpit.format(_("Remove zone $0"), props.zone.id), handleClick: onRemoveZone }]} id={`dropdown-${props.zone.id}`} />);
166 1 : const addServiceAction = (
167 0 : <Button variant="primary" onClick={() => props.openServicesDialog(props.zone.id, props.zone.id)} className="add-services-button" aria-label={cockpit.format(_("Add services to zone $0"), props.zone.id)}>
168 1 : {_("Add services")}
169 1 : </Button>
170 : );
171 :
172 1 : const actions = !firewall.readonly && <div className="zone-section-buttons">{addServiceAction}{deleteButton}</div>;
173 :
174 : /** @type {import('cockpit-components-table.jsx').ListingTableColumnProps[]} */
175 1 : const listingTableColumns = [
176 1 : {
177 1 : title: _("Service"),
178 1 : props: {
179 1 : width: 40,
180 1 : }
181 1 : },
182 1 : {
183 1 : title: _("TCP"),
184 1 : props: {
185 1 : width: 30
186 1 : }
187 1 : },
188 1 : {
189 1 : title: _("UDP"),
190 1 : props: {
191 1 : width: 30
192 1 : }
193 1 : },
194 1 : {
195 1 : title: "",
196 1 : props: {
197 1 : width: 10,
198 1 : screenReaderText: _("Actions")
199 1 : }
200 1 : }
201 1 : ];
202 :
203 1 : return <Card isPlain className="zone-section" data-id={props.zone.id}>
204 1 : <CardHeader actions={{ actions }} className="zone-section-heading">
205 1 : <Flex alignItems={{ default: 'alignSelfBaseline' }} spaceItems={{ default: 'spaceItemsXl' }}>
206 1 : <CardTitle component="h2">
207 1 : { cockpit.format(_("$0 zone"), upperCaseFirstLetter(props.zone.name || props.zone.id)) }
208 1 : </CardTitle>
209 1 : <Flex>
210 1 : { props.zone.interfaces.length > 0 &&
211 1 : <span>
212 1 : <strong>{cockpit.ngettext("Interface", "Interfaces", props.zone.interfaces.length)}</strong> {props.zone.interfaces.join(", ")}
213 1 : </span>
214 : }
215 1 : <span>
216 0 : <strong>{_("Allowed addresses")}</strong> {props.zone.source.length ? props.zone.source.join(", ") : _("Entire subnet")}
217 1 : </span>
218 1 : </Flex>
219 1 : </Flex>
220 1 : </CardHeader>
221 1 : {(props.zone.services.length > 0 || props.zone.ports.length > 0) &&
222 1 : <CardBody className="contains-list">
223 1 : <ListingTable columns={listingTableColumns}
224 1 : id={props.zone.id}
225 1 : aria-label={props.zone.id}
226 1 : variant="compact"
227 1 : emptyCaption={_("There are no active services in this zone")}
228 1 : rows={
229 1 : props.zone.services.map(s => {
230 1 : if (s in firewall.services) {
231 1 : return serviceRow({
232 1 : key: firewall.services[s].id,
233 1 : service: firewall.services[s],
234 0 : onRemoveService: service => props.onRemoveService(props.zone.id, service),
235 0 : onEditService: service => props.onEditService(props.zone, firewall.services[service]),
236 1 : readonly: firewall.readonly,
237 1 : });
238 0 : } else {
239 0 : return null;
240 0 : }
241 1 : }).concat(
242 1 : props.zone.ports.length > 0
243 0 : ? props.zone.ports.map(p => {
244 0 : return portRow({
245 0 : key: `${props.zone.id}-${p.port}-${p.protocol}`,
246 0 : zone: props.zone,
247 0 : port: p,
248 0 : onRemovePort: port => props.onRemovePort(props.zone.id, port.port, port.protocol),
249 0 : readonly: firewall.readonly
250 0 : });
251 0 : })
252 1 : : [])
253 1 : .filter(Boolean)}
254 :
255 1 : />
256 1 : </CardBody>}
257 1 : </Card>;
258 1 : }
259 :
260 1 : class SearchInput extends React.Component {
261 0 : constructor(props) {
262 0 : super(props);
263 0 : this.onValueChanged = this.onValueChanged.bind(this);
264 0 : this.state = { value: props.value || "" };
265 0 : }
266 :
267 0 : onValueChanged(value) {
268 0 : this.setState({ value });
269 :
270 0 : if (this.timer)
271 0 : window.clearTimeout(this.timer);
272 :
273 0 : this.timer = window.setTimeout(() => {
274 0 : this.props.onChange(value);
275 0 : this.timer = null;
276 0 : }, 300);
277 0 : }
278 :
279 0 : render() {
280 0 : return (
281 0 : <Toolbar className="filter-services-toolbar">
282 0 : <ToolbarContent>
283 0 : <ToolbarItem variant="label">
284 0 : {_("Filter services")}
285 0 : </ToolbarItem>
286 0 : <ToolbarItem>
287 0 : <TextInput type="search"
288 0 : id={this.props.id}
289 0 : onChange={(_event, value) => this.onValueChanged(value)}
290 0 : value={this.state.value}
291 0 : />
292 0 : </ToolbarItem>
293 0 : </ToolbarContent>
294 0 : </Toolbar>
295 : );
296 0 : }
297 1 : }
298 :
299 0 : const renderPorts = service => {
300 0 : const tcpPorts = [];
301 0 : const udpPorts = [];
302 0 : function addPorts(ports) {
303 0 : for (const port of ports) {
304 0 : if (port.protocol === "tcp")
305 0 : tcpPorts.push(port.port);
306 : else
307 0 : udpPorts.push(port.port);
308 0 : }
309 0 : }
310 0 : addPorts(service.ports);
311 0 : for (const s of service.includes)
312 0 : addPorts(firewall.services[s].ports);
313 :
314 0 : return (
315 0 : <div className="service-list-item-text">
316 0 : { tcpPorts.length > 0 && <span className="service-ports tcp"><strong>TCP: </strong>{ tcpPorts.join(', ') }</span> }
317 0 : { udpPorts.length > 0 && <span className="service-ports udp"><strong>UDP: </strong>{ udpPorts.join(', ') }</span> }
318 0 : </div>
319 : );
320 0 : };
321 :
322 1 : class AddEditServicesModal extends React.Component {
323 1 : static contextType = DialogsContext;
324 :
325 0 : constructor(props) {
326 0 : super(props);
327 :
328 0 : this.state = {
329 0 : services: null,
330 0 : selected: new Set(),
331 0 : filter: "",
332 0 : custom: !!props.custom_id,
333 0 : generate_custom_id: !props.custom_id,
334 0 : tcp_error: "",
335 0 : udp_error: "",
336 0 : avail_services: null,
337 0 : custom_id: props.custom_id || "",
338 0 : custom_description: props.custom_description || "",
339 0 : custom_tcp_ports: props.custom_tcp_ports || [],
340 0 : custom_udp_ports: props.custom_udp_ports || [],
341 0 : custom_tcp_value: props.custom_tcp_value || "",
342 0 : custom_udp_value: props.custom_udp_value || "",
343 0 : dialogError: null,
344 0 : dialogErrorDetail: null,
345 0 : };
346 0 : this.save = this.save.bind(this);
347 0 : this.edit = this.edit.bind(this);
348 0 : this.checkNullValues = this.checkNullValues.bind(this);
349 0 : this.onFilterChanged = this.onFilterChanged.bind(this);
350 0 : this.onToggleService = this.onToggleService.bind(this);
351 0 : this.setId = this.setId.bind(this);
352 0 : this.setDescription = this.setDescription.bind(this);
353 0 : this.getName = this.getName.bind(this);
354 0 : this.validate = this.validate.bind(this);
355 0 : this.createPorts = this.createPorts.bind(this);
356 0 : this.parseServices = this.parseServices.bind(this);
357 0 : this.onToggleType = this.onToggleType.bind(this);
358 0 : this.getCustomId = this.getCustomId.bind(this);
359 0 : }
360 :
361 0 : createPorts() {
362 0 : const ret = [];
363 0 : this.state.custom_tcp_ports.forEach(port => ret.push([port, 'tcp']));
364 0 : this.state.custom_udp_ports.forEach(port => ret.push([port, 'udp']));
365 0 : return ret;
366 0 : }
367 :
368 0 : getCustomId() {
369 0 : return "custom--" + (
370 0 : this.state.custom_tcp_ports.map(port => this.getName(port, "tcp"))
371 0 : .concat(this.state.custom_udp_ports.map(port => this.getName(port, "udp")))
372 0 : .join('-')
373 : );
374 0 : }
375 :
376 0 : checkNullValues() {
377 0 : return (!this.state.custom_tcp_value && !this.state.custom_udp_value);
378 0 : }
379 :
380 0 : edit(event) {
381 0 : const Dialogs = this.context;
382 0 : firewall.editService(this.props.custom_id, this.createPorts(), this.state.custom_description)
383 0 : .then(() => Dialogs.close())
384 0 : .catch(error => {
385 0 : this.setState({
386 0 : dialogError: _("Failed to edit service"),
387 0 : dialogErrorDetail: error.name + ": " + error.message,
388 0 : });
389 0 : });
390 :
391 0 : if (event)
392 0 : event.preventDefault();
393 0 : return false;
394 0 : }
395 :
396 0 : save(event) {
397 0 : const Dialogs = this.context;
398 0 : let p;
399 0 : if (this.state.custom) {
400 0 : const custom_id = this.state.custom_id === "" ? this.getCustomId() : this.state.custom_id;
401 0 : p = firewall.createService(custom_id, this.createPorts(), this.props.zoneId, this.state.custom_description);
402 0 : } else {
403 0 : p = firewall.addServices(this.props.zoneId, [...this.state.selected]);
404 0 : }
405 0 : p.then(() => Dialogs.close())
406 0 : .catch(error => {
407 0 : this.setState(prevState => ({
408 0 : dialogError: prevState.custom ? _("Failed to add port") : _("Failed to add service"),
409 0 : dialogErrorDetail: error.name + ": " + error.message,
410 0 : }));
411 0 : });
412 :
413 0 : if (event)
414 0 : event.preventDefault();
415 0 : return false;
416 0 : }
417 :
418 0 : onToggleService(event, serviceId) {
419 0 : const service = serviceId;
420 0 : const enabled = event.target.checked;
421 :
422 0 : this.setState(oldState => {
423 0 : const selected = new Set(oldState.selected);
424 :
425 0 : if (enabled)
426 0 : selected.add(service);
427 : else
428 0 : selected.delete(service);
429 :
430 0 : return {
431 0 : selected
432 0 : };
433 0 : });
434 0 : }
435 :
436 : /* Create list of services from /etc/services type file
437 : *
438 : * Return dictionary of services:
439 : * - key => port number or port alias (80/http)
440 : * - item => dictionary with 3 compulsory items:
441 : * - name => port alias (http)
442 : * - port => port number (80)
443 : * - type => list of types (tcp/udp...)
444 : * - description => _may be not present_ (Web Server)
445 : */
446 0 : parseServices(content) {
447 0 : if (!content) {
448 0 : console.warn("Couldn't read /etc/services");
449 0 : return [];
450 0 : }
451 :
452 0 : const ret = {};
453 0 : content.split('\n').forEach(line => {
454 0 : if (!line || line.startsWith("#"))
455 0 : return;
456 0 : const m = line.match(/^(\S+)\s+(\d+)\/(\S+).*?(#(.*))?$/);
457 0 : const new_port = { name: m[1], port: m[2], type: [m[3]] };
458 0 : if (m.length > 5 && m[5])
459 0 : new_port.description = m[5].trim();
460 0 : if (ret[m[1]])
461 0 : ret[m[1]].type.push(new_port.type[0]);
462 : else
463 0 : ret[m[1]] = new_port;
464 0 : if (ret[m[2]])
465 0 : ret[m[2]].type.push(new_port.type[0]);
466 : else
467 0 : ret[m[2]] = new_port;
468 0 : });
469 0 : return ret;
470 0 : }
471 :
472 0 : setId(value) {
473 0 : this.setState({
474 0 : custom_id: value,
475 0 : generate_custom_id: value.length === 0,
476 0 : });
477 0 : }
478 :
479 0 : setDescription(value) {
480 0 : this.setState({
481 0 : custom_description: value
482 0 : });
483 0 : }
484 :
485 0 : getName(port, type) {
486 0 : const known = this.state.avail_services[port];
487 0 : if (known && known.type.includes(type))
488 0 : return known.name;
489 : else
490 0 : return port;
491 0 : }
492 :
493 0 : getPortNumber(port, type, avail) {
494 0 : if (!avail) {
495 0 : const num_p = Number(port);
496 0 : if (isNaN(num_p))
497 0 : return [0, _("Unknown service name")];
498 0 : else if (num_p <= 0 || num_p > 65535)
499 0 : return [0, _("Invalid port number")];
500 : else
501 0 : return [port, ""];
502 0 : } else {
503 0 : return [avail.port, ""];
504 0 : }
505 0 : }
506 :
507 0 : validate(event, value) {
508 0 : let error = "";
509 0 : let targets = ['tcp', 'custom_tcp_ports', 'tcp_error', 'custom_tcp_value'];
510 0 : if (event.target.id === "udp-ports")
511 0 : targets = ['udp', 'custom_udp_ports', 'udp_error', 'custom_udp_value'];
512 0 : const new_ports = [];
513 0 : const event_id = event.target.id;
514 :
515 0 : this.setState(oldState => {
516 0 : const ports = value.split(',');
517 0 : ports.forEach((port) => {
518 0 : port = port.trim();
519 0 : if (!port)
520 0 : return;
521 0 : let ports;
522 0 : if (port.indexOf("-") > -1) {
523 0 : ports = port.split("-");
524 0 : if (ports.length != 2) {
525 0 : error = _("Invalid range");
526 0 : return;
527 0 : }
528 0 : [ports[0], error] = this.getPortNumber(ports[0], targets[0], oldState.avail_services[ports[0]]);
529 0 : if (!error) {
530 0 : [ports[1], error] = this.getPortNumber(ports[1], targets[0], oldState.avail_services[ports[1]]);
531 0 : if (!error) {
532 0 : if (Number(ports[0]) >= Number(ports[1]))
533 0 : error = _("Range must be strictly ordered");
534 : else
535 0 : new_ports.push(ports[0] + "-" + ports[1]);
536 0 : }
537 0 : }
538 0 : } else {
539 0 : [ports, error] = this.getPortNumber(port, targets[0], oldState.avail_services[port]);
540 0 : if (!error)
541 0 : new_ports.push(ports);
542 0 : }
543 0 : });
544 0 : const newState = {
545 0 : [targets[1]]: new_ports,
546 0 : [targets[2]]: error,
547 0 : [targets[3]]: value
548 0 : };
549 :
550 0 : let all_ports;
551 0 : if (event_id === "udp-ports") {
552 0 : const old_ports = oldState.custom_tcp_ports.map(port => this.getName(port, "tcp"));
553 0 : all_ports = old_ports.concat(new_ports.map(port => this.getName(port, "udp")));
554 0 : } else {
555 0 : const old_ports = oldState.custom_udp_ports.map(port => this.getName(port, "udp"));
556 0 : all_ports = new_ports.map(port => this.getName(port, "tcp")).concat(old_ports);
557 0 : }
558 :
559 0 : if (oldState.generate_custom_id) {
560 0 : if (all_ports.length > 0)
561 0 : newState.custom_id = "custom--" + all_ports.join('-');
562 : else
563 0 : newState.custom_id = "";
564 0 : }
565 :
566 0 : return newState;
567 0 : });
568 0 : }
569 :
570 0 : onToggleType(event) {
571 0 : this.setState({
572 0 : custom: event.target.value === "ports"
573 0 : });
574 0 : }
575 :
576 0 : componentDidMount() {
577 0 : firewall.getAvailableServices()
578 0 : .then(services => this.setState({ services }));
579 0 : cockpit.file('/etc/services').read()
580 0 : .then(content => this.setState({
581 0 : avail_services: this.parseServices(content)
582 0 : }));
583 0 : }
584 :
585 0 : onFilterChanged(value) {
586 0 : this.setState({ filter: value.toLowerCase() });
587 0 : }
588 :
589 0 : render() {
590 0 : const Dialogs = this.context;
591 0 : let services;
592 0 : if (this.state.filter && this.state.services && !isNaN(this.state.filter))
593 0 : services = this.state.services.filter(s => {
594 0 : for (const port of s.ports)
595 0 : if (port.port === this.state.filter)
596 0 : return true;
597 0 : return false;
598 0 : });
599 0 : else if (this.state.filter && this.state.services)
600 0 : services = this.state.services.filter(s => s.id.indexOf(this.state.filter) > -1);
601 : else
602 0 : services = this.state.services;
603 :
604 : // hide services which have been enabled in the zone
605 0 : if (services)
606 0 : services = services.filter(s => firewall.zones[this.props.zoneId].services.indexOf(s.id) === -1);
607 :
608 0 : let addText = "";
609 0 : let titleText = "";
610 0 : if (this.props.custom_id) {
611 0 : addText = _("Edit service");
612 0 : titleText = cockpit.format(_("Edit custom service in $0 zone"), this.props.zoneName);
613 0 : } else {
614 0 : addText = this.state.custom ? _("Add ports") : _("Add services");
615 0 : titleText = this.state.custom ? cockpit.format(_("Add ports to $0 zone"), this.props.zoneName) : cockpit.format(_("Add services to $0 zone"), this.props.zoneName);
616 0 : }
617 :
618 0 : return (
619 0 : <Modal id="add-services-dialog" isOpen
620 0 : position="top" variant="medium"
621 0 : onClose={Dialogs.close}
622 : >
623 0 : <ModalHeader title={titleText} />
624 0 : <ModalBody>
625 0 : <Form isHorizontal onSubmit={this.props.custom_id ? this.edit : this.save}>
626 : {
627 0 : this.state.dialogError && <ModalError dialogError={this.state.dialogError} dialogErrorDetail={this.state.dialogErrorDetail} />
628 : }
629 0 : { !!this.props.custom_id ||
630 0 : <FormGroup className="add-services-dialog-type" isInline>
631 0 : <Radio name="type"
632 0 : id="add-services-dialog--services"
633 0 : value="services"
634 0 : isChecked={!this.state.custom}
635 0 : onChange={this.onToggleType}
636 0 : label={_("Services")} />
637 0 : <Radio name="type"
638 0 : id="add-services-dialog--ports"
639 0 : value="ports"
640 0 : isChecked={this.state.custom}
641 0 : onChange={this.onToggleType}
642 0 : isDisabled={this.state.avail_services == null}
643 0 : label={_("Custom ports")} />
644 0 : </FormGroup>
645 : }
646 0 : { this.state.custom ||
647 0 : <div>
648 0 : { services
649 : ? (
650 0 : <>
651 0 : <SearchInput id="filter-services-input"
652 0 : value={this.state.filter}
653 0 : onChange={this.onFilterChanged} />
654 0 : <DataList className="service-list" isCompact>
655 0 : {services.map(s => (
656 0 : <DataListItem key={s.id} aria-labelledby={s.id}>
657 0 : <DataListItemRow>
658 0 : <DataListCheck aria-labelledby={s.id}
659 0 : isChecked={this.state.selected.has(s.id)}
660 0 : onChange={(event, value) => this.onToggleService(event, s.id)}
661 0 : id={"firewall-service-" + s.id}
662 0 : name={s.id + "-checkbox"} />
663 0 : <DataListItemCells
664 0 : dataListCells={[
665 0 : <DataListCell key="service-list-item">
666 0 : <label htmlFor={"firewall-service-" + s.id}
667 0 : className="service-list-iteam-heading">
668 0 : {s.id}
669 0 : </label>
670 0 : {renderPorts(s)}
671 0 : </DataListCell>,
672 0 : ]} />
673 0 : </DataListItemRow>
674 0 : </DataListItem>
675 0 : ))}
676 0 : </DataList>
677 0 : </>
678 : )
679 : : (
680 0 : <EmptyStatePanel loading />
681 : )}
682 0 : </div>
683 : }
684 0 : { !this.state.custom ||
685 0 : <>
686 0 : <FormGroup label="TCP">
687 0 : <TextInput id="tcp-ports" type="text" onChange={this.validate}
688 0 : validated={this.state.tcp_error ? "error" : "default"}
689 0 : isDisabled={this.state.avail_services == null}
690 0 : value={this.state.custom_tcp_value}
691 0 : placeholder={_("Example: 22,ssh,8080,5900-5910")} />
692 0 : <FormHelper helperTextInvalid={this.state.tcp_error} helperText={_("Comma-separated ports, ranges, and services are accepted")} />
693 0 : </FormGroup>
694 :
695 0 : <FormGroup label="UDP">
696 0 : <TextInput id="udp-ports" type="text" onChange={this.validate}
697 0 : validated={this.state.udp_error ? "error" : "default"}
698 0 : isDisabled={this.state.avail_services == null}
699 0 : value={this.state.custom_udp_value}
700 0 : placeholder={_("Example: 88,2019,nfs,rsync")} />
701 0 : <FormHelper helperTextInvalid={this.state.udp_error} helperText={_("Comma-separated ports, ranges, and services are accepted")} />
702 0 : </FormGroup>
703 :
704 0 : <FormGroup label={_("ID")}>
705 0 : <TextInput id="service-name" onChange={(_event, value) => this.setId(value)} isDisabled={!!this.props.custom_id || this.state.avail_services == null}
706 0 : value={this.state.custom_id} />
707 0 : <FormHelper helperText={_("If left empty, ID will be generated based on associated port services and port numbers")} />
708 0 : </FormGroup>
709 :
710 0 : <FormGroup label={_("Description")}>
711 0 : <TextInput id="service-description" onChange={(_event, value) => this.setDescription(value)} isDisabled={this.state.avail_services == null}
712 0 : value={this.state.custom_description} />
713 0 : </FormGroup>
714 0 : </>
715 : }
716 0 : </Form>
717 0 : </ModalBody>
718 0 : <ModalFooter>
719 0 : { !this.state.custom ||
720 0 : <Alert variant="warning"
721 0 : isInline
722 0 : title={_("Adding custom ports will reload firewalld. A reload will result in the loss of any runtime-only configuration!")} />
723 : }
724 0 : <Button variant='primary' isDisabled={(this.state.custom && this.checkNullValues()) || (!this.state.custom && !this.state.selected.size)} onClick={this.props.custom_id ? this.edit : this.save} aria-label={titleText}>
725 0 : {addText}
726 0 : </Button>
727 0 : <Button variant='link' className='btn-cancel' onClick={Dialogs.close}>
728 0 : {_("Cancel")}
729 0 : </Button>
730 0 : </ModalFooter>
731 0 : </Modal>
732 : );
733 0 : }
734 1 : }
735 :
736 1 : class ActivateZoneModal extends React.Component {
737 1 : static contextType = DialogsContext;
738 :
739 0 : constructor() {
740 0 : super();
741 :
742 0 : this.state = {
743 0 : ipRange: "ip-entire-subnet",
744 0 : ipRangeValue: null,
745 0 : zone: null,
746 0 : interfaces: new Set(),
747 0 : dialogError: null,
748 0 : dialogErrorDetail: null,
749 0 : };
750 0 : this.onFirewallChanged = this.onFirewallChanged.bind(this);
751 0 : this.onInterfaceChange = this.onInterfaceChange.bind(this);
752 0 : this.onChange = this.onChange.bind(this);
753 0 : this.save = this.save.bind(this);
754 0 : }
755 :
756 0 : componentDidMount() {
757 0 : firewall.addEventListener("changed", this.onFirewallChanged);
758 0 : }
759 :
760 0 : componentWillUnmount() {
761 0 : firewall.removeEventListener("changed", this.onFirewallChanged);
762 0 : }
763 :
764 0 : onFirewallChanged() {
765 0 : this.setState({});
766 0 : }
767 :
768 0 : onInterfaceChange(event) {
769 0 : const int = event.target.value;
770 0 : const enabled = event.target.checked;
771 0 : this.setState(state => {
772 0 : const interfaces = new Set(state.interfaces);
773 0 : if (enabled)
774 0 : interfaces.add(int);
775 : else
776 0 : interfaces.delete(int);
777 0 : return { interfaces };
778 0 : });
779 0 : }
780 :
781 0 : onChange(key, value) {
782 0 : this.setState({ [key]: value });
783 0 : }
784 :
785 0 : save(event) {
786 0 : const Dialogs = this.context;
787 0 : let p;
788 0 : if (firewall.zones[this.state.zone].services.indexOf("cockpit") === -1)
789 0 : p = firewall.addService(this.state.zone, "cockpit");
790 : else
791 0 : p = Promise.resolve();
792 :
793 0 : const sources = this.state.ipRange === "ip-range" ? this.state.ipRangeValue.split(",").map(ip => ip.trim()) : [];
794 0 : p.then(() =>
795 0 : firewall.activateZone(this.state.zone, [...this.state.interfaces], sources)
796 0 : .then(Dialogs.close)
797 0 : .catch(error => {
798 0 : this.setState({
799 0 : dialogError: _("Failed to add zone"),
800 0 : dialogErrorDetail: error.name + ": " + error.message,
801 0 : });
802 0 : }));
803 :
804 0 : if (event)
805 0 : event.preventDefault();
806 0 : return false;
807 0 : }
808 :
809 0 : render() {
810 0 : const Dialogs = this.context;
811 0 : const zones = Object.keys(firewall.zones).filter(z => firewall.zones[z].target === "default" && !firewall.activeZones.has(z));
812 0 : const customZones = zones.filter(z => firewall.predefinedZones.indexOf(z) === -1);
813 0 : const interfaces = firewall.availableInterfaces.filter(i => {
814 0 : let inZone = false;
815 0 : firewall.activeZones.forEach(z => {
816 0 : inZone ||= firewall.zones[z].interfaces.indexOf(i.device) !== -1;
817 0 : });
818 0 : return !inZone;
819 0 : });
820 : // https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceCapabilities
821 0 : const NM_DEVICE_CAP_IS_SOFTWARE = 4;
822 0 : const virtualDevices = interfaces.filter(i => (i.capabilities & NM_DEVICE_CAP_IS_SOFTWARE) !== 0 && i.device !== "lo").sort((a, b) => a.device.localeCompare(b.device));
823 0 : const physicalDevices = interfaces.filter(i => ((i.capabilities & NM_DEVICE_CAP_IS_SOFTWARE) === 0) && i.device !== "lo").sort((a, b) => a.device.localeCompare(b.device));
824 0 : return (
825 0 : <Modal id="add-zone-dialog" isOpen
826 0 : position="top" variant="medium"
827 0 : onClose={Dialogs.close}
828 : >
829 0 : <ModalHeader title={_("Add zone")} />
830 0 : <ModalBody>
831 0 : <Form isHorizontal onSubmit={this.save}>
832 : {
833 0 : this.state.dialogError && <ModalError dialogError={this.state.dialogError} dialogErrorDetail={this.state.dialogErrorDetail} />
834 : }
835 0 : <FormGroup label={ _("Trust level") } className="add-zone-zones">
836 0 : <Flex>
837 0 : <FlexItem className="add-zone-zones-firewalld">
838 0 : <legend>{ _("Sorted from least to most trusted") }</legend>
839 0 : { zones.filter(z => firewall.predefinedZones.indexOf(z) !== -1).sort((a, b) => firewall.predefinedZones.indexOf(a) - firewall.predefinedZones.indexOf(b))
840 0 : .map(z =>
841 0 : <Radio key={z} id={z} name="zone" value={z}
842 0 : isChecked={this.state.zone == z}
843 0 : onChange={e => this.onChange("zone", e.target.value)}
844 0 : label={ firewall.zones[z].id } />
845 0 : )}
846 0 : </FlexItem>
847 0 : <FlexItem className="add-zone-zones-custom">
848 0 : { customZones.length > 0 && <legend>{ _("Custom zones") }</legend> }
849 0 : { customZones.map(z =>
850 0 : <Radio key={z} id={z} name="zone" value={z}
851 0 : isChecked={this.state.zone == z}
852 0 : onChange={e => this.onChange("zone", e.target.value)}
853 0 : label={ firewall.zones[z].id } />
854 0 : )}
855 0 : </FlexItem>
856 0 : </Flex>
857 0 : </FormGroup>
858 :
859 0 : <FormGroup label={ _("Description") }>
860 0 : <p id="add-zone-description-readonly">
861 0 : { (this.state.zone && firewall.zones[this.state.zone].description) || _("No description available") }
862 0 : </p>
863 0 : </FormGroup>
864 :
865 0 : <FormGroup label={ _("Included services") } hasNoPaddingTop>
866 0 : <div id="add-zone-services-readonly">
867 0 : { (this.state.zone && firewall.zones[this.state.zone].services.join(", ")) || _("None") }
868 0 : </div>
869 0 : <FormHelper helperText={_("The cockpit service is automatically included")} />
870 0 : </FormGroup>
871 :
872 0 : <FormGroup label={ _("Interfaces") } hasNoPaddingTop isInline>
873 0 : { physicalDevices.map(i =>
874 0 : <Checkbox key={i.device}
875 0 : id={i.device}
876 0 : value={i.device}
877 0 : onChange={(event, value) => this.onInterfaceChange(event)}
878 0 : isChecked={this.state.interfaces.has(i.device)}
879 0 : label={i.device} />) }
880 0 : { virtualDevices.map(i =>
881 0 : <Checkbox key={i.device}
882 0 : id={i.device}
883 0 : value={i.device}
884 0 : onChange={(event, value) => this.onInterfaceChange(event)}
885 0 : isChecked={this.state.interfaces.has(i.device)}
886 0 : label={i.device} />) }
887 0 : </FormGroup>
888 :
889 0 : <FormGroup label={ _("Allowed addresses") } hasNoPaddingTop isInline>
890 0 : <Radio name="add-zone-ip"
891 0 : isChecked={this.state.ipRange == "ip-entire-subnet"}
892 0 : value="ip-entire-subnet"
893 0 : id="ip-entire-subnet"
894 0 : onChange={e => this.onChange("ipRange", e.target.value)}
895 0 : label={ _("Entire subnet") } />
896 0 : <Radio name="add-zone-ip"
897 0 : isChecked={this.state.ipRange == "ip-range"}
898 0 : value="ip-range"
899 0 : id="ip-range"
900 0 : onChange={e => this.onChange("ipRange", e.target.value)}
901 0 : label={ _("Range") } />
902 0 : {this.state.ipRange === "ip-range" && (
903 0 : <>
904 0 : <TextInput id="add-zone-ip" onChange={(_event, value) => this.onChange("ipRangeValue", value)} />
905 0 : <FormHelperText>{_("IP address with routing prefix. Separate multiple values with a comma. Example: 192.0.2.0/24, 2001:db8::/32")}</FormHelperText>
906 0 : </>
907 : )}
908 0 : </FormGroup>
909 0 : </Form>
910 0 : </ModalBody>
911 0 : <ModalFooter>
912 0 : <Button variant="primary" onClick={this.save} isDisabled={this.state.zone === null ||
913 0 : (this.state.interfaces.size === 0 && this.state.ipRange === "ip-entire-subnet") ||
914 0 : (this.state.ipRange === "ip-range" && !this.state.ipRangeValue)}>
915 0 : { _("Add zone") }
916 0 : </Button>
917 0 : <Button variant="link" className="btn-cancel" onClick={Dialogs.close}>
918 0 : { _("Cancel") }
919 0 : </Button>
920 0 : </ModalFooter>
921 0 : </Modal>
922 : );
923 0 : }
924 1 : }
925 :
926 0 : function DeleteConfirmationModal(props) {
927 0 : return (
928 0 : <Modal id="delete-confirmation-dialog" isOpen
929 0 : position="top" variant="medium"
930 0 : onClose={props.onCancel}
931 : >
932 0 : <ModalHeader title={props.title} />
933 0 : {props.body &&
934 0 : <ModalBody>
935 0 : <Alert variant="warning" isInline title={props.body} />
936 0 : </ModalBody>}
937 0 : <ModalFooter>
938 0 : <Button variant="danger" onClick={props.onDelete} aria-label={cockpit.format(_("Confirm removal of $0"), props.target)}>
939 0 : { _("Delete") }
940 0 : </Button>
941 0 : <Button variant="link" className="btn-cancel" onClick={props.onCancel}>
942 0 : { _("Cancel") }
943 0 : </Button>
944 0 : </ModalFooter>
945 0 : </Modal>
946 : );
947 0 : }
948 :
949 1 : export class Firewall extends React.Component {
950 1 : static contextType = DialogsContext;
951 :
952 1 : constructor() {
953 1 : super();
954 :
955 1 : this.state = {
956 1 : firewall,
957 1 : pendingTarget: null /* `null` for not pending */
958 1 : };
959 :
960 1 : this.onFirewallChanged = this.onFirewallChanged.bind(this);
961 1 : this.openServicesDialog = this.openServicesDialog.bind(this);
962 1 : this.openAddZoneDialog = this.openAddZoneDialog.bind(this);
963 1 : this.onRemoveZone = this.onRemoveZone.bind(this);
964 1 : this.onRemoveService = this.onRemoveService.bind(this);
965 1 : this.onEditService = this.onEditService.bind(this);
966 1 : }
967 :
968 1 : onFirewallChanged() {
969 1 : this.setState((prevState) => {
970 1 : if (prevState.pendingTarget === firewall.enabled)
971 0 : return { firewall, pendingTarget: null };
972 :
973 1 : return { firewall };
974 1 : });
975 1 : }
976 :
977 0 : onRemoveZone(zone) {
978 0 : const Dialogs = this.context;
979 0 : let body;
980 0 : if (firewall.zones[zone].services.indexOf("cockpit") !== -1)
981 0 : body = _("This zone contains the cockpit service. Make sure that this zone does not apply to your current web console connection.");
982 : else
983 0 : body = _("Removing the zone will remove all services within it.");
984 0 : Dialogs.show(<DeleteConfirmationModal title={ cockpit.format(_("Remove zone $0"), zone) }
985 0 : body={body}
986 0 : target={zone}
987 0 : onCancel={Dialogs.close}
988 0 : onDelete={ () => {
989 0 : firewall.deactiveateZone(zone);
990 0 : Dialogs.close();
991 0 : }} />
992 0 : );
993 0 : }
994 :
995 0 : onRemoveService(zone, service) {
996 0 : const Dialogs = this.context;
997 0 : if (service === 'cockpit') {
998 0 : const body = _("Removing the cockpit service might result in the web console becoming unreachable. Make sure that this zone does not apply to your current web console connection.");
999 0 : Dialogs.show(<DeleteConfirmationModal title={ cockpit.format(_("Remove $0 service from $1 zone"), service, zone) }
1000 0 : body={body}
1001 0 : target={service}
1002 0 : onCancel={Dialogs.close}
1003 0 : onDelete={ () => {
1004 0 : firewall.removeService(zone, service);
1005 0 : Dialogs.close();
1006 0 : }} />
1007 0 : );
1008 0 : } else {
1009 0 : firewall.removeService(zone, service);
1010 0 : }
1011 0 : }
1012 :
1013 0 : onRemovePort(zone, port, protocol) {
1014 0 : firewall.removePort(zone, port, protocol);
1015 0 : }
1016 :
1017 0 : onEditService(zone, service) {
1018 0 : const tcp_ports = [];
1019 0 : const udp_ports = [];
1020 0 : service.ports.forEach(port => {
1021 0 : if (port.protocol === "tcp")
1022 0 : tcp_ports.push(port.port);
1023 : else
1024 0 : udp_ports.push(port.port);
1025 0 : });
1026 :
1027 0 : const zone_name = zone.name ? zone.name : upperCaseFirstLetter(zone.id);
1028 :
1029 0 : const Dialogs = this.context;
1030 0 : Dialogs.show(<AddEditServicesModal zoneId={zone.id} zoneName={zone_name} custom_id={service.id}
1031 0 : custom_tcp_ports={tcp_ports} custom_udp_ports={udp_ports} custom_description={service.description}
1032 0 : custom_tcp_value={tcp_ports.join(", ")} custom_udp_value={udp_ports.join(", ")} />);
1033 0 : }
1034 :
1035 1 : componentDidMount() {
1036 1 : firewall.addEventListener("changed", this.onFirewallChanged);
1037 1 : }
1038 :
1039 0 : componentWillUnmount() {
1040 0 : firewall.removeEventListener("changed", this.onFirewallChanged);
1041 0 : }
1042 :
1043 0 : openServicesDialog(zoneId, zoneName) {
1044 0 : const Dialogs = this.context;
1045 0 : Dialogs.show(<AddEditServicesModal zoneId={zoneId} zoneName={zoneName} />);
1046 0 : }
1047 :
1048 0 : openAddZoneDialog() {
1049 0 : const Dialogs = this.context;
1050 0 : Dialogs.show(<ActivateZoneModal />);
1051 0 : }
1052 :
1053 1 : render() {
1054 0 : function go_up(event) {
1055 0 : cockpit.jump("/network", cockpit.transport.host);
1056 0 : }
1057 :
1058 0 : if (!this.state.firewall.installed) {
1059 0 : return <EmptyStatePanel title={ _("Firewall is not available") }
1060 0 : paragraph={ cockpit.format(_("Please install the $0 package"), "firewalld") }
1061 0 : icon={ ExclamationCircleIcon }
1062 0 : />;
1063 0 : }
1064 :
1065 1 : if (!this.state.firewall.ready)
1066 1 : return <EmptyStatePanel loading />;
1067 :
1068 1 : const addZoneAction = (
1069 1 : <Button variant="primary" onClick={this.openAddZoneDialog} id="add-zone-button" aria-label={_("Add a new zone")}>
1070 1 : {_("Add new zone")}
1071 1 : </Button>
1072 : );
1073 :
1074 1 : const zones = [...this.state.firewall.activeZones].sort((z1, z2) =>
1075 0 : z1 === firewall.defaultZone ? -1 : z2 === firewall.defaultZone ? 1 : 0
1076 1 : ).map(id => this.state.firewall.zones[id]);
1077 :
1078 1 : const enabled = this.state.firewall.enabled;
1079 :
1080 1 : return (
1081 1 : <Page className="pf-m-no-sidebar">
1082 1 : <PageBreadcrumb hasBodyWrapper={false} stickyOnBreakpoint={{ default: "top" }}>
1083 1 : <Breadcrumb>
1084 1 : <BreadcrumbItem onClick={go_up} className="pf-v6-c-breadcrumb__link">{_("Networking")}</BreadcrumbItem>
1085 1 : <BreadcrumbItem isActive>{_("Firewall")}</BreadcrumbItem>
1086 1 : </Breadcrumb>
1087 1 : </PageBreadcrumb>
1088 1 : <PageSection hasBodyWrapper={false} id="firewall-heading" className="firewall-heading">
1089 1 : <Flex alignItems={{ default: 'alignItemsCenter' }} justifyContent={{ default: 'justifyContentSpaceBetween' }}>
1090 1 : <Flex alignItems={{ default: 'alignItemsCenter' }} id="firewall-heading-title-group">
1091 1 : <Title headingLevel="h2" size="3xl">
1092 1 : {_("Firewall")}
1093 1 : </Title>
1094 1 : <FirewallSwitch firewall={firewall} />
1095 1 : <p>{_("Incoming requests are blocked by default. Outgoing requests are not blocked.")}</p>
1096 1 : </Flex>
1097 1 : { enabled && !firewall.readonly && <span className="btn-group">{addZoneAction}</span> }
1098 1 : </Flex>
1099 1 : </PageSection>
1100 1 : <PageSection hasBodyWrapper={false} id="zones-listing">
1101 1 : { enabled && <Stack hasGutter>
1102 : {
1103 1 : zones.map(z => <ZoneSection key={z.id}
1104 1 : zone={z}
1105 1 : openServicesDialog={this.openServicesDialog}
1106 1 : readonly={this.state.firewall.readonly}
1107 1 : onRemoveZone={this.onRemoveZone}
1108 1 : onEditService={this.onEditService}
1109 1 : onRemoveService={this.onRemoveService}
1110 1 : onRemovePort={this.onRemovePort} />
1111 1 : )
1112 : }
1113 1 : </Stack> }
1114 1 : </PageSection>
1115 1 : </Page>
1116 : );
1117 1 : }
1118 1 : }
1119 :
1120 1 : document.addEventListener("DOMContentLoaded", () => {
1121 1 : document.title = cockpit.gettext(document.title);
1122 1 : const root = createRoot(document.getElementById("firewall"));
1123 1 : root.render(<WithDialogs><Firewall /></WithDialogs>);
1124 1 : });
|