/* * Copyright (C) 2018 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import '../lib/patternfly/patternfly-6-cockpit.scss'; import 'cockpit-dark-theme'; // once per page import cockpit from "cockpit"; import React from 'react'; import { createRoot } from "react-dom/client"; import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js"; import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js"; import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js'; import { DataList, DataListCell, DataListCheck, DataListItem, DataListItemCells, DataListItemRow } from "@patternfly/react-core/dist/esm/components/DataList/index.js"; import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js'; import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; import { Form, FormGroup, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form/index.js"; import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js"; import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js"; import { Title } from "@patternfly/react-core/dist/esm/components/Title/index.js"; import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js"; import { Page, PageBreadcrumb, PageSection, } from "@patternfly/react-core/dist/esm/components/Page/index.js"; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/dist/esm/components/Modal/index.js'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import firewall from "./firewall-client.js"; import { FormHelper } from "cockpit-components-form-helper"; import { ListingTable } from 'cockpit-components-table.jsx'; import { ModalError } from "cockpit-components-inline-notification.jsx"; import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; import { FirewallSwitch } from "./firewall-switch.jsx"; import { superuser } from "superuser"; import { WithDialogs, DialogsContext } from "dialogs.jsx"; import { KebabDropdown } from "cockpit-components-dropdown"; import "./networking.scss"; const _ = cockpit.gettext; superuser.reload_page_on_change(); const upperCaseFirstLetter = text => text[0].toUpperCase() + text.slice(1); const DeleteDropdown = ({ items }) => { const dropdown_items = items.map(item => {item.text} ); return ; }; function serviceRow(props) { let tcp = props.service.ports.filter(p => p.protocol.toUpperCase() === 'TCP'); let udp = props.service.ports.filter(p => p.protocol.toUpperCase() === 'UDP'); for (const s of props.service.includes) { if (firewall.services[s]) { tcp = tcp.concat(firewall.services[s].ports.filter(p => p.protocol.toUpperCase() === 'TCP')); udp = udp.concat(firewall.services[s].ports.filter(p => p.protocol.toUpperCase() === 'UDP')); } } function onRemoveService(event) { props.onRemoveService(props.service.id); event.stopPropagation(); } function onEditService(event) { props.onEditService(props.service.id); event.stopPropagation(); } const columns = [ { title: props.service.id, header: true }, { title:
{ tcp.map(p => p.port).join(', ') }
}, { title:
{ udp.map(p => p.port).join(', ') }
}, ]; if (!props.readonly) { // Only allow editing manually created services - no name is a decent (and only) indicator const items = []; if (!props.service.name) items.push({ text: _("Edit"), ariaLabel: cockpit.format(_("Edit service $0"), props.service.id), handleClick: onEditService }); items.push({ text: _("Delete"), danger: true, ariaLabel: cockpit.format(_("Remove service $0"), props.service.id), handleClick: onRemoveService }); columns.push({ title: }); } let description; let includes; if (props.service.description) description =

{props.service.description}

; if (props.service.includes.length > 0) { includes = <>
{_("Included Services")}
; } return ({ props: { key: props.service.id, 'data-row-id': props.service.id }, columns, hasPadding: true, expandedContent: <>{description}{includes}, }); } function portRow(props) { function onRemovePort(event) { props.onRemovePort(props.port); event.stopPropagation(); } const columns = [ { title: { _("Additional port") } }, { title: props.port.protocol === "tcp" ? props.port.port : "" }, { title: props.port.protocol === "udp" ? props.port.port : "" }, { title: } ]; return ({ props: { key: props.key + "-port", 'data-row-id': props.key + "-port" }, columns }); } function ZoneSection(props) { function onRemoveZone(event) { event.stopPropagation(); props.onRemoveZone(props.zone.id); } const deleteButton = (); const addServiceAction = ( ); const actions = !firewall.readonly &&
{addServiceAction}{deleteButton}
; /** @type {import('cockpit-components-table.jsx').ListingTableColumnProps[]} */ const listingTableColumns = [ { title: _("Service"), props: { width: 40, } }, { title: _("TCP"), props: { width: 30 } }, { title: _("UDP"), props: { width: 30 } }, { title: "", props: { width: 10, screenReaderText: _("Actions") } } ]; return { cockpit.format(_("$0 zone"), upperCaseFirstLetter(props.zone.name || props.zone.id)) } { props.zone.interfaces.length > 0 && {cockpit.ngettext("Interface", "Interfaces", props.zone.interfaces.length)} {props.zone.interfaces.join(", ")} } {_("Allowed addresses")} {props.zone.source.length ? props.zone.source.join(", ") : _("Entire subnet")} {(props.zone.services.length > 0 || props.zone.ports.length > 0) && { if (s in firewall.services) { return serviceRow({ key: firewall.services[s].id, service: firewall.services[s], onRemoveService: service => props.onRemoveService(props.zone.id, service), onEditService: service => props.onEditService(props.zone, firewall.services[service]), readonly: firewall.readonly, }); } else { return null; } }).concat( props.zone.ports.length > 0 ? props.zone.ports.map(p => { return portRow({ key: `${props.zone.id}-${p.port}-${p.protocol}`, zone: props.zone, port: p, onRemovePort: port => props.onRemovePort(props.zone.id, port.port, port.protocol), readonly: firewall.readonly }); }) : []) .filter(Boolean)} /> } ; } class SearchInput extends React.Component { constructor(props) { super(props); this.onValueChanged = this.onValueChanged.bind(this); this.state = { value: props.value || "" }; } onValueChanged(value) { this.setState({ value }); if (this.timer) window.clearTimeout(this.timer); this.timer = window.setTimeout(() => { this.props.onChange(value); this.timer = null; }, 300); } render() { return ( {_("Filter services")} this.onValueChanged(value)} value={this.state.value} /> ); } } const renderPorts = service => { const tcpPorts = []; const udpPorts = []; function addPorts(ports) { for (const port of ports) { if (port.protocol === "tcp") tcpPorts.push(port.port); else udpPorts.push(port.port); } } addPorts(service.ports); for (const s of service.includes) addPorts(firewall.services[s].ports); return (
{ tcpPorts.length > 0 && TCP: { tcpPorts.join(', ') } } { udpPorts.length > 0 && UDP: { udpPorts.join(', ') } }
); }; class AddEditServicesModal extends React.Component { static contextType = DialogsContext; constructor(props) { super(props); this.state = { services: null, selected: new Set(), filter: "", custom: !!props.custom_id, generate_custom_id: !props.custom_id, tcp_error: "", udp_error: "", avail_services: null, custom_id: props.custom_id || "", custom_description: props.custom_description || "", custom_tcp_ports: props.custom_tcp_ports || [], custom_udp_ports: props.custom_udp_ports || [], custom_tcp_value: props.custom_tcp_value || "", custom_udp_value: props.custom_udp_value || "", dialogError: null, dialogErrorDetail: null, }; this.save = this.save.bind(this); this.edit = this.edit.bind(this); this.checkNullValues = this.checkNullValues.bind(this); this.onFilterChanged = this.onFilterChanged.bind(this); this.onToggleService = this.onToggleService.bind(this); this.setId = this.setId.bind(this); this.setDescription = this.setDescription.bind(this); this.getName = this.getName.bind(this); this.validate = this.validate.bind(this); this.createPorts = this.createPorts.bind(this); this.parseServices = this.parseServices.bind(this); this.onToggleType = this.onToggleType.bind(this); this.getCustomId = this.getCustomId.bind(this); } createPorts() { const ret = []; this.state.custom_tcp_ports.forEach(port => ret.push([port, 'tcp'])); this.state.custom_udp_ports.forEach(port => ret.push([port, 'udp'])); return ret; } getCustomId() { return "custom--" + ( this.state.custom_tcp_ports.map(port => this.getName(port, "tcp")) .concat(this.state.custom_udp_ports.map(port => this.getName(port, "udp"))) .join('-') ); } checkNullValues() { return (!this.state.custom_tcp_value && !this.state.custom_udp_value); } edit(event) { const Dialogs = this.context; firewall.editService(this.props.custom_id, this.createPorts(), this.state.custom_description) .then(() => Dialogs.close()) .catch(error => { this.setState({ dialogError: _("Failed to edit service"), dialogErrorDetail: error.name + ": " + error.message, }); }); if (event) event.preventDefault(); return false; } save(event) { const Dialogs = this.context; let p; if (this.state.custom) { const custom_id = this.state.custom_id === "" ? this.getCustomId() : this.state.custom_id; p = firewall.createService(custom_id, this.createPorts(), this.props.zoneId, this.state.custom_description); } else { p = firewall.addServices(this.props.zoneId, [...this.state.selected]); } p.then(() => Dialogs.close()) .catch(error => { this.setState(prevState => ({ dialogError: prevState.custom ? _("Failed to add port") : _("Failed to add service"), dialogErrorDetail: error.name + ": " + error.message, })); }); if (event) event.preventDefault(); return false; } onToggleService(event, serviceId) { const service = serviceId; const enabled = event.target.checked; this.setState(oldState => { const selected = new Set(oldState.selected); if (enabled) selected.add(service); else selected.delete(service); return { selected }; }); } /* Create list of services from /etc/services type file * * Return dictionary of services: * - key => port number or port alias (80/http) * - item => dictionary with 3 compulsory items: * - name => port alias (http) * - port => port number (80) * - type => list of types (tcp/udp...) * - description => _may be not present_ (Web Server) */ parseServices(content) { if (!content) { console.warn("Couldn't read /etc/services"); return []; } const ret = {}; content.split('\n').forEach(line => { if (!line || line.startsWith("#")) return; const m = line.match(/^(\S+)\s+(\d+)\/(\S+).*?(#(.*))?$/); const new_port = { name: m[1], port: m[2], type: [m[3]] }; if (m.length > 5 && m[5]) new_port.description = m[5].trim(); if (ret[m[1]]) ret[m[1]].type.push(new_port.type[0]); else ret[m[1]] = new_port; if (ret[m[2]]) ret[m[2]].type.push(new_port.type[0]); else ret[m[2]] = new_port; }); return ret; } setId(value) { this.setState({ custom_id: value, generate_custom_id: value.length === 0, }); } setDescription(value) { this.setState({ custom_description: value }); } getName(port, type) { const known = this.state.avail_services[port]; if (known && known.type.includes(type)) return known.name; else return port; } getPortNumber(port, type, avail) { if (!avail) { const num_p = Number(port); if (isNaN(num_p)) return [0, _("Unknown service name")]; else if (num_p <= 0 || num_p > 65535) return [0, _("Invalid port number")]; else return [port, ""]; } else { return [avail.port, ""]; } } validate(event, value) { let error = ""; let targets = ['tcp', 'custom_tcp_ports', 'tcp_error', 'custom_tcp_value']; if (event.target.id === "udp-ports") targets = ['udp', 'custom_udp_ports', 'udp_error', 'custom_udp_value']; const new_ports = []; const event_id = event.target.id; this.setState(oldState => { const ports = value.split(','); ports.forEach((port) => { port = port.trim(); if (!port) return; let ports; if (port.indexOf("-") > -1) { ports = port.split("-"); if (ports.length !== 2) { error = _("Invalid range"); return; } [ports[0], error] = this.getPortNumber(ports[0], targets[0], oldState.avail_services[ports[0]]); if (!error) { [ports[1], error] = this.getPortNumber(ports[1], targets[0], oldState.avail_services[ports[1]]); if (!error) { if (Number(ports[0]) >= Number(ports[1])) error = _("Range must be strictly ordered"); else new_ports.push(ports[0] + "-" + ports[1]); } } } else { [ports, error] = this.getPortNumber(port, targets[0], oldState.avail_services[port]); if (!error) new_ports.push(ports); } }); const newState = { [targets[1]]: new_ports, [targets[2]]: error, [targets[3]]: value }; let all_ports; if (event_id === "udp-ports") { const old_ports = oldState.custom_tcp_ports.map(port => this.getName(port, "tcp")); all_ports = old_ports.concat(new_ports.map(port => this.getName(port, "udp"))); } else { const old_ports = oldState.custom_udp_ports.map(port => this.getName(port, "udp")); all_ports = new_ports.map(port => this.getName(port, "tcp")).concat(old_ports); } if (oldState.generate_custom_id) { if (all_ports.length > 0) newState.custom_id = "custom--" + all_ports.join('-'); else newState.custom_id = ""; } return newState; }); } onToggleType(event) { this.setState({ custom: event.target.value === "ports" }); } componentDidMount() { firewall.getAvailableServices() .then(services => this.setState({ services })); cockpit.file('/etc/services').read() .then(content => this.setState({ avail_services: this.parseServices(content) })); } onFilterChanged(value) { this.setState({ filter: value.toLowerCase() }); } render() { const Dialogs = this.context; let services; if (this.state.filter && this.state.services && !isNaN(this.state.filter)) services = this.state.services.filter(s => { for (const port of s.ports) if (port.port === this.state.filter) return true; return false; }); else if (this.state.filter && this.state.services) services = this.state.services.filter(s => s.id.indexOf(this.state.filter) > -1); else services = this.state.services; // hide services which have been enabled in the zone if (services) services = services.filter(s => firewall.zones[this.props.zoneId].services.indexOf(s.id) === -1); let addText = ""; let titleText = ""; if (this.props.custom_id) { addText = _("Edit service"); titleText = cockpit.format(_("Edit custom service in $0 zone"), this.props.zoneName); } else { addText = this.state.custom ? _("Add ports") : _("Add services"); titleText = this.state.custom ? cockpit.format(_("Add ports to $0 zone"), this.props.zoneName) : cockpit.format(_("Add services to $0 zone"), this.props.zoneName); } return (
{ this.state.dialogError && } { !!this.props.custom_id || } { this.state.custom ||
{ services ? ( <> {services.map(s => ( this.onToggleService(event, s.id)} id={"firewall-service-" + s.id} name={s.id + "-checkbox"} /> {renderPorts(s)} , ]} /> ))} ) : ( )}
} { !this.state.custom || <> this.setId(value)} isDisabled={!!this.props.custom_id || this.state.avail_services == null} value={this.state.custom_id} /> this.setDescription(value)} isDisabled={this.state.avail_services == null} value={this.state.custom_description} /> }
{ !this.state.custom || }
); } } class ActivateZoneModal extends React.Component { static contextType = DialogsContext; constructor() { super(); this.state = { ipRange: "ip-entire-subnet", ipRangeValue: null, zone: null, interfaces: new Set(), dialogError: null, dialogErrorDetail: null, }; this.onFirewallChanged = this.onFirewallChanged.bind(this); this.onInterfaceChange = this.onInterfaceChange.bind(this); this.onChange = this.onChange.bind(this); this.save = this.save.bind(this); } componentDidMount() { firewall.addEventListener("changed", this.onFirewallChanged); } componentWillUnmount() { firewall.removeEventListener("changed", this.onFirewallChanged); } onFirewallChanged() { this.setState({}); } onInterfaceChange(event) { const int = event.target.value; const enabled = event.target.checked; this.setState(state => { const interfaces = new Set(state.interfaces); if (enabled) interfaces.add(int); else interfaces.delete(int); return { interfaces }; }); } onChange(key, value) { this.setState({ [key]: value }); } save(event) { const Dialogs = this.context; let p; if (firewall.zones[this.state.zone].services.indexOf("cockpit") === -1) p = firewall.addService(this.state.zone, "cockpit"); else p = Promise.resolve(); const sources = this.state.ipRange === "ip-range" ? this.state.ipRangeValue.split(",").map(ip => ip.trim()) : []; p.then(() => firewall.activateZone(this.state.zone, [...this.state.interfaces], sources) .then(Dialogs.close) .catch(error => { this.setState({ dialogError: _("Failed to add zone"), dialogErrorDetail: error.name + ": " + error.message, }); })); if (event) event.preventDefault(); return false; } render() { const Dialogs = this.context; const zones = Object.keys(firewall.zones).filter(z => firewall.zones[z].target === "default" && !firewall.activeZones.has(z)); const customZones = zones.filter(z => firewall.predefinedZones.indexOf(z) === -1); const interfaces = firewall.availableInterfaces.filter(i => { let inZone = false; firewall.activeZones.forEach(z => { inZone ||= firewall.zones[z].interfaces.indexOf(i.device) !== -1; }); return !inZone; }); // https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceCapabilities const NM_DEVICE_CAP_IS_SOFTWARE = 4; const virtualDevices = interfaces.filter(i => (i.capabilities & NM_DEVICE_CAP_IS_SOFTWARE) !== 0 && i.device !== "lo").sort((a, b) => a.device.localeCompare(b.device)); const physicalDevices = interfaces.filter(i => ((i.capabilities & NM_DEVICE_CAP_IS_SOFTWARE) === 0) && i.device !== "lo").sort((a, b) => a.device.localeCompare(b.device)); return (
{ this.state.dialogError && } { _("Sorted from least to most trusted") } { zones.filter(z => firewall.predefinedZones.indexOf(z) !== -1).sort((a, b) => firewall.predefinedZones.indexOf(a) - firewall.predefinedZones.indexOf(b)) .map(z => this.onChange("zone", e.target.value)} label={ firewall.zones[z].id } /> )} { customZones.length > 0 && { _("Custom zones") } } { customZones.map(z => this.onChange("zone", e.target.value)} label={ firewall.zones[z].id } /> )}

