diff --git a/internal/confparser/confparser.go b/internal/confparser/confparser.go index 5ccd1cb..701ba9a 100644 --- a/internal/confparser/confparser.go +++ b/internal/confparser/confparser.go @@ -346,6 +346,17 @@ func (f *FieldConfig) validateField() error { return nil } + // Handle []string types, like dns servers, time sync ntp servers, etc. + if slice, ok := f.CurrentValue.([]string); ok { + for i, item := range slice { + if err := f.validateSingleValue(item, i); err != nil { + return err + } + } + return nil + } + + // Handle single string types val, err := toString(f.CurrentValue) if err != nil { return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err) @@ -355,27 +366,46 @@ func (f *FieldConfig) validateField() error { return nil } + return f.validateSingleValue(val, -1) +} + +func (f *FieldConfig) validateSingleValue(val string, index int) error { for _, validateType := range f.ValidateTypes { + var fieldRef string + if index >= 0 { + fieldRef = fmt.Sprintf("field `%s[%d]`", f.Name, index) + } else { + fieldRef = fmt.Sprintf("field `%s`", f.Name) + } + switch validateType { case "ipv4": if net.ParseIP(val).To4() == nil { - return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val) + return fmt.Errorf("%s is not a valid IPv4 address: %s", fieldRef, val) } case "ipv6": if net.ParseIP(val).To16() == nil { - return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val) + return fmt.Errorf("%s is not a valid IPv6 address: %s", fieldRef, val) + } + case "ipv4_or_ipv6": + if net.ParseIP(val) == nil { + return fmt.Errorf("%s is not a valid IPv4 or IPv6 address: %s", fieldRef, val) } case "hwaddr": if _, err := net.ParseMAC(val); err != nil { - return fmt.Errorf("field `%s` is not a valid MAC address: %s", f.Name, val) + return fmt.Errorf("%s is not a valid MAC address: %s", fieldRef, val) } case "hostname": if _, err := idna.Lookup.ToASCII(val); err != nil { - return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val) + return fmt.Errorf("%s is not a valid hostname: %s", fieldRef, val) } case "proxy": if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" { - return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val) + return fmt.Errorf("%s is not a valid HTTP proxy URL: %s", fieldRef, val) + } + case "url": + if _, err := url.Parse(val); err != nil { + return fmt.Errorf("%s is not a valid URL: %s", fieldRef, val) } default: return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType) diff --git a/ui/src/components/StaticIpv4Card.tsx b/ui/src/components/StaticIpv4Card.tsx new file mode 100644 index 0000000..b7809fe --- /dev/null +++ b/ui/src/components/StaticIpv4Card.tsx @@ -0,0 +1,237 @@ +import { useState, useEffect } from "react"; +import { LuPlus, LuX } from "react-icons/lu"; +import isIP from "validator/es/lib/isIP"; + +import { GridCard } from "@/components/Card"; +import { Button } from "@/components/Button"; +import { InputFieldWithLabel } from "@/components/InputField"; +import { IPv4StaticConfig, NetworkSettings, NetworkState } from "@/hooks/stores"; + +interface StaticIpv4CardProps { + networkSettings: NetworkSettings; + onUpdate: (settings: NetworkSettings) => void; + networkState?: NetworkState; + onApply: () => void; +} + +export default function StaticIpv4Card({ + networkSettings, + onUpdate, + networkState, + onApply, +}: StaticIpv4CardProps) { + const [staticConfig, setStaticConfig] = useState({ + address: "", + netmask: "", + gateway: "", + dns: [], + }); + + // Validation errors + const addressError = + staticConfig.address && !isIP(staticConfig.address, "4") ? "Invalid IP address" : ""; + const netmaskError = + staticConfig.netmask && !isIP(staticConfig.netmask, "4") ? "Invalid subnet mask" : ""; + const gatewayError = + staticConfig.gateway && !isIP(staticConfig.gateway, "4") + ? "Invalid gateway address" + : ""; + const dnsErrors = staticConfig.dns.map(dns => + dns && !isIP(dns, "4") ? "Invalid DNS server" : "", + ); + + // Check if any field has an error or if required fields are empty + const hasValidationErrors = !!( + addressError || + netmaskError || + gatewayError || + dnsErrors.some(error => error) + ); + const hasEmptyRequiredFields = + !staticConfig.address || + !staticConfig.netmask || + !staticConfig.gateway || + staticConfig.dns.length === 0; + const isFormValid = !hasValidationErrors && !hasEmptyRequiredFields; + + // Initialize from existing settings or use current network state as defaults + useEffect(() => { + if (networkSettings.ipv4_static) { + setStaticConfig(networkSettings.ipv4_static); + } else if (networkState?.dhcp_lease) { + // Use current DHCP values as defaults + const defaults: IPv4StaticConfig = { + address: networkState.dhcp_lease.ip || "", + netmask: networkState.dhcp_lease.netmask || "", + gateway: networkState.dhcp_lease.routers?.[0] || "", + dns: networkState.dhcp_lease.dns_servers || [], + }; + setStaticConfig(defaults); + // Update the parent with these default values + onUpdate({ ...networkSettings, ipv4_static: defaults }); + } + }, [networkSettings.ipv4_static, networkState?.dhcp_lease, networkSettings, onUpdate]); + + const handleConfigChange = ( + field: keyof Omit, + value: string, + ) => { + const updatedConfig = { ...staticConfig, [field]: value }; + setStaticConfig(updatedConfig); + onUpdate({ ...networkSettings, ipv4_static: updatedConfig }); + }; + + const handleDnsChange = (index: number, value: string) => { + const updatedDns = [...staticConfig.dns]; + updatedDns[index] = value; + const updatedConfig = { ...staticConfig, dns: updatedDns }; + setStaticConfig(updatedConfig); + onUpdate({ ...networkSettings, ipv4_static: updatedConfig }); + }; + + const handleAddDnsField = () => { + const updatedConfig = { + ...staticConfig, + dns: [...staticConfig.dns, ""], + }; + setStaticConfig(updatedConfig); + onUpdate({ ...networkSettings, ipv4_static: updatedConfig }); + }; + + const handleRemoveDnsServer = (index: number) => { + const updatedConfig = { + ...staticConfig, + dns: staticConfig.dns.filter((_, i) => i !== index), + }; + setStaticConfig(updatedConfig); + onUpdate({ ...networkSettings, ipv4_static: updatedConfig }); + }; + + return ( + +
+
+

