Can configure network NAT settings from UI

This commit is contained in:
Pierre HUBERT 2024-01-09 21:57:18 +01:00
parent 71e22bc328
commit f82925dbcb
11 changed files with 446 additions and 11 deletions

View File

@ -44,6 +44,9 @@ pub const DISK_SIZE_MIN: usize = 100;
/// Disk size max (MB) /// Disk size max (MB)
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2; pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2;
/// Net nat entry comment max size
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
/// Network mac address default prefix /// Network mac address default prefix
pub const NET_MAC_ADDR_PREFIX: &str = "52:54:00"; pub const NET_MAC_ADDR_PREFIX: &str = "52:54:00";

View File

@ -43,6 +43,7 @@ struct ServerConstraints {
disk_size: LenConstraints, disk_size: LenConstraints,
net_name_size: LenConstraints, net_name_size: LenConstraints,
net_title_size: LenConstraints, net_title_size: LenConstraints,
net_nat_comment_size: LenConstraints,
dhcp_reservation_host_name: LenConstraints, dhcp_reservation_host_name: LenConstraints,
nwfilter_name_size: LenConstraints, nwfilter_name_size: LenConstraints,
nwfilter_comment_size: LenConstraints, nwfilter_comment_size: LenConstraints,
@ -81,6 +82,10 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
net_name_size: LenConstraints { min: 2, max: 50 }, net_name_size: LenConstraints { min: 2, max: 50 },
net_title_size: LenConstraints { min: 0, max: 50 }, net_title_size: LenConstraints { min: 0, max: 50 },
net_nat_comment_size: LenConstraints {
min: 0,
max: constants::NET_NAT_COMMENT_MAX_SIZE,
},
dhcp_reservation_host_name: LenConstraints { min: 2, max: 250 }, dhcp_reservation_host_name: LenConstraints { min: 2, max: 250 },

View File

@ -1,3 +1,4 @@
use crate::constants;
use crate::utils::net_utils; use crate::utils::net_utils;
use std::net::{Ipv4Addr, Ipv6Addr}; use std::net::{Ipv4Addr, Ipv6Addr};
@ -64,6 +65,12 @@ impl<IPv> Nat<IPv> {
return Err(NatDefError::InvalidNatDef("Invalid guest port!").into()); return Err(NatDefError::InvalidNatDef("Invalid guest port!").into());
} }
if let Some(comment) = &self.comment {
if comment.len() > constants::NET_NAT_COMMENT_MAX_SIZE {
return Err(NatDefError::InvalidNatDef("Comment is too large!").into());
}
}
Ok(()) Ok(())
} }
} }

View File

@ -13,10 +13,28 @@ export interface DHCPConfig {
hosts: DHCPHost[]; hosts: DHCPHost[];
} }
export type NatSource =
| { type: "interface"; name: string }
| { type: "ip"; ip: string };
export type NatHostPort =
| { type: "single"; port: number }
| { type: "range"; start: number; end: number };
export interface NatEntry {
protocol: "TCP" | "UDP" | "Both";
host_addr: NatSource;
host_port: NatHostPort;
guest_addr: string;
guest_port: number;
comment?: string;
}
export interface IpConfig { export interface IpConfig {
bridge_address: string; bridge_address: string;
prefix: number; prefix: number;
dhcp?: DHCPConfig; dhcp?: DHCPConfig;
nat?: NatEntry[];
} }
export interface NetworkInfo { export interface NetworkInfo {

View File

@ -21,6 +21,7 @@ export interface ServerConstraints {
disk_size: LenConstraint; disk_size: LenConstraint;
net_name_size: LenConstraint; net_name_size: LenConstraint;
net_title_size: LenConstraint; net_title_size: LenConstraint;
net_nat_comment_size: LenConstraint;
dhcp_reservation_host_name: LenConstraint; dhcp_reservation_host_name: LenConstraint;
nwfilter_name_size: LenConstraint; nwfilter_name_size: LenConstraint;
nwfilter_comment_size: LenConstraint; nwfilter_comment_size: LenConstraint;

View File

@ -11,6 +11,7 @@ import {
ListItemText, ListItemText,
Paper, Paper,
Tooltip, Tooltip,
Typography,
} from "@mui/material"; } from "@mui/material";
import { DHCPConfig, DHCPHost } from "../../api/NetworksApi"; import { DHCPConfig, DHCPHost } from "../../api/NetworksApi";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
@ -54,6 +55,11 @@ export function NetDHCPHostReservations(p: {
</Grid> </Grid>
))} ))}
</Grid> </Grid>
{p.dhcp.hosts.length === 0 && (
<Typography style={{ textAlign: "center" }}>
You have not set any DHCP host reservations.
</Typography>
)}
{p.editable && ( {p.editable && (
<Button onClick={addHost}>Add new host reservation</Button> <Button onClick={addHost}>Add new host reservation</Button>
)} )}