{ (this.state.zone && firewall.zones[this.state.zone].description) || _("No description available") }

{ (this.state.zone && firewall.zones[this.state.zone].services.join(", ")) || _("None") }
{ physicalDevices.map(i => this.onInterfaceChange(event)} isChecked={this.state.interfaces.has(i.device)} label={i.device} />) } { virtualDevices.map(i => this.onInterfaceChange(event)} isChecked={this.state.interfaces.has(i.device)} label={i.device} />) } this.onChange("ipRange", e.target.value)} label={ _("Entire subnet") } /> this.onChange("ipRange", e.target.value)} label={ _("Range") } /> {this.state.ipRange === "ip-range" && ( <> this.onChange("ipRangeValue", value)} /> {_("IP address with routing prefix. Separate multiple values with a comma. Example: 192.0.2.0/24, 2001:db8::/32")} )}
); } } function DeleteConfirmationModal(props) { return ( {props.body && } ); } export class Firewall extends React.Component { static contextType = DialogsContext; constructor() { super(); this.state = { firewall, pendingTarget: null /* `null` for not pending */ }; this.onFirewallChanged = this.onFirewallChanged.bind(this); this.openServicesDialog = this.openServicesDialog.bind(this); this.openAddZoneDialog = this.openAddZoneDialog.bind(this); this.onRemoveZone = this.onRemoveZone.bind(this); this.onRemoveService = this.onRemoveService.bind(this); this.onEditService = this.onEditService.bind(this); } onFirewallChanged() { this.setState((prevState) => { if (prevState.pendingTarget === firewall.enabled) return { firewall, pendingTarget: null }; return { firewall }; }); } onRemoveZone(zone) { const Dialogs = this.context; let body; if (firewall.zones[zone].services.indexOf("cockpit") !== -1) body = _("This zone contains the cockpit service. Make sure that this zone does not apply to your current web console connection."); else body = _("Removing the zone will remove all services within it."); Dialogs.show( { firewall.deactivateZone(zone); Dialogs.close(); }} /> ); } onRemoveService(zone, service) { const Dialogs = this.context; if (service === 'cockpit') { 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."); Dialogs.show( { firewall.removeService(zone, service); Dialogs.close(); }} /> ); } else { firewall.removeService(zone, service); } } onRemovePort(zone, port, protocol) { firewall.removePort(zone, port, protocol); } onEditService(zone, service) { const tcp_ports = []; const udp_ports = []; service.ports.forEach(port => { if (port.protocol === "tcp") tcp_ports.push(port.port); else udp_ports.push(port.port); }); const zone_name = zone.name ? zone.name : upperCaseFirstLetter(zone.id); const Dialogs = this.context; Dialogs.show(); } componentDidMount() { firewall.addEventListener("changed", this.onFirewallChanged); } componentWillUnmount() { firewall.removeEventListener("changed", this.onFirewallChanged); } openServicesDialog(zoneId, zoneName) { const Dialogs = this.context; Dialogs.show(); } openAddZoneDialog() { const Dialogs = this.context; Dialogs.show(); } render() { function go_up(event) { cockpit.jump("/network", cockpit.transport.host); } if (!this.state.firewall.installed) { return ; } if (!this.state.firewall.ready) return ; const addZoneAction = ( ); const zones = [...this.state.firewall.activeZones].sort((z1, z2) => z1 === firewall.defaultZone ? -1 : z2 === firewall.defaultZone ? 1 : 0 ).map(id => this.state.firewall.zones[id]); const enabled = this.state.firewall.enabled; return ( {_("Networking")} {_("Firewall")} {_("Firewall")}

{_("Incoming requests are blocked by default. Outgoing requests are not blocked.")}

{ enabled && !firewall.readonly && {addZoneAction} }
{ enabled && { zones.map(z => ) } }
); } } document.addEventListener("DOMContentLoaded", () => { document.title = cockpit.gettext(document.title); const root = createRoot(document.getElementById("firewall")); root.render(); });