import { useCallback, useEffect, useMemo, useState } from "react"; import { Button as AntdButton, Checkbox, Input, Modal, Select } from "antd"; import { useReactAt } from "i18n-auto-extractor/react"; import { SettingsSectionHeader } from "@components/Settings/SettingsSectionHeader"; import { isMobile } from "react-device-detect"; import { SettingsItem } from "@components/Settings/SettingsView"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; import AutoHeight from "@components/AutoHeight"; import { GridCard } from "@components/Card"; import { ConfirmDialog } from "@components/ConfirmDialog"; type FirewallChain = "input" | "output" | "forward"; type FirewallAction = "accept" | "drop" | "reject"; export interface FirewallConfig { base: { inputPolicy: FirewallAction; outputPolicy: FirewallAction; forwardPolicy: FirewallAction; }; rules: FirewallRule[]; portForwards: FirewallPortRule[]; } export interface FirewallRule { chain: FirewallChain; sourceIP: string; sourcePort?: number | null; protocols: string[]; destinationIP: string; destinationPort?: number | null; action: FirewallAction; comment: string; } export interface FirewallPortRule { chain?: "output" | "prerouting" | "prerouting_redirect"; managed?: boolean; sourcePort: number; protocols: string[]; destinationIP: string; destinationPort: number; comment: string; } const defaultFirewallConfig: FirewallConfig = { base: { inputPolicy: "accept", outputPolicy: "accept", forwardPolicy: "accept" }, rules: [], portForwards: [], }; const actionOptions: { value: FirewallAction; label: string }[] = [ { value: "accept", label: "Accept" }, { value: "drop", label: "Drop" }, { value: "reject", label: "Reject" }, ]; const chainOptions: { value: FirewallChain; label: string }[] = [ { value: "input", label: "Input" }, { value: "output", label: "Output" }, { value: "forward", label: "Forward" }, ]; const commProtocolOptions = [ { key: "any", label: "Any" }, { key: "tcp", label: "TCP" }, { key: "udp", label: "UDP" }, { key: "icmp", label: "ICMP" }, { key: "igmp", label: "IGMP" }, ]; const portForwardProtocolOptions = [ { key: "tcp", label: "TCP" }, { key: "udp", label: "UDP" }, { key: "sctp", label: "SCTP" }, { key: "dccp", label: "DCCP" }, ]; function formatProtocols(protocols: string[]) { if (!protocols?.length) return "-"; if (protocols.includes("any")) return "Any"; return protocols.map(p => p.toUpperCase()).join(", "); } function actionLabel(action: FirewallAction) { return actionOptions.find(o => o.value === action)?.label ?? action; } function chainLabel(chain: FirewallChain) { return chainOptions.find(o => o.value === chain)?.label ?? chain; } function portForwardChainLabel(chain: FirewallPortRule["chain"]) { switch (chain ?? "prerouting") { case "output": return "OUTPUT"; case "prerouting_redirect": return "PREROUTING_REDIRECT"; default: return "PREROUTING"; } } function formatEndpoint(ip: string, port: number | null | undefined, anyText: string) { const t = (ip || "").trim(); if (!t && (port === null || port === undefined)) return anyText; if (!t && port !== null && port !== undefined) return `${anyText}:${port}`; if (t && (port === null || port === undefined)) return t; return `${t}:${port}`; } function normalizePort(v: string) { const t = v.trim(); if (t === "") return null; const n = Number(t); if (!Number.isFinite(n)) return null; if (n < 1 || n > 65535) return null; return Math.trunc(n); } function normalizeRuleProtocols(list: string[]) { if (list.includes("any")) return ["any"]; return list; } export default function FirewallSettings() { const { $at } = useReactAt(); const [send] = useJsonRpc(); const [activeTab, setActiveTab] = useState<"base" | "rules" | "portForwards">( "base", ); const [appliedConfig, setAppliedConfig] = useState(defaultFirewallConfig); const [baseDraft, setBaseDraft] = useState( defaultFirewallConfig.base, ); const [loading, setLoading] = useState(false); const [applying, setApplying] = useState(false); const [showBaseSubmitConfirm, setShowBaseSubmitConfirm] = useState(false); const [selectedRuleRows, setSelectedRuleRows] = useState>(new Set()); const [selectedPortForwardRows, setSelectedPortForwardRows] = useState>( new Set(), ); const [ruleModalOpen, setRuleModalOpen] = useState(false); const [ruleEditingIndex, setRuleEditingIndex] = useState(null); const [ruleDraft, setRuleDraft] = useState({ chain: "input", sourceIP: "", sourcePort: null, protocols: ["any"], destinationIP: "", destinationPort: null, action: "accept", comment: "", }); const [ruleSourcePortText, setRuleSourcePortText] = useState(""); const [ruleDestinationPortText, setRuleDestinationPortText] = useState(""); const [pfModalOpen, setPfModalOpen] = useState(false); const [pfEditingIndex, setPfEditingIndex] = useState(null); const [pfDraft, setPfDraft] = useState({ chain: "prerouting", sourcePort: 1, protocols: ["tcp"], destinationIP: "", destinationPort: 1, comment: "", }); const [pfSourcePortText, setPfSourcePortText] = useState("1"); const [pfDestinationPortText, setPfDestinationPortText] = useState("1"); const fetchConfig = useCallback(() => { setLoading(true); send("getFirewallConfig", {}, resp => { setLoading(false); if ("error" in resp) { notifications.error( `${$at("Failed to get firewall config")}: ${resp.error.data || resp.error.message}`, ); return; } const cfg = resp.result as FirewallConfig; setAppliedConfig(cfg); setBaseDraft(cfg.base); setSelectedRuleRows(new Set()); setSelectedPortForwardRows(new Set()); }); }, [send, $at]); useEffect(() => { fetchConfig(); }, [fetchConfig]); const hasBaseChanges = useMemo(() => { return JSON.stringify(appliedConfig.base) !== JSON.stringify(baseDraft); }, [appliedConfig.base, baseDraft]); const applyFirewallConfig = useCallback( ( nextConfig: FirewallConfig, opts?: { onSuccess?: () => void; successText?: string }, ) => { setApplying(true); send("setFirewallConfig", { config: nextConfig }, resp => { setApplying(false); if ("error" in resp) { notifications.error( `${$at("Failed to apply firewall config")}: ${resp.error.data || resp.error.message}`, ); return; } setAppliedConfig(nextConfig); if (opts?.successText) notifications.success(opts.successText); if (opts?.onSuccess) opts.onSuccess(); }); }, [send, $at], ); const handleBaseSubmit = useCallback(() => { const nextConfig: FirewallConfig = { ...appliedConfig, base: baseDraft }; applyFirewallConfig(nextConfig, { successText: $at("Firewall config applied"), onSuccess: () => { setShowBaseSubmitConfirm(false); }, }); }, [appliedConfig, baseDraft, applyFirewallConfig, $at]); const requestBaseSubmit = useCallback(() => { if (!hasBaseChanges) return; setShowBaseSubmitConfirm(true); }, [hasBaseChanges]); const openAddRule = () => { setRuleEditingIndex(null); setRuleDraft({ chain: "input", sourceIP: "", sourcePort: null, protocols: ["any"], destinationIP: "", destinationPort: null, action: "accept", comment: "", }); setRuleSourcePortText(""); setRuleDestinationPortText(""); setRuleModalOpen(true); }; const openEditRule = (idx: number) => { const current = appliedConfig.rules[idx]; if (!current) return; setRuleEditingIndex(idx); setRuleDraft({ ...current }); setRuleSourcePortText(current.sourcePort ? String(current.sourcePort) : ""); setRuleDestinationPortText(current.destinationPort ? String(current.destinationPort) : ""); setRuleModalOpen(true); }; const saveRuleDraft = () => { const next: FirewallRule = { ...ruleDraft, protocols: normalizeRuleProtocols(ruleDraft.protocols), sourcePort: normalizePort(ruleSourcePortText), destinationPort: normalizePort(ruleDestinationPortText), sourceIP: ruleDraft.sourceIP.trim(), destinationIP: ruleDraft.destinationIP.trim(), comment: ruleDraft.comment.trim(), }; if (!next.protocols.length) { notifications.error($at("Please select protocol")); return; } if (!next.chain || !next.action) { notifications.error($at("Missing required fields")); return; } const rules = [...appliedConfig.rules]; if (ruleEditingIndex === null) { rules.push(next); } else { rules[ruleEditingIndex] = next; } const nextConfig: FirewallConfig = { ...appliedConfig, rules, }; applyFirewallConfig(nextConfig, { successText: $at("Firewall config applied"), onSuccess: () => { setRuleModalOpen(false); setSelectedRuleRows(new Set()); }, }); }; const deleteSelectedRules = () => { const idxs = [...selectedRuleRows.values()].sort((a, b) => a - b); if (!idxs.length) return; const nextRules = appliedConfig.rules.filter((_, i) => !selectedRuleRows.has(i)); const nextConfig: FirewallConfig = { ...appliedConfig, rules: nextRules, }; applyFirewallConfig(nextConfig, { successText: $at("Firewall config applied"), onSuccess: () => { setSelectedRuleRows(new Set()); }, }); }; const openAddPortForward = () => { setPfEditingIndex(null); setPfDraft({ chain: "prerouting", sourcePort: 1, protocols: ["tcp"], destinationIP: "", destinationPort: 1, comment: "", }); setPfSourcePortText("1"); setPfDestinationPortText("1"); setPfModalOpen(true); }; const openEditPortForward = (idx: number) => { const current = appliedConfig.portForwards[idx]; if (!current) return; if (current.managed === false) return; setPfEditingIndex(idx); const inferredChain = current.chain ?? (current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1" ? "output" : "prerouting"); setPfDraft({ ...current, chain: inferredChain, destinationIP: inferredChain === "output" || inferredChain === "prerouting_redirect" ? "0.0.0.0" : current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1" ? "" : current.destinationIP, }); setPfSourcePortText(String(current.sourcePort)); setPfDestinationPortText(String(current.destinationPort)); setPfModalOpen(true); }; const savePortForwardDraft = () => { const srcPort = normalizePort(pfSourcePortText); const dstPort = normalizePort(pfDestinationPortText); if (!srcPort || !dstPort) { notifications.error($at("Invalid port")); return; } const next: FirewallPortRule = { ...pfDraft, sourcePort: srcPort, destinationPort: dstPort, destinationIP: (pfDraft.chain ?? "prerouting") === "output" || (pfDraft.chain ?? "prerouting") === "prerouting_redirect" ? "0.0.0.0" : pfDraft.destinationIP.trim(), protocols: normalizeRuleProtocols(pfDraft.protocols).filter(p => p !== "any"), comment: pfDraft.comment.trim(), }; const pfChain = next.chain ?? "prerouting"; if (pfChain === "prerouting" && ["0.0.0.0", "127.0.0.1"].includes(next.destinationIP.trim())) { notifications.error($at("For PREROUTING, Destination IP must be a real host IP")); return; } if (pfChain === "prerouting" && !next.destinationIP) { notifications.error($at("Destination IP is required")); return; } if (!next.protocols.length) { notifications.error($at("Please select protocol")); return; } const items = [...appliedConfig.portForwards]; if (pfEditingIndex === null) { items.push(next); } else { items[pfEditingIndex] = next; } const nextConfig: FirewallConfig = { ...appliedConfig, portForwards: items, }; applyFirewallConfig(nextConfig, { successText: $at("Firewall config applied"), onSuccess: () => { setPfModalOpen(false); setSelectedPortForwardRows(new Set()); }, }); }; const deleteSelectedPortForwards = () => { const idxs = [...selectedPortForwardRows.values()].sort((a, b) => a - b); if (!idxs.length) return; const nextItems = appliedConfig.portForwards.filter((r, i) => { if (!selectedPortForwardRows.has(i)) return true; return r.managed === false; }); const nextConfig: FirewallConfig = { ...appliedConfig, portForwards: nextItems, }; applyFirewallConfig(nextConfig, { successText: $at("Firewall config applied"), onSuccess: () => { setSelectedPortForwardRows(new Set()); }, }); }; return (
{[ { id: "base", label: $at("Basic") }, { id: "rules", label: $at("Communication Rules") }, { id: "portForwards", label: $at("Port Forwarding") }, ].map(tab => ( ))}
{activeTab === "base" && (
{$at("Input")}
setBaseDraft({ ...baseDraft, outputPolicy: v as FirewallAction, }) } options={actionOptions} />
{$at("Forward")}
setRuleDraft({ ...ruleDraft, chain: v as FirewallChain })} options={chainOptions} />
{$at("Source IP")}
setRuleDraft({ ...ruleDraft, sourceIP: e.target.value })} />
{$at("Source Port")}
setRuleSourcePortText(e.target.value)} inputMode="numeric" />
{$at("Protocol")}
{commProtocolOptions.map(p => ( { const checked = e.target.checked; const next = new Set(ruleDraft.protocols); if (checked) next.add(p.key); else next.delete(p.key); const arr = [...next.values()]; setRuleDraft({ ...ruleDraft, protocols: normalizeRuleProtocols(arr) }); }} > {p.label} ))}
{$at("Destination IP")}
setRuleDraft({ ...ruleDraft, destinationIP: e.target.value })} />
{$at("Destination Port")}
setRuleDestinationPortText(e.target.value)} inputMode="numeric" />
{$at("Action")}
setRuleDraft({ ...ruleDraft, comment: e.target.value })} />
setPfModalOpen(false)} onOk={savePortForwardDraft} confirmLoading={applying} title={$at(pfEditingIndex === null ? "Add Rule" : "Edit Rule")} okText={$at("OK")} cancelText={$at("Cancel")} destroyOnClose >
* {$at("Chain")}
setPfSourcePortText(e.target.value)} inputMode="numeric" />
{$at("Protocol")}
{portForwardProtocolOptions.map(p => ( { const checked = e.target.checked; const next = new Set(pfDraft.protocols); if (checked) next.add(p.key); else next.delete(p.key); setPfDraft({ ...pfDraft, protocols: [...next.values()] }); }} > {p.label} ))}
* {$at("Destination IP")}
setPfDraft({ ...pfDraft, destinationIP: e.target.value })} disabled={(pfDraft.chain ?? "prerouting") !== "prerouting"} />
* {$at("Destination Port")}
setPfDestinationPortText(e.target.value)} inputMode="numeric" />
{$at("Description")}
setPfDraft({ ...pfDraft, comment: e.target.value })} />
{ setShowBaseSubmitConfirm(false); }} title={$at("Submit Firewall Policies?")} description={ <>

{$at( "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.", )}

} variant="warning" cancelText={$at("Cancel")} confirmText={$at("Submit")} onConfirm={handleBaseSubmit} />
); }