View File

@ -0,0 +1,301 @@
import DeleteIcon from "@mui/icons-material/Delete";
import {
Button,
Card,
CardActions,
CardContent,
Grid,
IconButton,
Tooltip,
Typography,
} from "@mui/material";
import React, { PropsWithChildren } from "react";
import { NatEntry } from "../../api/NetworksApi";
import { ServerApi } from "../../api/ServerApi";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { IPInput } from "./IPInput";
import { PortInput } from "./PortInput";
import { RadioGroupInput } from "./RadioGroupInput";
import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput";
export function NetNatConfiguration(p: {
editable: boolean;
nat: NatEntry[];
nicsList: string[];
onChange?: (nat: NatEntry[]) => void;
version: 4 | 6;
}): React.ReactElement {
const confirm = useConfirm();
const addEntry = () => {
p.nat.push({
host_addr: {
type: "ip",
ip: p.version === 4 ? "10.0.0.1" : "fd00::",
},
host_port: { type: "single", port: 80 },
guest_addr: p.version === 4 ? "10.0.0.100" : "fd00::",
guest_port: 10,
protocol: "TCP",
});
p.onChange?.(p.nat);
};
const onDelete = async (idx: number) => {
if (!(await confirm("Do you really want to delete this entry?"))) return;
p.nat.splice(idx, 1);
p.onChange?.(p.nat);
};
return (
<>
{p.nat.map((e, num) => (
<NatEntryForm
key={num}
{...p}
entry={e}
onChange={() => p.onChange?.(p.nat)}
onDelete={() => onDelete(num)}
/>
))}
{p.nat.length === 0 && (
<Typography style={{ textAlign: "center" }}>
You have not set any NAT entry yet.
</Typography>
)}
{p.editable && <Button onClick={addEntry}>Add a new entry</Button>}
</>
);
}
function NatEntryForm(p: {
editable: boolean;
version: 4 | 6;
entry: NatEntry;
onChange?: () => void;
onDelete: () => void;
nicsList: string[];
}): React.ReactElement {
const guestPortEnd =
p.entry.host_port.type === "range"
? p.entry.host_port.end - p.entry.host_port.start + p.entry.guest_port
: undefined;
return (
<Card style={{ margin: "30px" }} elevation={3}>
<CardContent>
<Grid container>
<NATEntryProp>
<SelectInput
{...p}
label="Protocol"
options={[
{ value: "TCP" },
{ value: "UDP" },
{ label: "TCP & UDP", value: "Both" },
]}
value={p.entry.protocol}
onValueChange={(v) => {
p.entry.protocol = v as any;
p.onChange?.();
}}
/>
</NATEntryProp>
<NATEntryProp>
<TextInput
{...p}
label="Comment"
value={p.entry.comment}
onValueChange={(v) => {
p.entry.comment = v;
p.onChange?.();
}}
size={ServerApi.Config.constraints.net_nat_comment_size}
/>
</NATEntryProp>
{/* Host conf */}
<NATEntryProp label="Host configuration">
<SelectInput
{...p}
label="Host address specification"
options={[
{
label: "Specific IP",
value: "ip",
description: "Use a pre-defined IP address",
},
{
label: "Network interface",
value: "interface",
description:
"Use active IP addresses on the selected network interface during network startup to determine host adddress",
},
]}
value={p.entry.host_addr.type}
onValueChange={(v) => {
p.entry.host_addr.type = v as any;
p.onChange?.();
}}
/>
{p.entry.host_addr.type === "ip" && (
<IPInput
{...p}
label="Host IP address"
value={p.entry.host_addr.ip}
onValueChange={(v) => {
if (p.entry.host_addr.type === "ip")
p.entry.host_addr.ip = v!;
p.onChange?.();
}}
/>
)}
{p.entry.host_addr.type === "interface" && (
<SelectInput
{...p}
label="Network interface"
value={p.entry.host_addr.name}
options={p.nicsList.map((n) => {
return {
value: n,
};
})}
onValueChange={(v) => {
if (p.entry.host_addr.type === "interface")
p.entry.host_addr.name = v!;
p.onChange?.();
}}
/>
)}
</NATEntryProp>
<NATEntryProp label="Target guest configuration">
<IPInput
{...p}
label="Guest address"
value={p.entry.guest_addr}
onValueChange={(v) => {
p.entry.guest_addr = v!;
p.onChange?.();
}}
/>
</NATEntryProp>
<NATEntryProp>
<RadioGroupInput
{...p}
options={[
{ label: "Single port", value: "single" },
{ label: "Range of ports", value: "range" },
]}
value={p.entry.host_port.type}
onValueChange={(v) => {
p.entry.host_port.type = v as any;
p.onChange?.();
}}
/>
{p.entry.host_port.type === "single" && (
<PortInput
{...p}
label="Host port"
value={p.entry.host_port.port}
onChange={(v) => {
if (p.entry.host_port.type === "single")
p.entry.host_port.port = v!;
p.onChange?.();
}}
/>
)}
{p.entry.host_port.type === "range" && (
<div style={{ display: "flex" }}>
<PortInput
{...p}
label="Host port start"
value={p.entry.host_port.start}
onChange={(v) => {
if (p.entry.host_port.type === "range")
p.entry.host_port.start = v!;
p.onChange?.();
}}
/>
<PortSpacer />
<PortInput
{...p}
label="Host port end"
value={p.entry.host_port.end}
onChange={(v) => {
if (p.entry.host_port.type === "range")
p.entry.host_port.end = v!;
p.onChange?.();
}}
/>
</div>
)}
</NATEntryProp>
<NATEntryProp>
<div style={{ display: "flex", height: "100%", alignItems: "end" }}>
<PortInput
{...p}
label={`Guest port ${guestPortEnd ? "start" : ""}`}
value={p.entry.guest_port}
onChange={(v) => {
p.entry.guest_port = v!;
p.onChange?.();
}}
/>
{guestPortEnd && <PortSpacer />}
{guestPortEnd && (
<PortInput
editable={false}
label={`Guest port end`}
value={guestPortEnd}
onChange={(v) => {
p.entry.guest_port = v!;
p.onChange?.();
}}
/>
)}
</div>
</NATEntryProp>
</Grid>
</CardContent>
<CardActions>
{p.editable && (
<Tooltip title="Remove the entry">
<IconButton color="error" onClick={p.onDelete}>
<DeleteIcon />
</IconButton>
</Tooltip>
)}
</CardActions>
</Card>
);
}
function NATEntryProp(
p: PropsWithChildren<{ label?: string }>
): React.ReactElement {
return (
<Grid item sm={12} md={6} style={{ padding: "20px" }}>
{p.label && (
<Typography variant="h6" style={{ marginBottom: "10px" }}>
{p.label}
</Typography>
)}
{p.children}
</Grid>
);
}
function PortSpacer(): React.ReactElement {
return <span style={{ width: "20px" }}></span>;
}

