21 Commits

Author SHA1 Message Date
524ab50df7 Remove debug marker 2024-01-04 16:55:56 +01:00
8cd32d35e2 Add new attribute to 'all' rules 2024-01-04 16:53:24 +01:00
307e5d1b50 Make NWFilter clickable when not editable 2024-01-04 16:28:10 +01:00
ff66a5cf97 Improve network filter item 2024-01-04 16:21:26 +01:00
dcf6cdab9b Add a tip to help with NWFilter priorities 2024-01-04 15:42:05 +01:00
2649bfbd25 Create a widget to define priority 2024-01-04 15:38:58 +01:00
3eab3ba4b5 Fix issue on filter reference select 2024-01-04 15:33:35 +01:00
975b4ab395 Add Layer4 rules support 2024-01-04 15:30:25 +01:00
c40ee037da Add IPv4 / IPv6 selectors definition 2024-01-04 13:11:43 +01:00
719ab3b265 Can define ARP rules 2024-01-04 13:02:58 +01:00
ad45c0d654 Can edit MAC rules 2024-01-04 12:26:51 +01:00
7d7a052f5f Started to create rules editor 2024-01-04 11:30:20 +01:00
aafa4bf145 Can set the list of referenced filters in filters list 2024-01-04 10:47:08 +01:00
baa0adf529 Move DHCP component to a more logical location 2024-01-04 10:19:42 +01:00
fdd005a3ec Improve select network filter input 2024-01-03 22:11:35 +01:00
ed48b22f7f Create a specific widget for network filters 2024-01-03 20:28:33 +01:00
a7bfb80547 Add network filters metadata 2024-01-03 19:53:47 +01:00
0710c61909 Create base network filter details page 2024-01-03 19:34:17 +01:00
85dcb06014 Improve code quality 2024-01-03 19:23:35 +01:00
c880c5e6bb Create frames for network filters management 2024-01-03 19:20:37 +01:00
22f5acd0ff Create network filters route 2024-01-03 14:50:59 +01:00
29 changed files with 2025 additions and 103 deletions

View File

@ -2159,18 +2159,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-xml-rs"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb3aa78ecda1ebc9ec9847d5d3aba7d618823446a049ba2491940506da6e2782"
dependencies = [
"log",
"serde",
"thiserror",
"xml-rs",
]
[[package]]
name = "serde_derive"
version = "1.0.193"
@ -2677,7 +2665,6 @@ dependencies = [
"reqwest",
"rust-embed",
"serde",
"serde-xml-rs",
"serde_json",
"sysinfo",
"tempfile",
@ -2981,12 +2968,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "xml-rs"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a"
[[package]]
name = "zerocopy"
version = "0.7.31"

View File

@ -22,7 +22,6 @@ actix-web-actors = "4.2.0"
actix-http = "3.4.0"
serde = { version = "1.0.193", features = ["derive"] }
serde_json = "1.0.108"
serde-xml-rs = "0.6.0"
quick-xml = { version = "0.31.0", features = ["serialize", "overlapped-lists"] }
futures-util = "0.3.28"
anyhow = "1.0.75"

View File

@ -74,3 +74,6 @@ pub const BUILTIN_NETWORK_FILTER_RULES: [&str; 24] = [
"qemu-announce-self",
"qemu-announce-self-rarp",
];
/// List of valid network chains
pub const NETWORK_CHAINS: [&str; 8] = ["root", "mac", "stp", "vlan", "arp", "rarp", "ipv4", "ipv6"];

View File

@ -15,8 +15,9 @@ struct StaticConfig {
oidc_auth_enabled: bool,
iso_mimetypes: &'static [&'static str],
net_mac_prefix: &'static str,
builtin_nwfilter_rules: &'static [&'static str],
nwfilter_chains: &'static [&'static str],
constraints: ServerConstraints,
builtin_network_rules: &'static [&'static str],
}
#[derive(serde::Serialize)]
@ -25,6 +26,12 @@ struct LenConstraints {
max: usize,
}
#[derive(serde::Serialize)]
struct SLenConstraints {
min: i64,
max: i64,
}
#[derive(serde::Serialize)]
struct ServerConstraints {
iso_max_size: usize,
@ -37,6 +44,10 @@ struct ServerConstraints {
net_name_size: LenConstraints,
net_title_size: LenConstraints,
dhcp_reservation_host_name: LenConstraints,
nwfilter_name_size: LenConstraints,
nwfilter_comment_size: LenConstraints,
nwfilter_priority: SLenConstraints,
nwfilter_selectors_count: LenConstraints,
}
pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
@ -46,7 +57,8 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
oidc_auth_enabled: !AppConfig::get().disable_oidc,
iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES,
net_mac_prefix: constants::NET_MAC_ADDR_PREFIX,
builtin_network_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
nwfilter_chains: &constants::NETWORK_CHAINS,
constraints: ServerConstraints {
iso_max_size: constants::ISO_MAX_SIZE,
@ -71,6 +83,14 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
net_title_size: LenConstraints { min: 0, max: 50 },
dhcp_reservation_host_name: LenConstraints { min: 2, max: 250 },
nwfilter_name_size: LenConstraints { min: 2, max: 250 },
nwfilter_comment_size: LenConstraints { min: 0, max: 256 },
nwfilter_priority: SLenConstraints {
min: -1000,
max: 1000,
},
nwfilter_selectors_count: LenConstraints { min: 0, max: 1 },
},
})
}

View File

