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