View File

@ -14,6 +14,7 @@ export function PortInput(p: {
onValueChange={(v) => { onValueChange={(v) => {
p.onChange?.(sanitizePort(v)); p.onChange?.(sanitizePort(v));
}} }}
checkValue={(v) => Number(v) <= 65535}
/> />
); );
} }

View File

@ -0,0 +1,40 @@
import {
RadioGroup,
FormControlLabel,
Radio,
FormControl,
FormLabel,
} from "@mui/material";
interface RadioGroupOption {
label: string;
value: string;
}
export function RadioGroupInput(p: {
editable: boolean;
label?: string;
options: RadioGroupOption[];
value: string;
onValueChange: (v: string) => void;
}): React.ReactElement {
return (
<FormControl>
{p.label && <FormLabel>{p.label}</FormLabel>}
<RadioGroup
row
value={p.value}
onChange={(_ev, v) => p.onValueChange?.(v)}
>
{p.options.map((o) => (
<FormControlLabel
disabled={!p.editable}
value={o.value}
control={<Radio />}
label={o.label}
/>
))}
</RadioGroup>
</FormControl>
);
}

View File

@ -9,7 +9,7 @@ import { TextInput } from "./TextInput";
export interface SelectOption { export interface SelectOption {
value?: string; value?: string;
label: string; label?: string;
description?: string; description?: string;
} }
@ -23,9 +23,10 @@ export function SelectInput(p: {
if (!p.editable && !p.value) return <></>; if (!p.editable && !p.value) return <></>;
if (!p.editable) { if (!p.editable) {
const value = p.options.find((o) => o.value === p.value)?.label; const value = p.options.find((o) => o.value === p.value)?.label ?? p.value;
return <TextInput label={p.label} editable={p.editable} value={value} />; return <TextInput label={p.label} editable={p.editable} value={value} />;
} }
return ( return (
<FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}> <FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}>
<InputLabel>{p.label}</InputLabel> <InputLabel>{p.label}</InputLabel>
@ -41,7 +42,7 @@ export function SelectInput(p: {
style={{ fontStyle: e.value === undefined ? "italic" : undefined }} style={{ fontStyle: e.value === undefined ? "italic" : undefined }}
> >
<div> <div>
{e.label} {e.label ?? e.value}
{e.description && ( {e.description && (
<Typography <Typography
component={"div"} component={"div"}

View File

@ -8,13 +8,14 @@ import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { useSnackbar } from "../../hooks/providers/SnackbarProvider"; import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
import { AsyncWidget } from "../AsyncWidget"; import { AsyncWidget } from "../AsyncWidget";
import { TabsWidget } from "../TabsWidget"; import { TabsWidget } from "../TabsWidget";
import { XMLAsyncWidget } from "../XMLWidget";
import { EditSection } from "../forms/EditSection"; import { EditSection } from "../forms/EditSection";
import { IPInput } from "../forms/IPInput"; import { IPInput } from "../forms/IPInput";
import { NetDHCPHostReservations } from "../forms/NetDHCPHostReservations";
import { NetNatConfiguration } from "../forms/NetNatConfiguration";
import { ResAutostartInput } from "../forms/ResAutostartInput"; import { ResAutostartInput } from "../forms/ResAutostartInput";
import { SelectInput } from "../forms/SelectInput"; import { SelectInput } from "../forms/SelectInput";
import { TextInput } from "../forms/TextInput"; import { TextInput } from "../forms/TextInput";
import { NetDHCPHostReservations } from "../forms/NetDHCPHostReservations";
import { XMLAsyncWidget } from "../XMLWidget";
interface DetailsProps { interface DetailsProps {
net: NetworkInfo; net: NetworkInfo;
@ -223,7 +224,7 @@ function NetworkDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
function NetworkDetailsTabIPv4(p: DetailsInnerProps): React.ReactElement { function NetworkDetailsTabIPv4(p: DetailsInnerProps): React.ReactElement {
return ( return (
<IPSection <IPSection
editable={p.editable} {...p}
config={p.net.ip_v4} config={p.net.ip_v4}
onChange={(c) => { onChange={(c) => {
p.net.ip_v4 = c; p.net.ip_v4 = c;
@ -237,7 +238,7 @@ function NetworkDetailsTabIPv4(p: DetailsInnerProps): React.ReactElement {
function NetworkDetailsTabIPv6(p: DetailsInnerProps): React.ReactElement { function NetworkDetailsTabIPv6(p: DetailsInnerProps): React.ReactElement {
return ( return (
<IPSection <IPSection
editable={p.editable} {...p}
config={p.net.ip_v6} config={p.net.ip_v6}
onChange={(c) => { onChange={(c) => {
p.net.ip_v6 = c; p.net.ip_v6 = c;
@ -253,6 +254,7 @@ function IPSection(p: {
config?: IpConfig; config?: IpConfig;
onChange: (c: IpConfig | undefined) => void; onChange: (c: IpConfig | undefined) => void;
version: 4 | 6; version: 4 | 6;
nicsList: string[];
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
@ -260,7 +262,7 @@ function IPSection(p: {
if (!!p.config) { if (!!p.config) {
if ( if (
!(await confirm( !(await confirm(
`Do you really want to disable IPv${p.version} on this network?` `Do you really want to disable IPv${p.version} on this network? Specific configuration will be deleted!`
)) ))
) )
return; return;
@ -275,8 +277,8 @@ function IPSection(p: {
}); });
}; };
const toggleDHCP = (v: boolean) => { const toggleDHCP = async (v: boolean) => {
if (v) if (v) {
p.config!.dhcp = p.config!.dhcp =
p.version === 4 p.version === 4
? { ? {
@ -285,7 +287,32 @@ function IPSection(p: {
hosts: [], hosts: [],
} }
: { start: "fd00::100", end: "fd00::f00", hosts: [] }; : { start: "fd00::100", end: "fd00::f00", hosts: [] };
else p.config!.dhcp = undefined; } else {
if (
!(await confirm(
`Do you really want to disable DHCPv${p.version} on this network? Specific configuration will be deleted!`
))
)
return;
p.config!.dhcp = undefined;
}
p.onChange?.(p.config);
};
const toggleNAT = async (v: boolean) => {
if (v) {
p.config!.nat = [];
} else {
if (
(p.config?.nat?.length ?? 0 > 0) &&
!(await confirm(
`Do you really want to disable NAT port forwarding on this network? Specific configuration will be deleted!`
))
)
return;
p.config!.nat = undefined;
}
p.onChange?.(p.config); p.onChange?.(p.config);
}; };
@ -384,6 +411,31 @@ function IPSection(p: {
/> />
</EditSection> </EditSection>
)} )}
{p.config && (p.editable || p.config.nat) && (
<EditSection
title={`NAT v${p.version} ports redirection`}
fullWidth
actions={
<Checkbox
disabled={!p.editable}
checked={!!p.config.nat}
onChange={(_ev, val) => toggleNAT(val)}
/>
}
>
{p.config.nat && (
<NetNatConfiguration
{...p}
nat={p.config.nat}
onChange={(n) => {
p.config!.nat = n;
p.onChange?.(p.config);
}}
/>
)}
</EditSection>
)}
</Grid> </Grid>
); );
} }