+ Static IPv4 Configuration +

+ +
+ handleConfigChange("address", e.target.value)} + error={addressError} + /> + + handleConfigChange("netmask", e.target.value)} + error={netmaskError} + /> +
+ + handleConfigChange("gateway", e.target.value)} + error={gatewayError} + /> + + {/* DNS server fields */} +
+ {staticConfig.dns.length === 0 && ( +
+
+ { + const updatedConfig = { ...staticConfig, dns: [e.target.value] }; + setStaticConfig(updatedConfig); + onUpdate({ ...networkSettings, ipv4_static: updatedConfig }); + }} + /> +
+
+ )} + + {staticConfig.dns.map((dns, index) => ( + handleDnsChange(index, e)} + onAdd={handleAddDnsField} + onRemove={handleRemoveDnsServer} + error={dnsErrors[index]} + /> + ))} +
+
+
+
+ ); +} + +export function StaticIpv4DnsField({ + value, + index, + isLast, + onChange, + onAdd, + onRemove, + error, +}: { + value: string; + index: number; + isLast: boolean; + onChange: (dns: string) => void; + onAdd: () => void; + onRemove: (index: number) => void; + error?: string; +}) { + return ( +
+
+ onChange(e.target.value)} + error={error || ""} + /> +
+
+ { + // if last item, show add button + isLast ? ( +
+
+ ); +} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index aa29528..565244e 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -683,7 +683,7 @@ export interface DhcpLease { bootp_file?: string; timezone?: string; routers?: string[]; - dns?: string[]; + dns_servers?: string[]; ntp_servers?: string[]; lpr_servers?: string[]; _time_servers?: string[]; @@ -744,11 +744,19 @@ export type TimeSyncMode = | "custom" | "unknown"; +export interface IPv4StaticConfig { + address: string; + netmask: string; + gateway: string; + dns: string[]; +} + export interface NetworkSettings { hostname: string; domain: string; http_proxy: string; ipv4_mode: IPv4Mode; + ipv4_static?: IPv4StaticConfig; ipv6_mode: IPv6Mode; lldp_mode: LLDPMode; lldp_tx_tlvs: string[]; diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 6fcd588..4139a0d 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -27,6 +27,7 @@ import Ipv6NetworkCard from "../components/Ipv6NetworkCard"; import EmptyCard from "../components/EmptyCard"; import AutoHeight from "../components/AutoHeight"; import DhcpLeaseCard from "../components/DhcpLeaseCard"; +import StaticIpv4Card from "../components/StaticIpv4Card"; import { SettingsItem } from "./devices.$id.settings"; @@ -37,6 +38,7 @@ const defaultNetworkSettings: NetworkSettings = { http_proxy: "", domain: "", ipv4_mode: "unknown", + ipv4_static: undefined, ipv6_mode: "unknown", lldp_mode: "unknown", lldp_tx_tlvs: [], @@ -127,7 +129,17 @@ export default function SettingsNetworkRoute() { const setNetworkSettingsRemote = useCallback( (settings: NetworkSettings) => { setNetworkSettingsLoaded(false); - send("setNetworkSettings", { settings }, resp => { + + // Filter out empty DNS strings from static IP config before sending + const filteredSettings = { ...settings }; + if (filteredSettings.ipv4_static?.dns) { + filteredSettings.ipv4_static = { + ...filteredSettings.ipv4_static, + dns: filteredSettings.ipv4_static.dns.filter(dns => dns.trim() !== "") + }; + } + + send("setNetworkSettings", { settings: filteredSettings }, resp => { if ("error" in resp) { notifications.error( "Failed to save network settings: " + @@ -375,7 +387,7 @@ export default function SettingsNetworkRoute() { onChange={e => handleIpv4ModeChange(e.target.value)} options={filterUnknown([ { value: "dhcp", label: "DHCP" }, - // { value: "static", label: "Static" }, + { value: "static", label: "Static" }, ])} /> @@ -390,12 +402,21 @@ export default function SettingsNetworkRoute() {
-
-
+
setNetworkSettingsRemote(networkSettings)}
- ) : networkState?.dhcp_lease && networkState.dhcp_lease.ip ? ( + ) : networkSettings.ipv4_mode === "static" ? ( + setNetworkSettingsRemote(networkSettings)} + /> + ) : networkSettings.ipv4_mode === "dhcp" && + networkState?.dhcp_lease && + networkState.dhcp_lease.ip ? ( )}