@ -9,10 +9,6 @@ pub struct NetworkFilterRefXML {
pub filter: String,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
#[serde(rename = "all")]
pub struct NetworkFilterRuleProtocolAll {}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
#[serde(rename = "mac")]
pub struct NetworkFilterRuleProtocolMac {
@ -47,7 +43,6 @@ pub struct NetworkFilterRuleProtocolArpXML {
pub arpdstipaddr: Option<String>,
#[serde(rename = "@arpdstipmask", skip_serializing_if = "Option::is_none")]
pub arpdstipmask: Option<u8>,
#[serde(rename = "@comment", skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
@ -111,7 +106,37 @@ pub struct NetworkFilterRuleProtocolLayer4<IPv> {
pub dstportend: Option<u16>,
#[serde(rename = "@state", skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(rename = "@comment", skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
#[serde(rename = "all")]
pub struct NetworkFilterRuleProtocolAllXML<IPv> {
#[serde(rename = "@srcmacaddr", skip_serializing_if = "Option::is_none")]
pub srcmacaddr: Option<String>,
#[serde(rename = "@srcipaddr", skip_serializing_if = "Option::is_none")]
pub srcipaddr: Option<IPv>,
#[serde(rename = "@srcipmask", skip_serializing_if = "Option::is_none")]
pub srcipmask: Option<u8>,
#[serde(rename = "@dstipaddr", skip_serializing_if = "Option::is_none")]
pub dstipaddr: Option<IPv>,
#[serde(rename = "@dstipmask", skip_serializing_if = "Option::is_none")]
pub dstipmask: Option<u8>,
/// Start of range of source IP address
#[serde(rename = "@srcipfrom", skip_serializing_if = "Option::is_none")]
pub srcipfrom: Option<IPv>,
/// End of range of source IP address
#[serde(rename = "@srcipto", skip_serializing_if = "Option::is_none")]
pub srcipto: Option<IPv>,
/// Start of range of destination IP address
#[serde(rename = "@dstipfrom", skip_serializing_if = "Option::is_none")]
pub dstipfrom: Option<IPv>,
/// End of range of destination IP address
#[serde(rename = "@dstipto", skip_serializing_if = "Option::is_none")]
pub dstipto: Option<IPv>,
#[serde(rename = "@state", skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(rename = "@comment", skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
@ -126,10 +151,6 @@ pub struct NetworkFilterRuleXML {
#[serde(rename = "@priority")]
pub priority: Option<i32>,
/// Match all protocols
#[serde(default, rename = "all", skip_serializing_if = "Vec::is_empty")]
pub all_selectors: Vec<NetworkFilterRuleProtocolAll>,
/// Match mac protocol
#[serde(default, rename = "mac", skip_serializing_if = "Vec::is_empty")]
pub mac_selectors: Vec<NetworkFilterRuleProtocolMac>,
@ -166,6 +187,10 @@ pub struct NetworkFilterRuleXML {
#[serde(default, rename = "icmp", skip_serializing_if = "Vec::is_empty")]
pub icmp_selectors: Vec<NetworkFilterRuleProtocolLayer4<Ipv4Addr>>,
/// Match all protocols
#[serde(default, rename = "all", skip_serializing_if = "Vec::is_empty")]
pub all_selectors: Vec<NetworkFilterRuleProtocolAllXML<Ipv4Addr>>,
/// Match TCP IPv6 protocol
#[serde(default, rename = "tcp-ipv6", skip_serializing_if = "Vec::is_empty")]
pub tcp_ipv6_selectors: Vec<NetworkFilterRuleProtocolLayer4<Ipv6Addr>>,
@ -181,6 +206,10 @@ pub struct NetworkFilterRuleXML {
/// Match ICMP IPv6 protocol
#[serde(default, rename = "icmpv6", skip_serializing_if = "Vec::is_empty")]
pub imcp_ipv6_selectors: Vec<NetworkFilterRuleProtocolLayer4<Ipv6Addr>>,
/// Match all ipv6 protocols
#[serde(default, rename = "all-ipv6", skip_serializing_if = "Vec::is_empty")]
pub all_ipv6_selectors: Vec<NetworkFilterRuleProtocolAllXML<Ipv6Addr>>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]

View File

@ -1,5 +1,5 @@
use crate::libvirt_lib_structures::nwfilter::{
NetworkFilterRefXML, NetworkFilterRuleProtocolAll, NetworkFilterRuleProtocolArpXML,
NetworkFilterRefXML, NetworkFilterRuleProtocolAllXML, NetworkFilterRuleProtocolArpXML,
NetworkFilterRuleProtocolIpvx, NetworkFilterRuleProtocolLayer4, NetworkFilterRuleProtocolMac,
NetworkFilterRuleXML, NetworkFilterXML,
};
@ -366,10 +366,28 @@ pub struct NetworkFilterSelectorLayer4<IPv> {
comment: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct NetworkSelectorAll<IPv> {
comment: Option<String>,
srcmacaddr: Option<NetworkFilterMacAddressOrVar>,
srcipaddr: Option<IPv>,
srcipmask: Option<u8>,
dstipaddr: Option<IPv>,
dstipmask: Option<u8>,
/// Start of range of source IP address
srcipfrom: Option<IPv>,
/// End of range of source IP address
srcipto: Option<IPv>,
/// Start of range of destination IP address
dstipfrom: Option<IPv>,
/// End of range of destination IP address
dstipto: Option<IPv>,
state: Option<Layer4State>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum NetworkFilterSelector {
All,
Mac(NetworkSelectorMac),
Arp(NetworkSelectorARP),
Rarp(NetworkSelectorARP),
@ -379,10 +397,12 @@ pub enum NetworkFilterSelector {
UDP(NetworkFilterSelectorLayer4<Ipv4Addr>),
SCTP(NetworkFilterSelectorLayer4<Ipv4Addr>),
ICMP(NetworkFilterSelectorLayer4<Ipv4Addr>),
All(NetworkSelectorAll<Ipv4Addr>),
TCPipv6(NetworkFilterSelectorLayer4<Ipv6Addr>),
UDPipv6(NetworkFilterSelectorLayer4<Ipv6Addr>),
SCTPipv6(NetworkFilterSelectorLayer4<Ipv6Addr>),
ICMPipv6(NetworkFilterSelectorLayer4<Ipv6Addr>),
Allipv6(NetworkSelectorAll<Ipv6Addr>),
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
@ -410,10 +430,6 @@ pub struct NetworkFilter {
}
impl NetworkFilter {
fn lib2rest_process_all_rule(_n: &NetworkFilterRuleProtocolAll) -> NetworkFilterSelector {
NetworkFilterSelector::All
}
fn lib2rest_process_mac_rule(n: &NetworkFilterRuleProtocolMac) -> NetworkFilterSelector {
NetworkFilterSelector::Mac(NetworkSelectorMac {
src_mac_addr: n.srcmacaddr.as_ref().map(|v| v.into()),
@ -476,21 +492,30 @@ impl NetworkFilter {
})
}
fn lib2rest_process_all_rule<IPv: Copy>(
n: &NetworkFilterRuleProtocolAllXML<IPv>,
) -> anyhow::Result<NetworkSelectorAll<IPv>> {
Ok(NetworkSelectorAll {
srcmacaddr: n.srcmacaddr.as_ref().map(|v| v.into()),
srcipaddr: n.srcipaddr,
srcipmask: n.srcipmask,
dstipaddr: n.dstipaddr,
dstipmask: n.dstipmask,
srcipfrom: n.srcipfrom,
srcipto: n.srcipto,
dstipfrom: n.dstipfrom,
dstipto: n.dstipto,
state: n.state.as_deref().map(Layer4State::from_xml).transpose()?,
comment: n.comment.clone(),
})
}
pub fn lib2rest(xml: NetworkFilterXML) -> anyhow::Result<Self> {
let mut rules = Vec::with_capacity(xml.rules.len());
for rule in &xml.rules {
let mut selectors = Vec::new();
// All selector
selectors.append(
&mut rule
.all_selectors
.iter()
.map(Self::lib2rest_process_all_rule)
.collect(),
);
// Mac rules
// Mac selectors
selectors.append(
&mut rule
.mac_selectors
@ -499,7 +524,7 @@ impl NetworkFilter {
.collect(),
);
// ARP - RARP rules
// ARP - RARP selectors
selectors.append(
&mut rule
.arp_selectors
@ -515,7 +540,7 @@ impl NetworkFilter {
.collect(),
);
// IPv4 - IPv6 rules
// IPv4 - IPv6 selectors
selectors.append(
&mut rule
.ipv4_selectors
@ -531,7 +556,7 @@ impl NetworkFilter {
.collect(),
);
// Layer 4 protocols
// Layer 4 protocols selectors
selectors.append(
&mut rule
.tcp_selectors
@ -622,6 +647,31 @@ impl NetworkFilter {
.collect::<Result<Vec<_>, anyhow::Error>>()?,
);
// All selectors
selectors.append(
&mut rule
.all_selectors
.iter()
.map(|r| {
Ok(NetworkFilterSelector::All(Self::lib2rest_process_all_rule(
r,
)?))
})
.collect::<Result<Vec<_>, anyhow::Error>>()?,
);
selectors.append(
&mut rule
.all_ipv6_selectors
.iter()
.map(|r| {
Ok(NetworkFilterSelector::Allipv6(
Self::lib2rest_process_all_rule(r)?,
))
})
.collect::<Result<Vec<_>, anyhow::Error>>()?,
);
rules.push(NetworkFilterRule {
action: NetworkFilterAction::from_xml(&rule.action)?,
direction: NetworkFilterDirection::from_xml(&rule.direction)?,
@ -704,6 +754,26 @@ impl NetworkFilter {
})
}
fn rest2lib_process_all_selector<IPv: Copy>(
selector: &NetworkSelectorAll<IPv>,
) -> anyhow::Result<NetworkFilterRuleProtocolAllXML<IPv>> {
Ok(NetworkFilterRuleProtocolAllXML {
srcmacaddr: extract_mac_address_or_var(&selector.srcmacaddr)?,
srcipaddr: selector.srcipaddr,
// This IP mask is not checked
srcipmask: selector.srcipmask,
dstipaddr: selector.dstipaddr,
// This IP mask is not checked
dstipmask: selector.dstipmask,
srcipfrom: selector.srcipfrom,
srcipto: selector.srcipto,
dstipfrom: selector.dstipfrom,
dstipto: selector.dstipto,
state: selector.state.map(|s| s.to_xml()),
comment: extract_nw_filter_comment(&selector.comment)?,
})
}
fn rest2lib_process_rule(rule: &NetworkFilterRule) -> anyhow::Result<NetworkFilterRuleXML> {
let mut rule_xml = NetworkFilterRuleXML {
action: rule.action.to_xml(),
@ -714,10 +784,6 @@ impl NetworkFilter {
for sel in &rule.selectors {
match sel {
NetworkFilterSelector::All => {
rule_xml.all_selectors.push(NetworkFilterRuleProtocolAll {});
}
NetworkFilterSelector::Mac(mac) => {
rule_xml.mac_selectors.push(NetworkFilterRuleProtocolMac {
srcmacaddr: extract_mac_address_or_var(&mac.src_mac_addr)?,
@ -733,6 +799,7 @@ impl NetworkFilter {
.arp_selectors
.push(Self::rest2lib_process_arp_selector(a)?);
}
NetworkFilterSelector::Rarp(a) => {
rule_xml
.rarp_selectors
@ -742,7 +809,6 @@ impl NetworkFilter {
NetworkFilterSelector::IPv4(ip) => rule_xml
.ipv4_selectors
.push(Self::rest2lib_process_ip_selector(ip)?),
NetworkFilterSelector::IPv6(ip) => rule_xml
.ipv6_selectors
.push(Self::rest2lib_process_ip_selector(ip)?),
@ -763,6 +829,12 @@ impl NetworkFilter {
.icmp_selectors
.push(Self::rest2lib_process_layer4_selector(icmp)?),
NetworkFilterSelector::All(all) => {
rule_xml
.all_selectors
.push(Self::rest2lib_process_all_selector(all)?);
}
NetworkFilterSelector::TCPipv6(tcpv6) => rule_xml
.tcp_ipv6_selectors
.push(Self::rest2lib_process_layer4_selector(tcpv6)?),
@ -778,6 +850,12 @@ impl NetworkFilter {
NetworkFilterSelector::ICMPipv6(icmpv6) => rule_xml
.imcp_ipv6_selectors
.push(Self::rest2lib_process_layer4_selector(icmpv6)?),
NetworkFilterSelector::Allipv6(all) => {
rule_xml
.all_ipv6_selectors
.push(Self::rest2lib_process_all_selector(all)?);
}
}
}

View File

@ -26,6 +26,12 @@ import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
import { BaseLoginPage } from "./widgets/BaseLoginPage";
import { ViewNetworkRoute } from "./routes/ViewNetworkRoute";
import { HomeRoute } from "./routes/HomeRoute";
import { NetworkFiltersListRoute } from "./routes/NetworkFiltersListRoute";
import { ViewNWFilterRoute } from "./routes/ViewNWFilterRoute";
import {
CreateNWFilterRoute,
EditNWFilterRoute,
} from "./routes/EditNWFilterRoute";
interface AuthContext {
signedIn: boolean;
@ -61,6 +67,11 @@ export function App() {
<Route path="net/:uuid" element={<ViewNetworkRoute />} />
<Route path="net/:uuid/edit" element={<EditNetworkRoute />} />
<Route path="nwfilter" element={<NetworkFiltersListRoute />} />
<Route path="nwfilter/new" element={<CreateNWFilterRoute />} />
<Route path="nwfilter/:uuid" element={<ViewNWFilterRoute />} />
<Route path="nwfilter/:uuid/edit" element={<EditNWFilterRoute />} />
<Route path="sysinfo" element={<SysInfoRoute />} />
<Route path="*" element={<NotFoundRoute />} />
</Route>

View File

@ -1,14 +1,11 @@
import { APIClient } from "./ApiClient";
import { ServerApi } from "./ServerApi";
export interface NWFilterChain {
protocol: string;
suffix?: string;
}
export interface NWFSAll {
type: "all";
}
export interface NWFSMac {
type: "mac";
src_mac_addr?: string;
@ -18,8 +15,114 @@ export interface NWFSMac {
comment?: string;
}
// TODO : complete
export type NWFSelector = NWFSAll | NWFSMac;
export interface NWFSArpOrRARP {
srcmacaddr?: string;
srcmacmask?: string;
dstmacaddr?: string;
dstmacmask?: string;
arpsrcipaddr?: string;
arpsrcipmask?: number;
arpdstipaddr?: string;
arpdstipmask?: number;
comment?: string;
}
export type NWFSArp = NWFSArpOrRARP & {
type: "arp";
};
export type NWFSRArp = NWFSArpOrRARP & {
type: "rarp";
};
export interface NWFSIPBase {
srcmacaddr?: string;
srcmacmask?: string;
dstmacaddr?: string;
dstmacmask?: string;
srcipaddr?: string;
srcipmask?: number;
dstipaddr?: string;
dstipmask?: number;
comment?: string;
}
export type NFWSIPv4 = NWFSIPBase & { type: "ipv4" };
export type NFWSIPv6 = NWFSIPBase & { type: "ipv6" };
export type Layer4State =
| "NEW"
| "ESTABLISHED"
| "RELATED"
| "INVALID"
| "NONE";
export interface NWFSLayer4Base {
srcmacaddr?: string;
srcipaddr?: string;
srcipmask?: number;
dstipaddr?: string;
dstipmask?: number;
srcipfrom?: string;
srcipto?: string;
dstipfrom?: string;
dstipto?: string;
srcportstart?: number;
srcportend?: number;
dstportstart?: number;
dstportend?: number;
state?: Layer4State;
comment?: string;
}
export type NFWSTCPv4 = NWFSLayer4Base & { type: "tcp" };
export type NFWSUDPv4 = NWFSLayer4Base & { type: "udp" };
export type NFWSSCTPv4 = NWFSLayer4Base & { type: "sctp" };
export type NFWSICMPv4 = NWFSLayer4Base & { type: "icmp" };
export type NFWSTCPv6 = NWFSLayer4Base & { type: "tcpipv6" };
export type NFWSUDPv6 = NWFSLayer4Base & { type: "udpipv6" };
export type NFWSSCTPv6 = NWFSLayer4Base & { type: "sctpipv6" };
export type NFWSICMPv6 = NWFSLayer4Base & { type: "icmpipv6" };
export interface NWFSAllBase {
srcmacaddr?: string;
srcipaddr?: string;
srcipmask?: number;
dstipaddr?: string;
dstipmask?: number;
srcipfrom?: string;
srcipto?: string;
dstipfrom?: string;
dstipto?: string;
state?: Layer4State;
comment?: string;
}
export type NWFSAll = NWFSAllBase & {
type: "all";
};
export type NWFSAllIPv6 = NWFSAllBase & {
type: "allipv6";
};
export type NWFSelector =
| NWFSMac
| NWFSArp
| NWFSRArp
| NFWSIPv4
| NFWSIPv6
| NFWSTCPv4
| NFWSUDPv4
| NFWSSCTPv4
| NFWSICMPv4
| NWFSAll
| NFWSTCPv6
| NFWSUDPv6
| NFWSSCTPv6
| NFWSICMPv6
| NWFSAllIPv6;
export interface NWFilterRule {
action: "drop" | "reject" | "accept" | "return" | "continue";
@ -30,13 +133,21 @@ export interface NWFilterRule {
export interface NWFilter {
name: string;
uuid?: string;
chain?: NWFilterChain;
priority?: number;
uuid?: string;
join_filters: string[];
rules: NWFilterRule[];
}
export function NWFilterURL(n: NWFilter, edit: boolean = false): string {
return `/nwfilter/${n.uuid}${edit ? "/edit" : ""}`;
}
export function NWFilterIsBuiltin(n: NWFilter): boolean {
return ServerApi.Config.builtin_nwfilter_rules.includes(n.name);
}
export class NWFilterApi {
/**
* Get the entire list of networks
@ -53,4 +164,64 @@ export class NWFilterApi {
return list;
}
/**
* Get the information about a single network filter
*/
static async GetSingle(uuid: string): Promise<NWFilter> {
return (
await APIClient.exec({
method: "GET",
uri: `/nwfilter/${uuid}`,
})
).data;
}
/**
* Get the source XML configuration of a network filter for debugging purposes
*/
static async GetSingleXML(uuid: string): Promise<string> {
return (
await APIClient.exec({
uri: `/nwfilter/${uuid}/src`,
method: "GET",
})
).data;
}
/**
* Create a new network filter
*/
static async Create(n: NWFilter): Promise<{ uid: string }> {
return (
await APIClient.exec({
method: "POST",
uri: "/nwfilter/create",
jsonData: n,
})
).data;
}
/**
* Update an existing network filter
*/
static async Update(n: NWFilter): Promise<{ uid: string }> {
return (
await APIClient.exec({
method: "PUT",
uri: `/nwfilter/${n.uuid}`,
jsonData: n,
})
).data;
}
/**
* Delete a network filter
*/
static async Delete(n: NWFilter): Promise<void> {
await APIClient.exec({
method: "DELETE",
uri: `/nwfilter/${n.uuid}`,
});
}
}

View File

@ -39,10 +39,6 @@ export function NetworkURL(n: NetworkInfo, edit: boolean = false): string {
return `/net/${n.uuid}${edit ? "/edit" : ""}`;
}
export function NetworkXMLURL(n: NetworkInfo): string {
return `/net/${n.uuid}/xml`;
}
export class NetworkApi {
/**
* Create a new network
@ -164,12 +160,10 @@ export class NetworkApi {
/**
* Delete a network
*/
static async Delete(n: NetworkInfo): Promise<NetworkInfo[]> {
return (
static async Delete(n: NetworkInfo): Promise<void> {
await APIClient.exec({
method: "DELETE",
uri: `/network/${n.uuid}`,
})
).data;
});
}
}

View File

@ -6,6 +6,8 @@ export interface ServerConfig {
oidc_auth_enabled: boolean;
iso_mimetypes: string[];
net_mac_prefix: string;
builtin_nwfilter_rules: string[];
nwfilter_chains: string[];
constraints: ServerConstraints;
}
@ -20,6 +22,10 @@ export interface ServerConstraints {
net_name_size: LenConstraint;
net_title_size: LenConstraint;
dhcp_reservation_host_name: LenConstraint;
nwfilter_name_size: LenConstraint;
nwfilter_comment_size: LenConstraint;
nwfilter_priority: LenConstraint;
nwfilter_selectors_count: LenConstraint;
}
export interface LenConstraint {

View File

@ -133,10 +133,6 @@ export class VMInfo implements VMInfoInterface {
get VNCURL(): string {
return `/vm/${this.uuid}/vnc`;
}
get XMLURL(): string {
return `/vm/${this.uuid}/xml`;
}
}
export class VMApi {

View File

@ -0,0 +1,151 @@
import { Button } from "@mui/material";
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { ConfigImportExportButtons } from "../widgets/ConfigImportExportButtons";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
import { NWFilter, NWFilterApi, NWFilterURL } from "../api/NWFilterApi";
import { NWFilterDetails } from "../widgets/nwfilter/NWFilterDetails";
export function CreateNWFilterRoute(): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const navigate = useNavigate();
const [nwfilter, setNWFilter] = React.useState<NWFilter>({
name: "my-filter",
chain: { protocol: "root" },
join_filters: [],
rules: [],
});
const createNWFilter = async (n: NWFilter) => {
try {
const res = await NWFilterApi.Create(n);
snackbar("The network filter was successfully created!");
navigate(`/nwfilter/${res.uid}`);
} catch (e) {
console.error(e);
alert(`Failed to create network filter!\n${e}`);
}
};
return (
<EditNetworkFilterRouteInner
nwfilter={nwfilter}
creating={true}
onCancel={() => navigate("/nwfilter")}
onSave={createNWFilter}
onReplace={setNWFilter}
/>
);
}
export function EditNWFilterRoute(): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const { uuid } = useParams();
const navigate = useNavigate();
const [nwfilter, setNWFilter] = React.useState<NWFilter | undefined>();
const load = async () => {
setNWFilter(await NWFilterApi.GetSingle(uuid!));
};
const updateNetworkFilter = async (n: NWFilter) => {
try {
await NWFilterApi.Update(n);
snackbar("The network filter was successfully updated!");
navigate(NWFilterURL(nwfilter!));
} catch (e) {
console.error(e);
alert(`Failed to update network filter!\n${e}`);
}
};
return (
<AsyncWidget
loadKey={uuid}
ready={nwfilter !== undefined}
errMsg="Failed to fetch network filter information!"
load={load}
build={() => (
<EditNetworkFilterRouteInner
nwfilter={nwfilter!}
creating={false}
onCancel={() => navigate(`/nwfilter/${uuid}`)}
onSave={updateNetworkFilter}
onReplace={setNWFilter}
/>
)}
/>
);
}
function EditNetworkFilterRouteInner(p: {
nwfilter: NWFilter;
creating: boolean;
onCancel: () => void;
onSave: (vm: NWFilter) => Promise<void>;
onReplace: (vm: NWFilter) => void;
}): React.ReactElement {
const loadingMessage = useLoadingMessage();
const [changed, setChanged] = React.useState(false);
const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => updateState({}), []);
const valueChanged = () => {
setChanged(true);
forceUpdate();
};
const save = async () => {
loadingMessage.show("Saving network filter configuration...");
await p.onSave(p.nwfilter);
loadingMessage.hide();
};
return (
<VirtWebRouteContainer
label={p.creating ? "Create a Network Filter" : "Edit Network Filter"}
actions={
<span>
<ConfigImportExportButtons
currentConf={p.nwfilter}
filename={`nwfilter-${p.nwfilter.name}.json`}
importConf={(c) => {
p.onReplace(c);
valueChanged();
}}
/>
{changed && (
<Button
variant="contained"
onClick={save}
style={{ marginRight: "10px" }}
>
{p.creating ? "Create" : "Save"}
</Button>
)}
<Button onClick={p.onCancel} variant="outlined">
Cancel
</Button>
</span>
}
>
<NWFilterDetails
nwfilter={p.nwfilter}
editable={true}
onChange={valueChanged}
/>
</VirtWebRouteContainer>
);
}

View File

@ -0,0 +1,154 @@
import VisibilityIcon from "@mui/icons-material/Visibility";
import {
Button,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import React from "react";
import { useNavigate } from "react-router-dom";
import {
NWFilter,
NWFilterApi,
NWFilterIsBuiltin,
NWFilterURL,
} from "../api/NWFilterApi";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { RouterLink } from "../widgets/RouterLink";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
export function NetworkFiltersListRoute(): React.ReactElement {
const [list, setList] = React.useState<NWFilter[] | undefined>();
const [count] = React.useState(1);
const load = async () => {
setList(await NWFilterApi.GetList());
};
return (
<AsyncWidget
loadKey={count}
load={load}
ready={list !== undefined}
errMsg="Failed to load the list of networks!"
build={() => <NetworkFiltersListRouteInner list={list!} />}
/>
);
}
enum VisibleFilters {
All,
Builtin,
Custom,
}
function NetworkFiltersListRouteInner(p: {
list: NWFilter[];
}): React.ReactElement {
const navigate = useNavigate();
const [visibleFilters, setVisibleFilters] = React.useState(
VisibleFilters.All
);
const filteredList = React.useMemo(() => {
if (visibleFilters === VisibleFilters.All) return p.list;
const onlyBuiltin = visibleFilters === VisibleFilters.Builtin;
return p.list.filter((f) => NWFilterIsBuiltin(f) === onlyBuiltin);
}, [visibleFilters]);
return (
<VirtWebRouteContainer
label="Network filters"
actions={
<>
<span style={{ flex: 10 }}></span>
<ToggleButtonGroup
size="small"
value={visibleFilters}
exclusive
onChange={(_ev, v) => setVisibleFilters(v)}
aria-label="visible filters"
>
<ToggleButton value={VisibleFilters.All}>All</ToggleButton>
<ToggleButton value={VisibleFilters.Builtin}>Builtin</ToggleButton>
<ToggleButton value={VisibleFilters.Custom}>Custom</ToggleButton>
</ToggleButtonGroup>
<span style={{ flex: 2 }}></span>
<RouterLink to="/nwfilter/new">
<Button>New</Button>
</RouterLink>
</>
}
>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Chain</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Referenced filters</TableCell>
<TableCell># of rules</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredList.map((t) => {
return (
<TableRow
key={t.uuid}
hover
onDoubleClick={() => navigate(NWFilterURL(t))}
>
<TableCell>{t.name}</TableCell>
<TableCell>
{t.chain?.protocol ?? (
<Typography style={{ fontStyle: "italic" }}>
None
</Typography>
)}
</TableCell>
<TableCell>
{t.priority ?? (
<Typography style={{ fontStyle: "italic" }}>
None
</Typography>
)}
</TableCell>
<TableCell>
<ul>
{t.join_filters.map((f, n) => (
<li key={n}>{f}</li>
))}
</ul>
</TableCell>
<TableCell>{t.rules.length}</TableCell>
<TableCell>
<RouterLink to={NWFilterURL(t)}>
<IconButton>
<VisibilityIcon />
</IconButton>
</RouterLink>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</VirtWebRouteContainer>
);
}

View File

@ -0,0 +1,65 @@
import { Button } from "@mui/material";
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
NWFilter,
NWFilterApi,
NWFilterIsBuiltin,
NWFilterURL,
} from "../api/NWFilterApi";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { ConfigImportExportButtons } from "../widgets/ConfigImportExportButtons";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
import { NWFilterDetails } from "../widgets/nwfilter/NWFilterDetails";
export function ViewNWFilterRoute() {
const { uuid } = useParams();
const [nwfilter, setNWFilter] = React.useState<NWFilter | undefined>();
const load = async () => {
setNWFilter(await NWFilterApi.GetSingle(uuid!));
};
return (
<AsyncWidget
loadKey={uuid}
ready={nwfilter !== undefined}
errMsg="Failed to fetch network filter information!"
load={load}
build={() => <ViewNetworkFilterRouteInner nwfilter={nwfilter!} />}
/>
);
}
function ViewNetworkFilterRouteInner(p: {
nwfilter: NWFilter;
}): React.ReactElement {
const navigate = useNavigate();
return (
<VirtWebRouteContainer
label={`Network filter ${p.nwfilter.name}`}
actions={
<span style={{ display: "flex", alignItems: "center" }}>
<ConfigImportExportButtons
filename={`nwfilter-${p.nwfilter.name}.json`}
currentConf={p.nwfilter}
/>
{!NWFilterIsBuiltin(p.nwfilter) && (
<Button
variant="contained"
style={{ marginLeft: "15px" }}
onClick={() => navigate(NWFilterURL(p.nwfilter, true))}
>
Edit
</Button>
)}
</span>
}
>
<NWFilterDetails nwfilter={p.nwfilter} editable={false} />
</VirtWebRouteContainer>
);
}

View File

@ -0,0 +1,5 @@
export function isDebug(): boolean {
return (
!import.meta.env.NODE_ENV || import.meta.env.NODE_ENV === "development"
);
}

View File

@ -3,7 +3,8 @@ import {
mdiDisc,
mdiHome,
mdiInformation,
mdiLan
mdiLan,
mdiSecurityNetwork,
} from "@mdi/js";
import Icon from "@mdi/react";
import {
@ -15,6 +16,7 @@ import {
ListItemText,
} from "@mui/material";
import { Outlet, useLocation } from "react-router-dom";
import { isDebug } from "../utils/DebugUtils";
import { RouterLink } from "./RouterLink";
import { VirtWebAppBar } from "./VirtWebAppBar";
@ -60,6 +62,11 @@ export function BaseAuthenticatedPage(): React.ReactElement {
uri="/net"
icon={<Icon path={mdiLan} size={1} />}
/>
<NavLink
label="Network filters"
uri="/nwfilter"
icon={<Icon path={mdiSecurityNetwork} size={1} />}
/>
<NavLink
label="ISO files"
uri="/iso"

View File

@ -1,3 +1,4 @@
import React from "react";
import { TextInput } from "./TextInput";
export function IPInput(p: {
@ -18,6 +19,47 @@ export function IPInput(p: {
);
}
export function IPInputWithMask(p: {
label: string;
editable: boolean;
ip?: string;
mask?: number;
onValueChange?: (ip?: string, mask?: number) => void;
version: 4 | 6;
}): React.ReactElement {
const showSlash = React.useRef(!!p.mask);
const currValue =
(p.ip ?? "") + (p.mask || showSlash.current ? "/" : "") + (p.mask ?? "");
const { onValueChange, ...props } = p;
return (
<TextInput
onValueChange={(v) => {
showSlash.current = false;
if (!v) {
onValueChange?.(undefined, undefined);
return;
}
const split = v?.split("/");
const ip =
p.version === 4 ? sanitizeIpV4(split[0]) : sanitizeIpV6(split[0]);
let mask = undefined;
if (split.length > 1) {
showSlash.current = true;
mask = sanitizeMask(p.version, split[1]);
}
onValueChange?.(ip, mask);
}}
value={currValue}
{...props}
/>
);
}
function sanitizeIpV4(s: string | undefined): string | undefined {
if (s === "" || s === undefined) return s;
@ -77,3 +119,15 @@ function sanitizeIpV6(s: string | undefined): string | undefined {
return needAnotherIteration ? sanitizeIpV6(res) : res;
}
function sanitizeMask(version: 4 | 6, mask?: string): number | undefined {
if (!mask) return undefined;
const value = Math.floor(Number(mask));
if (version === 4) {
return value < 0 || value > 32 ? 32 : value;
} else {
return value < 0 || value > 64 ? 64 : value;
}
}

View File

@ -0,0 +1,27 @@
import { Layer4State } from "../../api/NWFilterApi";
import { SelectInput } from "./SelectInput";
export function NWFConnStateInput(p: {
editable: boolean;
value?: Layer4State;
onChange: (s?: Layer4State) => void;
}): React.ReactElement {
return (
<SelectInput
{...p}
label="Connection state"
value={p.value}
onValueChange={(s) => {
p.onChange?.(s as any);
}}
options={[
{ label: "None", value: undefined },
{ label: "NEW", value: "NEW" },
{ label: "ESTABLISHED", value: "ESTABLISHED" },
{ label: "RELATED", value: "RELATED" },
{ label: "INVALID", value: "INVALID" },
{ label: "NONE", value: "NONE" },
]}
/>
);
}

View File

@ -0,0 +1,66 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { NWFilter, NWFilterURL } from "../../api/NWFilterApi";
import { NWFilterItem } from "../nwfilter/NWFilterItem";
import { NWFilterSelectInput } from "./NWFilterSelectInput";
export function NWFSelectReferencedFilters(p: {
editable: boolean;
selected: string[];
nwFiltersList: NWFilter[];
onChange?: () => void;
excludedFilters?: string[];
}): React.ReactElement {
const navigate = useNavigate();
const nwfilters = React.useMemo(
() =>
p.excludedFilters
? p.nwFiltersList.filter((f) => !p.excludedFilters!.includes(f.name))
: p.nwFiltersList,
[p.excludedFilters]
);
const selectedFilters = React.useMemo(
() => p.selected.map((f) => p.nwFiltersList.find((s) => s.name === f)),
[p.selected.length]
);
return (
<>
{selectedFilters.map((entry, n) => (
<NWFilterItem
key={n}
value={entry}
onDelete={
p.editable
? () => {
p.selected.splice(n, 1);
p.onChange?.();
}
: undefined
}
onClick={
!p.editable && entry
? () => navigate(NWFilterURL(entry))
: undefined
}
/>
))}
{p.editable && (
<NWFilterSelectInput
editable={p.editable}
label="Attach a new filter"
canBeNull={false}
nwfilters={nwfilters}
value={""}
onChange={(f) => {
p.selected.push(f!);
p.onChange?.();
}}
/>
)}
</>
);
}

View File

@ -0,0 +1,22 @@
import { ServerApi } from "../../api/ServerApi";
import { TextInput } from "./TextInput";
export function NWFilterPriorityInput(p: {
editable: boolean;
label: string;
value?: number;
onChange: (priority?: number) => void;
}): React.ReactElement {
return (
<TextInput
{...p}
value={p.value?.toString()}
type="number"
onValueChange={(v) => {
p.onChange?.(v && v !== "" ? Number(v) : undefined);
}}
size={ServerApi.Config.constraints.nwfilter_priority}
helperText="A lower priority value is accessed before one with a higher value"
/>
);
}

View File

@ -0,0 +1,709 @@
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import DeleteIcon from "@mui/icons-material/Delete";
import PlaylistAddIcon from "@mui/icons-material/PlaylistAdd";
import {
Button,
Card,
CardActions,
CardContent,
IconButton,
Paper,
Tooltip,
} from "@mui/material";
import {
NWFSAllBase,
NWFSArpOrRARP,
NWFSIPBase,
NWFSLayer4Base,
NWFSMac,
NWFSelector,
NWFilterRule,
} from "../../api/NWFilterApi";
import { ServerApi } from "../../api/ServerApi";
import { EditSection } from "./EditSection";
import { IPInput, IPInputWithMask } from "./IPInput";
import { MACInput } from "./MACInput";
import { NWFConnStateInput } from "./NWFConnStateInput";
import { NWFilterPriorityInput } from "./NWFilterPriorityInput";
import { PortInput } from "./PortInput";
import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput";
export function NWFilterRules(p: {
editable: boolean;
rules: NWFilterRule[];
onChange?: () => void;
}): React.ReactElement {
const addRule = () => {
p.rules.push({
action: "drop",
direction: "inout",
selectors: [],
});
p.onChange?.();
};
const swapRules = (f: number, s: number) => {
const swap = p.rules[f];
p.rules[f] = p.rules[s];
p.rules[s] = swap;
p.onChange?.();
};
const deleteRule = (num: number) => {
p.rules.splice(num, 1);
p.onChange?.();
};
return (
<EditSection title="Rules">
{p.rules.map((r, n) => (
<NWRuleEdit
key={n}
rule={r}
onDelete={() => {
deleteRule(n);
}}
onGoDown={
n < p.rules.length - 1 ? () => swapRules(n, n + 1) : undefined
}
onGoUp={n > 0 ? () => swapRules(n, n - 1) : undefined}
{...p}
/>
))}
<div style={{ textAlign: "right" }}>
{p.editable && <Button onClick={addRule}>Add a new rule</Button>}
</div>
</EditSection>
);
}
function NWRuleEdit(p: {
editable: boolean;
rule: NWFilterRule;
onChange?: () => void;
onGoUp?: () => void;
onGoDown?: () => void;
onDelete: () => void;
}): React.ReactElement {
const addSelector = () => {
p.rule.selectors.push({
type: "all",
});
p.onChange?.();
};
const deleteSelector = (num: number) => {
p.rule.selectors.splice(num, 1);
p.onChange?.();
};
return (
<Card style={{ margin: "30px" }} elevation={3}>
<CardContent>
<div style={{ display: "flex" }}>
<SelectInput
editable={p.editable}
label="Action"
value={p.rule.action}
onValueChange={(v) => {
p.rule.action = v as any;
p.onChange?.();
}}
options={[
{ label: "drop", value: "drop" },
{ label: "reject", value: "reject" },
{ label: "accept", value: "accept" },
{ label: "return", value: "return" },
{ label: "continue", value: "continue" },
]}
/>
<span style={{ width: "20px" }}></span>
<SelectInput
editable={p.editable}
label="Direction"
value={p.rule.direction}
onValueChange={(v) => {
p.rule.direction = v as any;
p.onChange?.();
}}
options={[
{ label: "in", value: "in" },
{ label: "out", value: "out" },
{ label: "inout", value: "inout" },
]}
/>
<span style={{ width: "20px" }}></span>
<NWFilterPriorityInput
{...p}
label="Priority"
value={p.rule.priority}
onChange={(v) => {
p.rule.priority = v;
p.onChange?.();
}}
/>
</div>
{p.rule.selectors.map((s, n) => (
<NWFSelectorEdit
key={n}
editable={p.editable}
onChange={p.onChange}
selector={s}
onDelete={() => deleteSelector(n)}
/>
))}
</CardContent>
<CardActions>
{p.editable && (
<div style={{ display: "flex", width: "100%" }}>
<Tooltip title="Remove the rule">
<IconButton color="error" onClick={p.onDelete}>
<DeleteIcon />
</IconButton>
</Tooltip>
<span style={{ flex: 1 }}></span>
{ServerApi.Config.constraints.nwfilter_selectors_count.max >
p.rule.selectors.length && (
<Tooltip title="Add a selector">
<IconButton onClick={addSelector}>
<PlaylistAddIcon />
</IconButton>
</Tooltip>
)}
{p.onGoUp && (
<Tooltip title="Move rule upward">
<IconButton onClick={p.onGoUp}>
<ArrowUpwardIcon />
</IconButton>
</Tooltip>
)}
{p.onGoDown && (
<Tooltip title="Move rule downward">
<IconButton onClick={p.onGoDown}>
<ArrowDownwardIcon />
</IconButton>
</Tooltip>
)}
</div>
)}
</CardActions>
</Card>
);
}
function NWFSelectorEdit(p: {
editable: boolean;
selector: NWFSelector;
onDelete: () => void;
onChange?: () => void;
}): React.ReactElement {
return (
<Paper elevation={10} style={{ padding: "10px" }}>
<div style={{ display: "flex", width: "100%" }}>
<div style={{ flex: 1 }}>
<SelectInput
editable={p.editable}
label="Type"
onValueChange={(v) => {
p.selector.type = v! as any;
p.onChange?.();
}}
value={p.selector.type}
options={[
{ label: "MAC (Ethernet)", value: "mac" },
{ label: "ARP", value: "arp" },
{ label: "RARP", value: "rarp" },
{ label: "IPv4", value: "ipv4" },
{ label: "IPv6", value: "ipv6" },
{ label: "TCP over IPv4", value: "tcp" },
{ label: "UDP over IPv4", value: "udp" },
{ label: "SCTP over IPv4", value: "sctp" },
{ label: "ICMPv4", value: "icmp" },
{ label: "All over IPv4", value: "all" },
{ label: "TCP over IPv6", value: "tcpipv6" },
{ label: "UDP over IPv6", value: "udpipv6" },
{ label: "SCTP over IPv6", value: "sctpipv6" },
{ label: "ICMPv6", value: "icmpipv6" },
{ label: "All over IPv6", value: "allipv6" },
]}
/>
{p.selector.type === "mac" && (
<NWFSelectorMac {...p} selector={p.selector} />
)}
{(p.selector.type === "arp" || p.selector.type === "rarp") && (
<NWFSelectorArp {...p} selector={p.selector} />
)}
{p.selector.type === "ipv4" && (
<NWFSelectorIP {...p} selector={p.selector} version={4} />
)}
{p.selector.type === "ipv6" && (
<NWFSelectorIP {...p} selector={p.selector} version={6} />
)}
{(p.selector.type === "tcp" ||
p.selector.type === "udp" ||
p.selector.type === "sctp" ||
p.selector.type === "icmp") && (
<NWFSelectorLayer4 {...p} selector={p.selector} version={4} />
)}
{p.selector.type === "all" && (
<NWFSelectorAll {...p} selector={p.selector} version={4} />
)}
{(p.selector.type === "tcpipv6" ||
p.selector.type === "udpipv6" ||
p.selector.type === "sctpipv6" ||
p.selector.type === "icmpipv6") && (
<NWFSelectorLayer4 {...p} selector={p.selector} version={6} />
)}
{p.selector.type === "allipv6" && (
<NWFSelectorAll {...p} selector={p.selector} version={6} />
)}
<TextInput
editable={p.editable}
label="Comment"
value={p.selector.comment}
onValueChange={(v) => {
p.selector.comment = v;
p.onChange?.();
}}
size={ServerApi.Config.constraints.nwfilter_comment_size}
/>
</div>
{p.editable && (
<div style={{ display: "flex", justifyContent: "center" }}>
<Tooltip title="Remove the selector">
<IconButton color="error" onClick={p.onDelete}>
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
)}
</div>
</Paper>
);
}
interface SpecificSelectorEditor<E> {
editable: boolean;
selector: E;
onChange?: () => void;
}
interface SpecificSelectorEditorWithIPVersion<E>
extends SpecificSelectorEditor<E> {
version: 4 | 6;
}
function NWFSelectorMac(
p: SpecificSelectorEditor<NWFSMac>
): React.ReactElement {
return (
<>
<MACInput
{...p}
label="Src mac address"
value={p.selector.src_mac_addr}
onValueChange={(v) => {
p.selector.src_mac_addr = v;
p.onChange?.();
}}
/>
<MACInput
{...p}
label="Src mac mask"
value={p.selector.src_mac_mask}
onValueChange={(v) => {
p.selector.src_mac_mask = v;
p.onChange?.();
}}
/>
<MACInput
{...p}
label="Dst mac address"
value={p.selector.dst_mac_addr}
onValueChange={(v) => {
p.selector.dst_mac_addr = v;
p.onChange?.();
}}
/>
<MACInput
{...p}
label="Dst mac mask"
value={p.selector.dst_mac_mask}
onValueChange={(v) => {
p.selector.dst_mac_mask = v;
p.onChange?.();
}}
/>
</>
);
}
function NWFSelectorArp(
p: SpecificSelectorEditor<NWFSArpOrRARP>
): React.ReactElement {
return (
<>
<MACInput
{...p}
label="Src mac address"
value={p.selector.srcmacaddr}
onValueChange={(v) => {
p.selector.srcmacaddr = v;
p.onChange?.();
}}
/>
<MACInput
{...p}
label="Src mac mask"
value={p.selector.srcmacmask}
onValueChange={(v) => {
p.selector.srcmacmask = v;
p.onChange?.();
}}
/>
<MACInput
{...p}
label="Dst mac address"
value={p.selector.dstmacaddr}
onValueChange={(v) => {
p.selector.dstmacaddr = v;
p.onChange?.();
}}
/>
<MACInput
{...p}
label="Dst mac mask"
value={p.selector.dstmacmask}
onValueChange={(v) => {
p.selector.dstmacmask = v;
p.onChange?.();
}}
/>
<IPInputWithMask
{...p}
label="ARP src ip"
ip={p.selector.arpsrcipaddr}
mask={p.selector.arpsrcipmask}
version={4}
onValueChange={(ip, mask) => {
p.selector.arpsrcipaddr = ip;
p.selector.arpsrcipmask = mask;
p.onChange?.();
}}
/>
<IPInputWithMask
{...p}
label="ARP dst ip"
ip={p.selector.arpdstipaddr}
mask={p.selector.arpdstipmask}
version={4}
onValueChange={(ip, mask) => {
p.selector.arpdstipaddr = ip;
p.selector.arpdstipmask = mask;
p.onChange?.();
}}
/>
</>
);
}
function NWFSelectorIP(
p: SpecificSelectorEditorWithIPVersion<NWFSIPBase>
): React.ReactElement {
return (
<>
<MACInput
{...p}
label="Src mac address"
value={p.selector.srcmacaddr}
onValueChange={(v) => {
p.selector.srcmacaddr = v;
p.onChange?.();
}}
/>
<MACInput
{...p}
label="Src mac mask"
value={p.selector.srcmacmask}
onValueChange={(v) => {
p.selector.srcmacmask = v;
p.onChange?.();
}}
/>
<MACInput
{...p}
label="Dst mac address"
value={p.selector.dstmacaddr}
onValueChange={(v) => {
p.selector.dstmacaddr = v;
p.onChange?.();
}}
/>
<MACInput
{...p}
label="Dst mac mask"
value={p.selector.dstmacmask}
onValueChange={(v) => {
p.selector.dstmacmask = v;
p.onChange?.();
}}
/>
<IPInputWithMask
{...p}
label="Source IP address / mask"
ip={p.selector.srcipaddr}
mask={p.selector.srcipmask}
version={p.version}
onValueChange={(ip, mask) => {
p.selector.srcipaddr = ip;
p.selector.srcipmask = mask;
p.onChange?.();
}}
/>
<IPInputWithMask
{...p}
label="Destination IP address / mask"
ip={p.selector.dstipaddr}
mask={p.selector.dstipmask}
version={p.version}
onValueChange={(ip, mask) => {
p.selector.dstipaddr = ip;
p.selector.dstipmask = mask;
p.onChange?.();
}}
/>
</>
);
}
function NWFSelectorLayer4(
p: SpecificSelectorEditorWithIPVersion<NWFSLayer4Base>
): React.ReactElement {
return (
<>
<MACInput
{...p}
label="Src mac address"
value={p.selector.srcmacaddr}
onValueChange={(v) => {
p.selector.srcmacaddr = v;
p.onChange?.();
}}
/>
<IPInputWithMask
{...p}
label="Source IP address / mask"
ip={p.selector.srcipaddr}
mask={p.selector.srcipmask}
version={p.version}
onValueChange={(ip, mask) => {
p.selector.srcipaddr = ip;
p.selector.srcipmask = mask;
p.onChange?.();
}}
/>
<IPInputWithMask
{...p}
label="Destination IP address / mask"
ip={p.selector.dstipaddr}
mask={p.selector.dstipmask}
version={p.version}
onValueChange={(ip, mask) => {
p.selector.dstipaddr = ip;
p.selector.dstipmask = mask;
p.onChange?.();
}}
/>
<IPInput
{...p}
label="Source IP from"
value={p.selector.srcipfrom}
onValueChange={(ip) => {
p.selector.srcipfrom = ip;
p.onChange?.();
}}
/>
<IPInput
{...p}
label="Source IP to"
value={p.selector.srcipto}
onValueChange={(ip) => {
p.selector.srcipto = ip;
p.onChange?.();
}}
/>
<IPInput
{...p}
label="Destination IP from"
value={p.selector.dstipfrom}
onValueChange={(ip) => {
p.selector.dstipfrom = ip;
p.onChange?.();
}}
/>
<IPInput
{...p}
label="Destination IP to"
value={p.selector.dstipto}
onValueChange={(ip) => {
p.selector.dstipto = ip;
p.onChange?.();
}}
/>
<PortInput
{...p}
label="Source port start"
value={p.selector.srcportstart}
onChange={(port) => {
p.selector.srcportstart = port;
p.onChange?.();
}}
/>
<PortInput
{...p}
label="Source port end"
value={p.selector.srcportend}
onChange={(port) => {
p.selector.srcportend = port;
p.onChange?.();
}}
/>
<PortInput
{...p}
label="Destination port start"
value={p.selector.dstportstart}
onChange={(port) => {
p.selector.dstportstart = port;
p.onChange?.();
}}
/>
<PortInput
{...p}
label="Destination port end"
value={p.selector.dstportend}
onChange={(port) => {
p.selector.dstportend = port;
p.onChange?.();
}}
/>
<NWFConnStateInput
{...p}
value={p.selector.state}
onChange={(v) => {
p.selector.state = v;
p.onChange?.();
}}
/>
</>
);
}
function NWFSelectorAll(
p: SpecificSelectorEditorWithIPVersion<NWFSAllBase>
): React.ReactElement {
return (
<>
<MACInput
{...p}
label="Src mac address"
value={p.selector.srcmacaddr}
onValueChange={(v) => {
p.selector.srcmacaddr = v;
p.onChange?.();
}}
/>
<IPInputWithMask
{...p}
label="Source IP address / mask"
ip={p.selector.srcipaddr}
mask={p.selector.srcipmask}
version={p.version}
onValueChange={(ip, mask) => {
p.selector.srcipaddr = ip;
p.selector.srcipmask = mask;
p.onChange?.();
}}
/>
<IPInputWithMask
{...p}
label="Destination IP address / mask"
ip={p.selector.dstipaddr}
mask={p.selector.dstipmask}
version={p.version}
onValueChange={(ip, mask) => {
p.selector.dstipaddr = ip;
p.selector.dstipmask = mask;
p.onChange?.();
}}
/>
<IPInput
{...p}
label="Source IP from"
value={p.selector.srcipfrom}
onValueChange={(ip) => {
p.selector.srcipfrom = ip;
p.onChange?.();
}}
/>
<IPInput
{...p}
label="Source IP to"
value={p.selector.srcipto}
onValueChange={(ip) => {
p.selector.srcipto = ip;
p.onChange?.();
}}
/>
<IPInput
{...p}
label="Destination IP from"
value={p.selector.dstipfrom}
onValueChange={(ip) => {
p.selector.dstipfrom = ip;
p.onChange?.();
}}
/>
<IPInput
{...p}
label="Destination IP to"
value={p.selector.dstipto}
onValueChange={(ip) => {
p.selector.dstipto = ip;
p.onChange?.();
}}
/>
<NWFConnStateInput
{...p}
value={p.selector.state}
onChange={(v) => {
p.selector.state = v;
p.onChange?.();
}}
/>
</>
);
}

View File

@ -0,0 +1,63 @@
import { Autocomplete, TextField } from "@mui/material";
import React from "react";
import { useNavigate } from "react-router-dom";
import { NWFilter, NWFilterURL } from "../../api/NWFilterApi";
import { NWFilterItem } from "../nwfilter/NWFilterItem";
export function NWFilterSelectInput(p: {
editable: boolean;
label?: string;
nwfilters: NWFilter[];
value?: string;
onChange?: (name?: string) => void;
canBeNull: boolean;
}): React.ReactElement {
const navigate = useNavigate();
const [open, setOpen] = React.useState(false);
const selectedValue = p.nwfilters.find((o) => o.name === p.value);
if (!p.editable && !selectedValue) return <></>;
if (selectedValue)
return (
<NWFilterItem
value={selectedValue}
onDelete={p.editable ? () => p.onChange?.(undefined) : undefined}
onClick={
!p.editable && selectedValue
? () => navigate(NWFilterURL(selectedValue))
: undefined
}
/>
);
return (
<Autocomplete
open={open}
onOpen={() => {
setOpen(true);
}}
onClose={() => {
setOpen(false);
}}
readOnly={!p.editable}
options={[...(p.canBeNull ? [undefined] : []), ...p.nwfilters]}
getOptionLabel={(o) => o?.name ?? "Unspecified"}
value={selectedValue}
renderInput={(params) => (
<TextField {...params} variant="standard" label={p.label} />
)}
renderOption={(_props, option, _state) => (
<NWFilterItem
dense
onClick={() => {
p.onChange?.(option?.name);
setOpen(false);
}}
value={option}
/>
)}
/>
);
}

View File

@ -14,11 +14,11 @@ import {
import { DHCPConfig, DHCPHost } from "../../api/NetworksApi";
import { ServerApi } from "../../api/ServerApi";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { IPInput } from "../forms/IPInput";
import { MACInput } from "../forms/MACInput";
import { TextInput } from "../forms/TextInput";
import { IPInput } from "./IPInput";
import { MACInput } from "./MACInput";
import { TextInput } from "./TextInput";
export function DHCPHostReservations(p: {
export function NetDHCPHostReservations(p: {
editable: boolean;
dhcp: DHCPConfig;
version: 4 | 6;

View File

@ -0,0 +1,28 @@
import { TextInput } from "./TextInput";
export function PortInput(p: {
editable: boolean;
label: string;
value?: number;
onChange: (value: number | undefined) => void;
}): React.ReactElement {
return (
<TextInput
{...p}
value={p.value?.toString() ?? ""}
type="number"
onValueChange={(v) => {
p.onChange?.(sanitizePort(v));
}}
/>
);
}
function sanitizePort(port?: string): number | undefined {
if (port === undefined) return undefined;
const val = Number(port);
if (val < 0) return 0;
if (val > 65535) return 65535;
return val;
}

View File

@ -16,6 +16,7 @@ export function TextInput(p: {
maxRows?: number;
type?: React.HTMLInputTypeAttribute;
style?: React.CSSProperties;
helperText?: string;
}): React.ReactElement {
if (!p.editable && (p.value ?? "") === "") return <></>;
@ -54,7 +55,7 @@ export function TextInput(p: {
minRows={p.minRows}
maxRows={p.maxRows}
error={valueError !== undefined}
helperText={valueError}
helperText={valueError ?? p.helperText}
/>
);
}

View File

@ -17,10 +17,11 @@ import { ServerApi } from "../../api/ServerApi";
import { VMInfo, VMNetInterface } from "../../api/VMApi";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { randomMacAddress } from "../../utils/RandUtils";
import { EditSection } from "./EditSection";
import { MACInput } from "./MACInput";
import { NWFilterSelectInput } from "./NWFilterSelectInput";
import { SelectInput } from "./SelectInput";
import { VMNetworkFilterParameters } from "./VMNetworkFilterParameters";
import { EditSection } from "./EditSection";
export function VMNetworksList(p: {
vm: VMInfo;
@ -174,11 +175,11 @@ function NetworkInfoWidget(p: {
/>
{/* Network Filter */}
<SelectInput
<NWFilterSelectInput
editable={p.editable}
label="Network filter"
value={p.network.nwfilterref?.name}
onValueChange={(v) => {
onChange={(v) => {
if (v && !p.network.nwfilterref) {
p.network.nwfilterref = { name: v, parameters: [] };
} else if (v) {
@ -188,16 +189,8 @@ function NetworkInfoWidget(p: {
}
p.onChange?.();
}}
options={[
{ label: "No network filer", value: undefined },
...p.networkFiltersList.map((v) => {
return {
value: v.name,
label: `${v.name} (${v.chain?.protocol ?? "unspecified"})`,
description: `${v.rules.length} rules - ${v.join_filters.length} joint filters`,
};
}),
]}
canBeNull={true}
nwfilters={p.networkFiltersList}
/>
{p.network.nwfilterref && (

View File

@ -13,7 +13,7 @@ import { IPInput } from "../forms/IPInput";
import { ResAutostartInput } from "../forms/ResAutostartInput";
import { SelectInput } from "../forms/SelectInput";
import { TextInput } from "../forms/TextInput";
import { DHCPHostReservations } from "./DHCPHostReservations";
import { NetDHCPHostReservations } from "../forms/NetDHCPHostReservations";
import { XMLAsyncWidget } from "../XMLWidget";
interface DetailsProps {
@ -23,10 +23,10 @@ interface DetailsProps {
}
export function NetworkDetails(p: DetailsProps): React.ReactElement {
const [cardsList, setCardsList] = React.useState<string[] | any>();
const [nicsList, setNicsList] = React.useState<string[] | any>();
const load = async () => {
setCardsList(await ServerApi.GetNetworksList());
setNicsList(await ServerApi.GetNetworksList());
};
return (
@ -34,7 +34,7 @@ export function NetworkDetails(p: DetailsProps): React.ReactElement {
loadKey={"1"}
load={load}
errMsg="Failed to load the list of host network cards!"
build={() => <NetworkDetailsInner cardsList={cardsList} {...p} />}
build={() => <NetworkDetailsInner nicsList={nicsList} {...p} />}
/>
);
}
@ -47,7 +47,7 @@ enum NetTab {
Danger,
}
type DetailsInnerProps = DetailsProps & { cardsList: string[] };
type DetailsInnerProps = DetailsProps & { nicsList: string[] };
function NetworkDetailsInner(p: DetailsInnerProps): React.ReactElement {
const [currTab, setCurrTab] = React.useState(NetTab.General);
@ -176,7 +176,7 @@ function NetworkDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
value={p.net.device}
options={[
{ label: "Default interface", value: undefined },
...p.cardsList.map((d) => {
...p.nicsList.map((d) => {
return { label: d, value: d };
}),
]}
@ -374,7 +374,7 @@ function IPSection(p: {
{p.config?.dhcp && (p.editable || p.config.dhcp.hosts.length > 0) && (
<EditSection title="DHCP hosts reservations">
<DHCPHostReservations
<NetDHCPHostReservations
{...p}
dhcp={p.config.dhcp}
onChange={(d) => {

View File

@ -0,0 +1,218 @@
import { Button, Grid } from "@mui/material";
import React, { ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import {
NWFilter,
NWFilterApi,
NWFilterIsBuiltin,
} from "../../api/NWFilterApi";
import { useAlert } from "../../hooks/providers/AlertDialogProvider";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
import { AsyncWidget } from "../AsyncWidget";
import { TabsWidget } from "../TabsWidget";
import { XMLAsyncWidget } from "../XMLWidget";
import { EditSection } from "../forms/EditSection";
import { TextInput } from "../forms/TextInput";
import { ServerApi } from "../../api/ServerApi";
import { SelectInput } from "../forms/SelectInput";
import { NWFSelectReferencedFilters } from "../forms/NWFSelectReferencedFilters";
import { NWFilterRules } from "../forms/NWFilterRules";
import { NWFilterPriorityInput } from "../forms/NWFilterPriorityInput";
interface DetailsProps {
nwfilter: NWFilter;
editable: boolean;
onChange?: () => void;
}
export function NWFilterDetails(p: DetailsProps): ReactElement {
const [nwFiltersList, setNwFiltersList] = React.useState<NWFilter[] | any>();
const load = async () => {
setNwFiltersList(await NWFilterApi.GetList());
};
return (
<AsyncWidget
loadKey={p.nwfilter.uuid}
load={load}
errMsg="Failed to load the list of network filters!"
build={() => (
<NetworkFilterDetailsInner nwFiltersList={nwFiltersList} {...p} />
)}
/>
);
}
type InnerDetailsProps = DetailsProps & { nwFiltersList: NWFilter[] };
enum NetFilterTab {
General = 0,
Rules,
XML,
Danger,
}
export function NetworkFilterDetailsInner(
p: InnerDetailsProps
): React.ReactElement {
const [currTab, setCurrTab] = React.useState(NetFilterTab.General);
return (
<>
<TabsWidget
currTab={currTab}
onTabChange={setCurrTab}
options={[
{ label: "General", value: NetFilterTab.General, visible: true },
{
label: "Rules",
value: NetFilterTab.Rules,
visible: p.editable || p.nwfilter.rules.length > 0,
},
{
label: "XML",
value: NetFilterTab.XML,
visible: !p.editable,
},
{
label: "Danger zone",
value: NetFilterTab.Danger,
color: "red",
visible: !p.editable && !NWFilterIsBuiltin(p.nwfilter),
},
]}
/>
{currTab === NetFilterTab.General && (
<NetworkFilterDetailsTabGeneral {...p} />
)}
{currTab === NetFilterTab.Rules && (
<NetworkFilterDetailsTabRules {...p} />
)}
{currTab === NetFilterTab.XML && <NetworkFilterDetailsTabXML {...p} />}
{currTab === NetFilterTab.Danger && (
<NetworkFilterDetailsTabDanger {...p} />
)}
</>
);
}
function NetworkFilterDetailsTabGeneral(
p: InnerDetailsProps
): React.ReactElement {
return (
<Grid container spacing={2}>
{/* Metadata section */}
<EditSection title="Metadata">
<TextInput
label="Name"
editable={p.editable}
value={p.nwfilter.name}
onValueChange={(v) => {
p.nwfilter.name = v ?? "";
p.onChange?.();
}}
checkValue={(v) => /^[a-zA-Z0-9\_\-]+$/.test(v)}
size={ServerApi.Config.constraints.nwfilter_name_size}
/>
<TextInput label="UUID" editable={false} value={p.nwfilter.uuid} />
<SelectInput
label="Chain"
editable={p.editable}
value={p.nwfilter.chain?.protocol}
onValueChange={(v) => {
p.nwfilter.chain = v ? { protocol: v } : undefined;
p.onChange?.();
}}
options={ServerApi.Config.nwfilter_chains.map((c) => {
return { label: c, value: c };
})}
/>
<NWFilterPriorityInput
{...p}
label="Priority"
value={p.nwfilter.priority}
onChange={(pri) => {
p.nwfilter.priority = pri;
p.onChange?.();
}}
/>
</EditSection>
{/* Referenced filters */}
{(p.editable || p.nwfilter.join_filters.length > 0) && (
<EditSection title="Referenced filters">
<NWFSelectReferencedFilters
selected={p.nwfilter.join_filters}
excludedFilters={[p.nwfilter.name]}
{...p}
/>
</EditSection>
)}
</Grid>
);
}
function NetworkFilterDetailsTabRules(
p: InnerDetailsProps
): React.ReactElement {
return (
<NWFilterRules
editable={p.editable}
rules={p.nwfilter.rules}
onChange={p.onChange}
/>
);
}
function NetworkFilterDetailsTabXML(p: InnerDetailsProps): React.ReactElement {
return (
<XMLAsyncWidget
errMsg="Failed to load network filter XML definition!"
identifier={p.nwfilter.uuid!}
load={() => NWFilterApi.GetSingleXML(p.nwfilter.uuid!)}
/>
);
}
function NetworkFilterDetailsTabDanger(
p: InnerDetailsProps
): React.ReactElement {
const confirm = useConfirm();
const snackbar = useSnackbar();
const alert = useAlert();
const navigate = useNavigate();
const requestDelete = async () => {
try {
if (
!(await confirm(
"Do you really want to delete this network filter?",
`Delete network filter ${p.nwfilter.name}`,
"Delete"
))
)
return;
await NWFilterApi.Delete(p.nwfilter);
navigate("/nwfilter");
snackbar("The network filter was successfully deleted!");
} catch (e) {
console.error(e);
alert(`Failed to delete the network filter!\n${e}`);
}
};
return (
<Button color="error" onClick={requestDelete}>
Delete this network filter
</Button>
);
}

View File

@ -0,0 +1,71 @@
import { mdiSecurityNetwork } from "@mdi/js";
import Icon from "@mdi/react";
import DeleteIcon from "@mui/icons-material/Delete";
import {
Avatar,
IconButton,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemText,
} from "@mui/material";
import { NWFilter } from "../../api/NWFilterApi";
export function NWFilterItem(p: {
value?: NWFilter;
onClick?: () => void;
dense?: boolean;
onDelete?: () => void;
}): React.ReactElement {
const specs = [];
if (p.value) {
if (p.value.rules.length === 1) specs.push(`1 rule`);
else if (p.value.rules.length > 1)
specs.push(`${p.value.rules.length} rules`);
if (p.value.join_filters.length === 1) specs.push(`1 joint filter`);
else if (p.value.join_filters.length > 1)
specs.push(`${p.value.join_filters.length} joint filters`);
if (p.value.priority) specs.push(`priority: ${p.value.priority}`);
}
const inner = (
<>
<ListItemAvatar>
<Avatar>
<Icon path={mdiSecurityNetwork} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
p.value
? `${p.value.name} (${p.value.chain?.protocol ?? "unspecified"})`
: "Unspecified"
}
secondary={specs.join(" / ")}
/>
</>
);
if (p.onClick)
return (
<ListItemButton onClick={p.onClick} dense={p.dense}>
{inner}
</ListItemButton>
);
return (
<ListItem
secondaryAction={
p.onDelete ? (
<IconButton onClick={p.onDelete}>
<DeleteIcon />
</IconButton>
) : undefined
}
>
{inner}
</ListItem>
);
}