diff --git a/app/src/components/Shell.tsx b/app/src/components/Shell.tsx index a18dc3ca..787662e3 100644 --- a/app/src/components/Shell.tsx +++ b/app/src/components/Shell.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { LucideIcon } from "lucide-react"; -import { Activity, ArrowLeftRight, Blocks, ChartColumn, ChartPie, Download, EyeOff, Forward, Globe, HardDrive, Info, LayoutDashboard, LogOut, Menu, Network, Radar, Server, Settings, ShieldBan, Smartphone, Wifi, Wrench } from "lucide-react"; +import { Activity, ArrowLeftRight, Blocks, ChartColumn, ChartPie, Download, EyeOff, Forward, Globe, HardDrive, Info, LayoutDashboard, LogOut, Menu, Network, Radar, Server, Settings, ShieldAlert, ShieldBan, Smartphone, Wifi, Wrench } from "lucide-react"; import { api, disableDemo, isDemo } from "../api"; import type { Board, DDNSProbe, DriftProbe, EthPort, FwdProbe, GuestProbe, IoTProbe, IPv6Probe, MultiWanProbe, OVPNProbe, PhysPortsProbe, SelfUpdateCheck, SQMProbe, StorageProbe, SystemInfo, TSProbe, UsteerAP, UpdateCheck, WanStatus, WGProbe, WirelessRadio } from "../types"; import { useHealthScore } from "../hooks/useHealthScore"; @@ -16,6 +16,7 @@ import { ClientsPage } from "../pages/Clients"; import { L2ClientsPage } from "../pages/L2Clients"; import { WifiPage } from "../pages/Wifi"; import { InternetPage } from "../pages/Internet"; +import { TrafficRulesPage } from "../pages/TrafficRules"; import { Services } from "../pages/Services"; import { Ports } from "../pages/Ports"; import { System } from "../pages/System"; @@ -26,7 +27,7 @@ import { StoragePage } from "../pages/Storage"; import { AboutPage } from "../pages/About"; import { SelfUpdateDialog } from "../components/system/SelfUpdateDialog"; -export type Page = "overview" | "internet" | "wan" | "clients" | "coverage" | "wifi" | "lan" | "services" | "traffic" | "banip" | "upnp" | "ports" | "forwards" | "tools" | "diagnostics" | "fleet" | "storage" | "system" | "dpi" | "about"; +export type Page = "overview" | "internet" | "wan" | "clients" | "coverage" | "wifi" | "lan" | "services" | "traffic" | "trafficrules" | "banip" | "upnp" | "ports" | "forwards" | "tools" | "diagnostics" | "fleet" | "storage" | "system" | "dpi" | "about"; const NAV_ICONS: Record = { overview: LayoutDashboard, @@ -38,6 +39,7 @@ const NAV_ICONS: Record = { lan: Network, services: Blocks, traffic: ChartPie, + trafficrules: ShieldAlert, banip: ShieldBan, upnp: ArrowLeftRight, ports: ArrowLeftRight, @@ -66,7 +68,7 @@ const ROLE_BADGE: Record<"router" | "ap" | "switch", { labelKey: string; cls: st const NAV_GROUPS: { group: string | null; items: Page[] }[] = [ { group: null, items: ["overview"] }, { group: "nav.group.yourNetwork", items: ["clients", "coverage", "traffic"] }, - { group: "nav.group.connection", items: ["internet", "wifi", "lan", "ports"] }, + { group: "nav.group.connection", items: ["internet", "wifi", "lan", "ports", "trafficrules"] }, { group: "nav.group.services", items: ["services"] }, { group: "nav.group.system", items: ["diagnostics", "storage", "fleet", "system", "about"] }, ]; @@ -221,7 +223,7 @@ function ShellInner({ onLogout }: { onLogout: () => void }) { if (!modeReady) return id === "overview" ? null : "loading"; // La línea de salida y todo lo que cuelga de ella (reenvío de puertos, // DPI, banIP) solo aplica a la puerta de enlace. - if ((id === "internet" || id === "wan" || id === "forwards" || id === "dpi" || id === "banip" || id === "upnp") && !isRouter) return "gatewayOnly"; + if ((id === "internet" || id === "wan" || id === "forwards" || id === "dpi" || id === "banip" || id === "upnp" || id === "trafficrules") && !isRouter) return "gatewayOnly"; // Red local (DHCP/DNS/reservas): sin dnsmasq la página quedaría vacía // (p. ej. un switch gestionado), así que la entrada no se ofrece. if (id === "lan" && !mode?.dnsmasq_on) return "noDnsmasq"; @@ -443,6 +445,7 @@ function ShellInner({ onLogout }: { onLogout: () => void }) { )} {activePage === "coverage" && } + {activePage === "trafficrules" && } {activePage === "wifi" && ( )} diff --git a/app/src/components/firewall/TrafficRulesCard.tsx b/app/src/components/firewall/TrafficRulesCard.tsx new file mode 100644 index 00000000..3e50521e --- /dev/null +++ b/app/src/components/firewall/TrafficRulesCard.tsx @@ -0,0 +1,441 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowRight, Clock, Globe, Home, Plus, Router, ShieldAlert, Trash2 } from "lucide-react"; +import { api } from "../../api"; +import type { FirewallProbe, FWRule, FirewallRuleAdd, FWZone } from "../../types"; +import { + ActionBanner, Banner, Button, Card, ConfirmDialog, EmptyState, Field, Modal, + Pill, SegmentedControl, SkeletonRows, type PillTone, +} from "../ui"; +import { useActionCycle } from "../wifi/action"; + +const TARGET_LABELS: Record = { + ACCEPT: "trafficRules.allow", + DROP: "trafficRules.deny", + REJECT: "trafficRules.reject", +}; + +function toneFor(v: string): PillTone { + if (v === "ACCEPT") return "ok"; + if (v === "REJECT" || v === "DROP") return "danger"; + return "muted"; +} + +/* ── Anti lock-out (espejo del backend lockoutRisk) ─────────────────── */ + +function ipv4ToInt(ip: string): number { + const parts = ip.split(".").map(Number); + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return NaN; + return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; +} + +function cidrToRange(cidr: string): { start: number; end: number } | null { + const [ip, bitsStr] = cidr.split("/"); + const bits = Number(bitsStr); + if (!Number.isInteger(bits) || bits < 0 || bits > 32) return null; + const start = ipv4ToInt(ip); + if (Number.isNaN(start)) return null; + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + const netStart = (start & mask) >>> 0; + const end = (netStart | (~mask >>> 0)) >>> 0; + return { start: netStart, end }; +} + +function cidrContains(outer: string, inner: string): boolean { + const o = cidrToRange(outer); + const i = cidrToRange(inner); + if (!o || !i) return false; + return o.start <= i.start && i.end <= o.end; +} + +function lockoutRisk(rule: FirewallRuleAdd, mgmtCidr?: string): boolean { + if (rule.target !== "DROP" && rule.target !== "REJECT") return false; + if (rule.dest_ip || rule.dest_port || rule.src_port) return false; + const src = (rule.src_ip ?? "").trim(); + if (!src || src === "0.0.0.0/0" || src === "0.0.0.0") return true; + if (mgmtCidr && cidrContains(src, mgmtCidr)) return true; + return false; +} + +/* ── Diagrama de zonas (solo lectura) ───────────────────────────────── */ + +function ZoneDiagram({ zones }: { zones: FWZone[] }) { + const { t } = useTranslation(); + const wan = zones.find((z) => z.name === "wan"); + const lan = zones.find((z) => z.name === "lan"); + const inP = wan?.input ?? "-"; + const outP = wan?.output ?? "-"; + const fwdP = wan?.forward ?? "-"; + const homeIn = lan?.input ?? "-"; + const homeFwd = lan?.forward ?? "-"; + const policyColor = (p: string) => p === "ACCEPT" ? "var(--color-ok)" : p === "REJECT" || p === "DROP" ? "var(--color-danger)" : "var(--color-muted)"; + const policyLabel = (p: string) => t(TARGET_LABELS[p] ?? "", { defaultValue: p }); + const dot = (label: string, p: string) => ( + + + ); + const Connector = ({ p, label }: { p: string; label: string }) => ( +
+
+ {policyLabel(p)} +
+
+ ); + const ZoneCard = ({ icon: Icon, title, network, dots, tone }: { + icon: typeof Globe; title: string; network: string; dots: React.ReactNode; tone?: "accent"; + }) => ( +
+
+ + {title} +
+

{network}

+
{dots}
+
+ ); + + return ( +
+ {dot("in", inP)}{dot("out", outP)}{dot("fwd", fwdP)}} /> + + {dot("in", inP)}{dot("fwd", fwdP)}} /> + + {dot("in", homeIn)}{dot("fwd", homeFwd)}} /> +
+ ); +} + +/* ── Editor de reglas de tráfico ────────────────────────────────────── */ + +export function TrafficRulesCard() { + const { t } = useTranslation(); + const [probe, setProbe] = useState(); + const { phase, detail, busy, run, clear } = useActionCycle(); + const [modal, setModal] = useState(false); + const [toDelete, setToDelete] = useState(); + + useEffect(() => { api.firewall().then(setProbe).catch(() => {}); }, []); + + const managed = probe?.rules.filter((r) => r.managed) ?? []; + const systemRules = probe?.rules.filter((r) => !r.managed) ?? []; + + const addRule = (rule: FirewallRuleAdd) => { + run(() => api.addFirewallRule(rule)).then((res) => { + if (res?.status === "applied") { + setProbe(res.state); + setModal(false); + } + }); + }; + + const delRule = (section: string) => { + run(() => api.deleteFirewallRule(section)).then((res) => { + if (res) setProbe(res.state); + }); + }; + + return ( +
+ setModal(true)}>{t("trafficRules.newRule")} + ) + } + > + {!probe ? ( + + ) : !probe.applicable ? ( + {t("trafficRules.notApplicable")} + ) : ( + <> + + +
+ {(probe.zones ?? []).map((z) => ( + + {z.name} + {(z.network ?? []).join(", ") || "-"} + {t(TARGET_LABELS[z.input] ?? "trafficRules.allow", { defaultValue: z.input })} + {z.masq && NAT} + + ))} +
+ + {phase && ( +
+ +
+ )} + +
+

{t("trafficRules.rulesTitle", { count: managed.length })}

+ {managed.length === 0 && systemRules.length === 0 ? ( + setModal(true)}>{t("trafficRules.firstRule")}} + /> + ) : ( +
    + {managed.map((r) => setToDelete(r)} busy={busy} />)} + {systemRules.length > 0 && ( +
  • +

    {t("trafficRules.systemRules")}

    +
      + {systemRules.map((r) => )} +
    +
  • + )} +
+ )} +
+ + )} +
+ + setModal(false)} + onConfirm={addRule} + /> + + setToDelete(undefined)} + onConfirm={() => { const r = toDelete; setToDelete(undefined); if (r) delRule(r.section); }} + title={t("trafficRules.deleteTitle", { name: toDelete?.name || "" })} + consequence={t("trafficRules.deleteConsequence")} + confirmLabel={t("trafficRules.deleteConfirm")} + busy={busy} + /> +
+ ); +} + +function RuleRow({ r, readOnly = false, onDelete, busy }: { + r: FWRule; + readOnly?: boolean; + onDelete?: () => void; + busy: boolean; +}) { + const { t } = useTranslation(); + const src = r.src_ip ? r.src_ip : t("trafficRules.any"); + const dest = r.dest_ip ? `${r.dest_ip}${r.dest_port ? `:${r.dest_port}` : ""}` : r.dest_port ? `*:${r.dest_port}` : t("trafficRules.any"); + const schedule = useMemo(() => { + const parts: string[] = []; + if (r.start_time || r.stop_time) parts.push(`${r.start_time || "00:00"}-${r.stop_time || "23:59"}`); + if (r.weekdays) parts.push(r.weekdays); + return parts.join(" · "); + }, [r.start_time, r.stop_time, r.weekdays]); + + return ( +
  • + {r.name || r.section} + {t("trafficRules.from")} {src} + {t("trafficRules.to")} {dest} + {r.proto && {r.proto}} + {t(TARGET_LABELS[r.target] ?? "trafficRules.allow", { defaultValue: r.target })} + {schedule && ( + + + )} + {!r.enabled && {t("trafficRules.disabled")}} + {readOnly ? ( + {t("trafficRules.system")} + ) : ( + + )} +
  • + ); +} + +/* ── Modal "Nueva regla" ────────────────────────────────────────────── */ + +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +function NewTrafficRuleModal({ open, mgmtCidr, busy, onClose, onConfirm }: { + open: boolean; + mgmtCidr?: string; + busy: boolean; + onClose: () => void; + onConfirm: (rule: FirewallRuleAdd) => void; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(""); + const [target, setTarget] = useState("ACCEPT"); + const [srcMode, setSrcMode] = useState<"any" | "ip">("any"); + const [srcIp, setSrcIp] = useState(""); + const [destIp, setDestIp] = useState(""); + const [destPort, setDestPort] = useState(""); + const [proto, setProto] = useState(""); + const [startTime, setStartTime] = useState(""); + const [stopTime, setStopTime] = useState(""); + const [days, setDays] = useState>(new Set()); + const [utc, setUtc] = useState(false); + + const rule: FirewallRuleAdd = { + name, + proto, + src_ip: srcMode === "any" ? "" : srcIp.trim(), + dest_ip: destIp.trim(), + dest_port: destPort.trim(), + target, + start_time: startTime.trim(), + stop_time: stopTime.trim(), + weekdays: [...days].join(" "), + utc_time: utc, + }; + + const risk = lockoutRisk(rule, mgmtCidr); + + const validName = name.trim() !== ""; + const validSrc = srcMode === "any" || srcIp.trim() !== ""; + const portOk = (p: string) => !p || /^(\d{1,5})(-\d{1,5})?$/.test(p); + const valid = validName && validSrc && portOk(destPort); + + const toggleDay = (d: string) => { + setDays((prev) => { + const next = new Set(prev); + if (next.has(d)) next.delete(d); else next.add(d); + return next; + }); + }; + + return ( + + + + + } + > + {risk && ( + {t("trafficRules.lockoutWarning")} + )} + + setName(e.target.value), maxLength: 60, placeholder: t("trafficRules.descriptionPlaceholder") }} /> + +
    +

    {t("trafficRules.action")}

    + +
    + +
    +

    {t("trafficRules.from")}

    + + {srcMode === "ip" && ( +
    + setSrcIp(e.target.value), placeholder: "192.168.1.0/24" }} /> +
    + )} +
    + +
    + setDestIp(e.target.value), placeholder: "10.0.0.9" }} /> + setDestPort(e.target.value), placeholder: "80-443", inputMode: "numeric" }} /> +
    + +
    +

    {t("trafficRules.proto")}

    + +
    + +
    +

    {t("trafficRules.schedule")}

    +
    + setStartTime(e.target.value), placeholder: "22:00" }} /> + setStopTime(e.target.value), placeholder: "07:00" }} /> +
    +
    + {DAYS.map((d) => ( + + ))} +
    + +
    +
    + ); +} diff --git a/app/src/components/services/FirewallCard.tsx b/app/src/components/services/FirewallCard.tsx deleted file mode 100644 index 17943214..00000000 --- a/app/src/components/services/FirewallCard.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Plus, ShieldAlert, Trash2, Globe, Router, Home, ArrowRight } from "lucide-react"; -import { api } from "../../api"; -import type { FirewallProbe, FWRule, FWZone } from "../../types"; -import { - ActionBanner, AdvancedDisclosure, Button, Card, ConfirmDialog, Input, - Pill, SkeletonRows, type PillTone, -} from "../ui"; -import { useActionCycle } from "../wifi/action"; - -/** Selects con el mismo estilo "filled" del Input de foundations (§4): - * fill + borde transparente en reposo, anillo accent al focus. */ -const SELECT_CLS = `h-[var(--input-h)] rounded-[10px] border border-transparent bg-fill px-2 text-small - outline-none transition-[background-color,border-color,box-shadow] duration-[var(--dur-fast)] - hover:border-border-strong focus:bg-surface focus:shadow-[0_0_0_2px_var(--color-accent)]`; - -/** - * Cortafuegos (services.md §9): informativo, sin toggle global (no existe en - * la API). Mini-diagrama SVG Internet → Router → Tu casa con la política de - * entrada real de la zona wan; zonas como chips; reglas bajo disclosure con - * alta validada y borrado con ConfirmDialog. - */ -export function FirewallCard({ index = 0 }: { index?: number }) { - const { t } = useTranslation(); - const [probe, setProbe] = useState(); - const { phase, detail, busy, run, clear } = useActionCycle(); - const [doneMsg, setDoneMsg] = useState(); - const [showAdd, setShowAdd] = useState(false); - const [delTarget, setDelTarget] = useState(); - const [form, setForm] = useState({ name: "", src: "wan", dest: "", proto: "tcp", dest_port: "", target: "ACCEPT" }); - - useEffect(() => { api.firewall().then(setProbe).catch(() => {}); }, []); - - if (probe && !probe.applicable) return null; - - const addRule = async () => { - setDoneMsg(undefined); - const res = await run(() => api.addFirewallRule(form)); - if (res) { - setProbe(res.state); - if (res.status === "applied") { - setShowAdd(false); - setForm({ name: "", src: "wan", dest: "", proto: "tcp", dest_port: "", target: "ACCEPT" }); - setDoneMsg(t("firewall.ruleAdded")); - } - } - }; - - const delRule = async (section: string) => { - setDoneMsg(undefined); - const res = await run(() => api.deleteFirewallRule(section)); - if (res) { - setProbe(res.state); - if (res.status === "applied") setDoneMsg(t("firewall.ruleDeleted")); - } - }; - - return ( - {t("firewall.protecting")}}> - {!probe ? ( - - ) : ( - <> - - - {/* Zonas como chips */} -
    - {(probe.zones ?? []).map((z) => ( - - {z.name} - {(z.network ?? []).join(", ") || "—"} - {t(TARGET_LABELS[z.input] ?? "firewall.accept", { defaultValue: z.input })} - {z.masq && NAT} - - ))} -
    - - {phase && ( -
    - -
    - )} - - -
    - {(probe.rules ?? []).length === 0 ? ( -

    {t("firewall.noRules")}

    - ) : ( - <> -
    - {t("firewall.colName")} - {t("firewall.colFrom")} - {t("firewall.colTo")} - {t("firewall.colPort")} - {t("firewall.colAction")} - -
    -
      - {(probe.rules ?? []).map((r) => ( -
    • - {r.name || r.section} - - {r.src || "*"} - - - {r.dest || "*"} - - {r.proto} {r.dest_port} - {t(TARGET_LABELS[r.target] ?? "firewall.accept", { defaultValue: r.target })} - -
    • - ))} -
    - - )} - - {showAdd ? ( -
    -
    - setForm({ ...form, name: e.target.value })} - placeholder={t("firewall.ruleName")} maxLength={40} /> -
    - - - setForm({ ...form, dest_port: e.target.value })} - placeholder={t("firewall.destPort")} inputMode="numeric" /> - -
    - - -
    -
    - ) : ( -
    - -
    - )} -
    -
    - - setDelTarget(undefined)} - onConfirm={() => { - const r = delTarget; - setDelTarget(undefined); - if (r) delRule(r.section); - }} - title={t("firewall.deleteTitle", { name: delTarget?.name || delTarget?.section || "" })} - consequence={t("firewall.deleteConsequence")} - confirmLabel={t("firewall.deleteConfirm")} - busy={busy} - /> - - )} -
    - ); -} - -function toneFor(v: string): PillTone { - if (v === "ACCEPT") return "ok"; - if (v === "REJECT" || v === "DROP") return "danger"; - return "muted"; -} - -const TARGET_LABELS: Record = { - ACCEPT: "firewall.accept", - REJECT: "firewall.reject", - DROP: "firewall.drop", -}; - -function ZoneDiagram({ zones }: { zones: FWZone[] }) { - const { t } = useTranslation(); - const wan = zones.find((z) => z.name === "wan"); - const lan = zones.find((z) => z.name === "lan"); - const inP = wan?.input ?? "—"; - const outP = wan?.output ?? "—"; - const fwdP = wan?.forward ?? "—"; - const homeIn = lan?.input ?? "—"; - const homeFwd = lan?.forward ?? "—"; - const policyColor = (p: string) => p === "ACCEPT" ? "var(--color-ok)" : p === "REJECT" || p === "DROP" ? "var(--color-danger)" : "var(--color-muted)"; - const policyLabel = (p: string) => t(TARGET_LABELS[p] ?? "", { defaultValue: p }); - const dot = (label: string, p: string) => ( - - - ); - const Connector = ({ p, label }: { p: string; label: string }) => ( -
    -
    - {policyLabel(p)} -
    -
    - ); - const ZoneCard = ({ icon: Icon, title, network, dots, tone }: { - icon: typeof Globe; title: string; network: string; dots: React.ReactNode; tone?: "accent"; - }) => ( -
    -
    - - {title} -
    -

    {network}

    -
    {dots}
    -
    - ); - - return ( -
    - {dot("in", inP)}{dot("out", outP)}{dot("fwd", fwdP)}} /> - - - - {dot("in", inP)}{dot("fwd", fwdP)}} /> - - - - {dot("in", homeIn)}{dot("fwd", homeFwd)}} /> -
    - ); -} diff --git a/app/src/demo/data.ts b/app/src/demo/data.ts index a9c38e49..b6c6a098 100644 --- a/app/src/demo/data.ts +++ b/app/src/demo/data.ts @@ -509,12 +509,17 @@ export const demoNlbwmon: T.NlbwmonProbe = { export const demoFirewall: T.FirewallProbe = { applicable: true, + mgmt_cidr: "192.168.1.0/24", zones: [ { name: "lan", input: "ACCEPT", output: "ACCEPT", forward: "ACCEPT", network: ["lan"], masq: false }, { name: "wan", input: "REJECT", output: "ACCEPT", forward: "REJECT", network: ["wan"], masq: true }, { name: "guest", input: "REJECT", output: "ACCEPT", forward: "REJECT", network: ["guest"], masq: false }, ], - rules: [], + rules: [ + { name: "Bloquear la tele por la noche", section: "netgrip_fw_1", proto: "tcp", src_ip: "192.168.1.50", dest_port: "80-443", target: "REJECT", start_time: "22:00", stop_time: "07:00", weekdays: "Mon Tue Wed Thu Fri Sun", enabled: true, managed: true }, + { name: "Impresora sin salida a Internet", section: "netgrip_fw_2", proto: "", src_ip: "192.168.1.60", target: "DROP", enabled: true, managed: true }, + { name: "Allow-DHCP-Renew", section: "allow-dhcp", proto: "udp", dest_port: "68", target: "ACCEPT", enabled: true, managed: false }, + ], }; // Mismos datos que internal/modules/templates.go (ListTemplates), para que el diff --git a/app/src/demo/index.ts b/app/src/demo/index.ts index f4de0588..6c1dcfcd 100644 --- a/app/src/demo/index.ts +++ b/app/src/demo/index.ts @@ -509,7 +509,8 @@ export const demoApi: typeof api = { }, firewall: () => get(state.firewall), addFirewallRule: async (rule) => { - state.firewall.rules.push({ section: `rule_${state.firewall.rules.length}`, ...rule }); + const n = state.firewall.rules.filter((r) => r.managed).length + 1; + state.firewall.rules.push({ section: `netgrip_fw_${n}`, managed: true, enabled: true, ...rule }); return write(state.firewall); }, deleteFirewallRule: async (section) => { diff --git a/app/src/locales/en.ts b/app/src/locales/en.ts index 0e79bfc4..77c3951e 100644 --- a/app/src/locales/en.ts +++ b/app/src/locales/en.ts @@ -36,6 +36,7 @@ export default { clients: "Clients", coverage: "Coverage", traffic: "Traffic", + trafficrules: "Traffic rules", wifi: "Wi-Fi", lan: "Local network", ports: "Ports", @@ -65,6 +66,7 @@ export default { clients: "Devices connected to your network", coverage: "How Wi-Fi is spread across your devices", traffic: "Which devices and apps use the most", + trafficrules: "Firewall policies: which traffic is allowed or blocked", wifi: "Networks, passwords and who's connected", lan: "Home addresses and IP reservations", ports: "Switch ports, VLANs and PoE power", @@ -257,7 +259,7 @@ export default { }, firewall: { title: "What does the firewall do?", - body: "It decides which traffic can enter your home from the Internet. The default setup (nothing gets in uninvited) is the right one.", + body: "Choose which traffic is allowed or blocked on your network: by source, destination, port, protocol and schedule. The default rules (nothing comes in uninvited) are correct.", }, lan: { title: "Your home addresses", @@ -2088,36 +2090,47 @@ export default { doneOn: "Done. The log is running.", doneOff: "Done. The log is stopped.", }, - firewall: { - title: "Firewall", - intro: "fw4 (nftables) zones and rules. Rules control which traffic is allowed or blocked between zones.", - zones: "Zones", - rules: "Rules", - addRule: "Add rule", - ruleName: "Rule name", - destPort: "Destination port", - add: "Add", - cancel: "Cancel", - confirmDel: "Delete", - noRules: "No custom rules", - ruleAdded: "Rule added", - ruleDeleted: "Rule deleted", - protecting: "Protecting", - accept: "Allow", + trafficRules: { + title: "Traffic rules", + newRule: "New rule", + notApplicable: "The firewall is not enabled on this device.", + rulesTitle_one: "{{count}} managed rule", + rulesTitle_other: "{{count}} managed rules", + empty: "No traffic rules yet", + firstRule: "Create the first rule", + systemRules: "System rules (read-only)", + system: "System", + externalHint: "System rules are not edited here", + disabled: "Disabled", + from: "From", + to: "To", + any: "Any", + allow: "Allow", + deny: "Deny", reject: "Reject", - drop: "Drop", + deleteTitle: "Delete {{name}}", + deleteConsequence: "The traffic this rule restricted will be unrestricted.", + deleteConfirm: "Delete rule", + modalTitle: "New traffic rule", + save: "Save", + description: "Description", + descriptionPlaceholder: "e.g. Block the TV at night", + action: "Action", + srcIp: "Source (IP or CIDR)", + destIp: "Destination (IP or CIDR)", + destIpHint: "Optional", + destPort: "Destination port", + destPortHint: "One or a range, e.g. 80-443", + proto: "Protocol", + schedule: "Schedule", + startTime: "From", + stopTime: "Until", + utc: "Use UTC time", + lockoutWarning: "This rule blocks all traffic from a broad source and could lock you out of the panel. Review the source before saving.", diagramInternet: "Internet", diagramRouter: "Router", diagramHome: "Your home", policyIn: "In: {{policy}}", - colName: "Name", - colFrom: "From", - colTo: "To", - colPort: "Port", - colAction: "Action", - deleteTitle: "Delete {{name}}", - deleteConsequence: "The traffic this rule allowed will be blocked.", - deleteConfirm: "Delete rule", }, adguard: { title: "Ad blocker", diff --git a/app/src/locales/es.ts b/app/src/locales/es.ts index 13b58ff9..dfc1a97e 100644 --- a/app/src/locales/es.ts +++ b/app/src/locales/es.ts @@ -36,6 +36,7 @@ export default { clients: "Clientes", coverage: "Cobertura", traffic: "Tráfico", + trafficrules: "Reglas de tráfico", wifi: "Inalámbrica", lan: "Red local", ports: "Puertos", @@ -65,6 +66,7 @@ export default { clients: "Equipos conectados a la red", coverage: "Cómo se reparte el Wi-Fi entre tus equipos", traffic: "Qué dispositivos y apps gastan más", + trafficrules: "Políticas del cortafuegos: qué tráfico se permite o bloquea", wifi: "Redes, claves y quién está conectado", lan: "Direcciones de tu casa y reservas de IP", ports: "Bocas del switch, VLANs y alimentación PoE", @@ -257,7 +259,7 @@ export default { }, firewall: { title: "¿Qué hace el cortafuegos?", - body: "Decide qué tráfico puede entrar de Internet a tu casa. La configuración por defecto (nada entra sin invitación) es la correcta.", + body: "Elige qué tráfico se permite o se bloquea en tu red: por origen, destino, puerto, protocolo y horario. Las reglas por defecto (nada entra sin invitación) son las correctas.", }, lan: { title: "Las direcciones de tu casa", @@ -2088,36 +2090,47 @@ export default { doneOn: "Listo. El registro está activo.", doneOff: "Listo. El registro está parado.", }, - firewall: { - title: "Cortafuegos", - intro: "Zonas y reglas del firewall fw4 (nftables). Las reglas controlan que trafico se permite o bloquea entre zonas.", - zones: "Zonas", - rules: "Reglas", - addRule: "Añadir regla", - ruleName: "Nombre de la regla", - destPort: "Puerto destino", - add: "Añadir", - cancel: "Cancelar", - confirmDel: "Borrar", - noRules: "Sin reglas personalizadas", - ruleAdded: "Regla añadida", - ruleDeleted: "Regla borrada", - protecting: "Protegiendo", - accept: "Permitir", + trafficRules: { + title: "Reglas de tráfico", + newRule: "Nueva regla", + notApplicable: "El firewall no está activo en este equipo.", + rulesTitle_one: "{{count}} regla gestionada", + rulesTitle_other: "{{count}} reglas gestionadas", + empty: "Sin reglas de tráfico todavía", + firstRule: "Crear la primera regla", + systemRules: "Reglas del sistema (solo lectura)", + system: "Sistema", + externalHint: "Las reglas del sistema no se editan aquí", + disabled: "Desactivada", + from: "Origen", + to: "Destino", + any: "Cualquiera", + allow: "Permitir", + deny: "Bloquear", reject: "Rechazar", - drop: "Descartar", + deleteTitle: "Borrar {{name}}", + deleteConsequence: "El tráfico que regulaba esta regla quedará sin restricción.", + deleteConfirm: "Borrar regla", + modalTitle: "Nueva regla de tráfico", + save: "Guardar", + description: "Descripción", + descriptionPlaceholder: "p. ej. Bloquear la tele por la noche", + action: "Acción", + srcIp: "Origen (IP o CIDR)", + destIp: "Destino (IP o CIDR)", + destIpHint: "Opcional", + destPort: "Puerto destino", + destPortHint: "Uno o un rango, p. ej. 80-443", + proto: "Protocolo", + schedule: "Horario", + startTime: "Desde", + stopTime: "Hasta", + utc: "Usar hora UTC", + lockoutWarning: "Esta regla bloquea todo el tráfico desde una fuente amplia y puede dejarte sin acceso al panel. Revisa el origen antes de guardar.", diagramInternet: "Internet", diagramRouter: "Router", diagramHome: "Tu casa", policyIn: "Entrada: {{policy}}", - colName: "Nombre", - colFrom: "Origen", - colTo: "Destino", - colPort: "Puerto", - colAction: "Acción", - deleteTitle: "Borrar {{name}}", - deleteConsequence: "El tráfico que permitía esta regla quedará bloqueado.", - deleteConfirm: "Borrar regla", }, adguard: { title: "Bloqueador de anuncios", diff --git a/app/src/pages/Services.tsx b/app/src/pages/Services.tsx index 397e0a44..abb73538 100644 --- a/app/src/pages/Services.tsx +++ b/app/src/pages/Services.tsx @@ -7,7 +7,6 @@ import { TailscaleCard } from "../components/services/TailscaleCard"; import { DdnsCard } from "../components/services/DdnsCard"; import { SqmCard } from "../components/services/SqmCard"; import { NlbwmonCard } from "../components/services/NlbwmonCard"; -import { FirewallCard } from "../components/services/FirewallCard"; import { AdguardCard } from "../components/services/AdguardCard"; import { LanServicesCard } from "../components/services/LanServicesCard"; import { BanipCard } from "../components/services/BanipCard"; @@ -53,7 +52,6 @@ export function Services({ wg, onWgChange, ddns, onDdnsChange, sqm, onSqmChange, )} {!apMode && } - {!apMode && } {!apMode && } diff --git a/app/src/pages/TrafficRules.tsx b/app/src/pages/TrafficRules.tsx new file mode 100644 index 00000000..2c891b9a --- /dev/null +++ b/app/src/pages/TrafficRules.tsx @@ -0,0 +1,15 @@ +import { TrafficRulesCard } from "../components/firewall/TrafficRulesCard"; + +/** + * Reglas de tráfico (#444): editor de políticas de firewall estilo UniFi + * (allow/deny/reject con origen IP/CIDR o cualquiera, destino + rango de + * puertos, protocolo y horario). Reemplaza a la antigua card de cortafuegos + * de Servicios: este es el único escritor de firewall.rule del panel. + */ +export function TrafficRulesPage() { + return ( +
    + +
    + ); +} diff --git a/app/src/types.ts b/app/src/types.ts index 44a29628..d8dcd19f 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -984,26 +984,39 @@ export interface FWZone { export interface FWRule { name: string; section: string; - src: string; - dest: string; proto: string; - dest_port: string; + src_ip?: string; + dest_ip?: string; + src_port?: string; + dest_port?: string; target: string; + start_time?: string; + stop_time?: string; + weekdays?: string; + utc_time?: boolean; + enabled: boolean; + managed: boolean; } export interface FirewallProbe { applicable: boolean; zones: FWZone[]; rules: FWRule[]; + mgmt_cidr?: string; } export interface FirewallRuleAdd { name: string; - src: string; - dest: string; proto: string; - dest_port: string; + src_ip?: string; + dest_ip?: string; + src_port?: string; + dest_port?: string; target: string; + start_time?: string; + stop_time?: string; + weekdays?: string; + utc_time?: boolean; } export interface Template { diff --git a/internal/modules/firewall.go b/internal/modules/firewall.go index f3141557..1768c192 100644 --- a/internal/modules/firewall.go +++ b/internal/modules/firewall.go @@ -2,13 +2,24 @@ package modules import ( "fmt" - "os/exec" + "net" + "regexp" "sort" + "strconv" "strings" "github.com/gnacho/netgrip/internal/executor" ) +// fwRulePrefix names the UCI sections NetGrip owns for traffic rules +// (firewall.rule sections named netgrip_fw_N). Anything else is a system +// rule (LuCI, the firmware factory config): listed read-only, never edited +// or deleted from here. +const fwRulePrefix = "netgrip_fw_" + +// reFwRuleSection matches a managed traffic rule section name (netgrip_fw_1). +var reFwRuleSection = regexp.MustCompile(`^` + fwRulePrefix + `[0-9]+$`) + type FWZone struct { Name string `json:"name"` Input string `json:"input"` @@ -18,35 +29,63 @@ type FWZone struct { Masq bool `json:"masq"` } +// FWRule is one firewall traffic rule. It models fw4's address-based rule +// options: a source IP/CIDR (empty = any), an optional destination IP/CIDR +// and port range, a protocol (empty = any), the action (ACCEPT/DROP/REJECT) +// and the schedule (start_time/stop_time/weekdays/utc_time). The zone-based +// options (src/dest zones) are not part of this editor; system rules that use +// them still show here read-only, with empty address fields. type FWRule struct { - Name string `json:"name"` - Section string `json:"section"` - Src string `json:"src"` - Dest string `json:"dest"` - Proto string `json:"proto"` - DestPort string `json:"dest_port"` - Target string `json:"target"` + Name string `json:"name"` + Section string `json:"section"` + Proto string `json:"proto"` + SrcIP string `json:"src_ip,omitempty"` + DestIP string `json:"dest_ip,omitempty"` + SrcPort string `json:"src_port,omitempty"` + DestPort string `json:"dest_port,omitempty"` + Target string `json:"target"` + StartTime string `json:"start_time,omitempty"` + StopTime string `json:"stop_time,omitempty"` + Weekdays string `json:"weekdays,omitempty"` + UTCTime bool `json:"utc_time,omitempty"` + Enabled bool `json:"enabled"` + // Managed: this rule was created by NetGrip and can be edited/removed + // here. Anything set up in LuCI or shipped by the firmware is read-only. + Managed bool `json:"managed"` } type FirewallProbe struct { Applicable bool `json:"applicable"` Zones []FWZone `json:"zones"` Rules []FWRule `json:"rules"` + // MgmtCIDR is the LAN subnet the panel is reached from, used by the UI + // to warn when a deny/reject rule could cut panel access. + MgmtCIDR string `json:"mgmt_cidr,omitempty"` +} + +// mgmtCIDR returns the LAN subnet (ipaddr/netmask) as a CIDR string, or "" +// when unresolvable. The panel is reached over the LAN, so this is the +// management subnet the anti-lockout check cares about. +func mgmtCIDR() string { + return subnetCIDR(uciGet("network.lan.ipaddr"), uciGet("network.lan.netmask")) } +// ProbeFirewall reads the firewall zones and rules. Both parse the same +// `uci show firewall` output, read once and memoized 2s (#356). func ProbeFirewall() *FirewallProbe { if !executor.ServiceEnabled("firewall") { - return &FirewallProbe{Applicable: false, Zones: []FWZone{}, Rules: []FWRule{}} + return &FirewallProbe{Applicable: false, Zones: []FWZone{}, Rules: []FWRule{}, MgmtCIDR: mgmtCIDR()} } - return &FirewallProbe{Applicable: true, Zones: parseFWZones(), Rules: parseFWRules()} -} - -func parseFWZones() []FWZone { - out, err := exec.Command("uci", "show", "firewall").Output() - if err != nil { - return []FWZone{} + out, ok := uciShowCached("firewall") + if !ok { + out = "" + } + return &FirewallProbe{ + Applicable: true, + Zones: parseFWZonesFrom(out), + Rules: parseFWRulesFrom(out), + MgmtCIDR: mgmtCIDR(), } - return parseFWZonesFrom(string(out)) } type rawZone struct { @@ -124,26 +163,25 @@ func parseFWZonesFrom(out string) []FWZone { return result } -func parseFWRules() []FWRule { - out, err := exec.Command("uci", "show", "firewall").Output() - if err != nil { - return []FWRule{} - } - return parseFWRulesFrom(string(out)) -} - type rawRule struct { - section string - name string - src string - dest string - proto string - destPort string - target string + section string + name string + proto string + srcIP string + destIP string + srcPort string + destPort string + target string + startTime string + stopTime string + weekdays string + utcTime bool + enabled bool } // parseFWRulesFrom parses `uci show firewall` output for "rule" sections. -// Section headers print as "firewall.
    =rule". Never returns nil. +// Section headers print as "firewall.
    =rule". Managed is true for +// sections named netgrip_fw_N. Never returns nil. func parseFWRulesFrom(out string) []FWRule { rules := map[string]*rawRule{} for _, line := range strings.Split(out, "\n") { @@ -162,7 +200,7 @@ func parseFWRulesFrom(out string) []FWRule { sec, opt, hasOpt := strings.Cut(rel, ".") if !hasOpt { if val == "rule" { - rules[sec] = &rawRule{section: sec} + rules[sec] = &rawRule{section: sec, enabled: true} } continue } @@ -173,102 +211,338 @@ func parseFWRulesFrom(out string) []FWRule { switch opt { case "name": r.name = val - case "src": - r.src = val - case "dest": - r.dest = val case "proto": r.proto = val + case "src_ip": + r.srcIP = val + case "dest_ip": + r.destIP = val + case "src_port": + r.srcPort = val case "dest_port": r.destPort = val case "target": r.target = val + case "start_time": + r.startTime = val + case "stop_time": + r.stopTime = val + case "weekdays": + r.weekdays = val + case "utc_time": + r.utcTime = val == "1" + case "enabled": + r.enabled = val != "0" } } result := make([]FWRule, 0, len(rules)) for _, r := range rules { result = append(result, FWRule{ - Name: r.name, - Section: r.section, - Src: r.src, - Dest: r.dest, - Proto: r.proto, - DestPort: r.destPort, - Target: r.target, + Name: r.name, + Section: r.section, + Proto: r.proto, + SrcIP: r.srcIP, + DestIP: r.destIP, + SrcPort: r.srcPort, + DestPort: r.destPort, + Target: r.target, + StartTime: r.startTime, + StopTime: r.stopTime, + Weekdays: r.weekdays, + UTCTime: r.utcTime, + Enabled: r.enabled, + Managed: reFwRuleSection.MatchString(r.section), }) } sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) return result } +// FirewallRuleAdd is the write model for a traffic rule. type FirewallRuleAdd struct { - Name string `json:"name"` - Src string `json:"src"` - Dest string `json:"dest"` - Proto string `json:"proto"` - DestPort string `json:"dest_port"` - Target string `json:"target"` + Name string `json:"name"` + Proto string `json:"proto"` + SrcIP string `json:"src_ip"` + DestIP string `json:"dest_ip"` + SrcPort string `json:"src_port"` + DestPort string `json:"dest_port"` + Target string `json:"target"` + StartTime string `json:"start_time"` + StopTime string `json:"stop_time"` + Weekdays string `json:"weekdays"` + UTCTime bool `json:"utc_time"` } -func AddFirewallRule(rule FirewallRuleAdd) (*FirewallProbe, bool, error) { - if !executor.ServiceEnabled("firewall") { - return nil, false, fmt.Errorf("firewall not enabled") +// validTrafficCIDR accepts an IP, a CIDR, or "" (any). +func validTrafficCIDR(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return true } - if rule.Name == "" || rule.Proto == "" || rule.DestPort == "" { - return nil, false, fmt.Errorf("name, proto and dest_port required") + if net.ParseIP(s) != nil { + return true } - snap, err := executor.Snapshot("firewall") - if err != nil { - return nil, false, fmt.Errorf("snapshot firewall: %w", err) + _, _, err := net.ParseCIDR(s) + return err == nil +} + +// validTrafficPorts accepts "", a single port (1-65535) or a range "a-b" +// with 1 <= a <= b <= 65535. +func validTrafficPorts(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return true + } + parts := strings.SplitN(s, "-", 2) + if len(parts) == 1 { + return validPort(s) + } + a, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + b, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + return err1 == nil && err2 == nil && a >= 1 && a <= 65535 && b >= 1 && b <= 65535 && a <= b +} + +// validTrafficProto accepts "", "tcp" or "udp" (empty = any protocol). +func validTrafficProto(p string) bool { + return p == "" || p == "tcp" || p == "udp" +} + +// validTrafficTarget accepts the three policies the editor exposes. +func validTrafficTarget(t string) bool { + return t == "ACCEPT" || t == "DROP" || t == "REJECT" +} + +// validTrafficTime accepts "" or HH:MM[:SS]. +func validTrafficTime(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return true + } + parts := strings.Split(s, ":") + if len(parts) < 2 || len(parts) > 3 { + return false + } + h, errH := strconv.Atoi(parts[0]) + m, errM := strconv.Atoi(parts[1]) + if errH != nil || errM != nil || h < 0 || h > 23 || m < 0 || m > 59 { + return false + } + if len(parts) == 3 { + sec, errS := strconv.Atoi(parts[2]) + if errS != nil || sec < 0 || sec > 59 { + return false + } + } + return true +} + +var reWeekdays = regexp.MustCompile(`^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)( (Mon|Tue|Wed|Thu|Fri|Sat|Sun))*$`) + +// validTrafficWeekdays accepts "" or space-separated short day names, the +// format fw4 writes to the `weekdays` option. +func validTrafficWeekdays(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return true + } + return reWeekdays.MatchString(s) +} + +// validateTrafficRule checks every field of a traffic rule before any write. +func validateTrafficRule(rule FirewallRuleAdd) error { + if strings.TrimSpace(rule.Name) == "" { + return fmt.Errorf("name is required") + } + if !validTrafficTarget(rule.Target) { + return fmt.Errorf("target must be ACCEPT, DROP or REJECT") + } + if !validTrafficCIDR(rule.SrcIP) { + return fmt.Errorf("invalid src_ip: use an IP, a CIDR or leave empty for any") + } + if !validTrafficCIDR(rule.DestIP) { + return fmt.Errorf("invalid dest_ip: use an IP or a CIDR") + } + if !validTrafficPorts(rule.SrcPort) { + return fmt.Errorf("invalid src_port: use 1-65535 or a range start-end") } + if !validTrafficPorts(rule.DestPort) { + return fmt.Errorf("invalid dest_port: use 1-65535 or a range start-end") + } + if !validTrafficProto(rule.Proto) { + return fmt.Errorf("proto must be tcp, udp or empty (any)") + } + if !validTrafficTime(rule.StartTime) || !validTrafficTime(rule.StopTime) { + return fmt.Errorf("invalid start_time/stop_time: use HH:MM or HH:MM:SS") + } + if !validTrafficWeekdays(rule.Weekdays) { + return fmt.Errorf("invalid weekdays: space-separated Mon..Sun") + } + return nil +} - cmd := exec.Command("uci", "add", "firewall", "rule") - secOut, err := cmd.Output() +// lockoutRisk reports whether a deny/reject rule with a broad source could +// cut access to the panel. It is a UI warning, never a hard block: a +// deny/reject whose source is everything (empty or 0.0.0.0/0) or that covers +// a management subnet, and whose destination is not narrowed to a specific +// host or port, would also drop the panel's own management traffic. +func lockoutRisk(rule FirewallRuleAdd, mgmtCIDRs []string) bool { + if rule.Target != "DROP" && rule.Target != "REJECT" { + return false + } + // A destination narrowed to a host or a port does not cover the panel. + if rule.DestIP != "" || rule.DestPort != "" || rule.SrcPort != "" { + return false + } + src := strings.TrimSpace(rule.SrcIP) + if src == "" || src == "0.0.0.0/0" || src == "0.0.0.0" { + return true + } + _, srcNet, err := net.ParseCIDR(src) if err != nil { - return ProbeFirewall(), false, fmt.Errorf("uci add rule: %w", err) + return false } - section := strings.TrimSpace(string(secOut)) + srcOnes, _ := srcNet.Mask.Size() + for _, c := range mgmtCIDRs { + _, mgmtNet, err := net.ParseCIDR(strings.TrimSpace(c)) + if err != nil { + continue + } + mgmtOnes, _ := mgmtNet.Mask.Size() + // The source covers the management subnet when it is broader (or + // equal) and contains its network address. + if srcOnes <= mgmtOnes && srcNet.Contains(mgmtNet.IP) { + return true + } + } + return false +} + +// nextTrafficRuleSection returns the next free netgrip_fw_N section name, +// given the existing section names. +func nextTrafficRuleSection(existing []string) string { + max := 0 + for _, s := range existing { + if !reFwRuleSection.MatchString(s) { + continue + } + n, err := strconv.Atoi(strings.TrimPrefix(s, fwRulePrefix)) + if err == nil && n > max { + max = n + } + } + return fmt.Sprintf("%s%d", fwRulePrefix, max+1) +} +// trafficRuleOps builds the UCI ops that create one named rule section. Only +// non-empty options are written (fw4 treats an absent option as its default: +// any source, any destination, any protocol). The batch ends with commit and +// a firewall reload, the same path AddFwdRule uses. +func trafficRuleOps(section string, rule FirewallRuleAdd) []executor.Op { + base := "firewall." + section ops := []executor.Op{ - {Kind: "uci_set", Args: []string{"firewall." + section + ".name", rule.Name}}, - {Kind: "uci_set", Args: []string{"firewall." + section + ".src", rule.Src}}, - {Kind: "uci_set", Args: []string{"firewall." + section + ".proto", rule.Proto}}, - {Kind: "uci_set", Args: []string{"firewall." + section + ".dest_port", rule.DestPort}}, - {Kind: "uci_set", Args: []string{"firewall." + section + ".target", rule.Target}}, + {Kind: "uci_set", Args: []string{base, "rule"}}, + } + set := func(opt, val string) { + if val != "" { + ops = append(ops, executor.Op{Kind: "uci_set", Args: []string{base + "." + opt, val}}) + } } - if rule.Dest != "" { - ops = append(ops, executor.Op{Kind: "uci_set", Args: []string{"firewall." + section + ".dest", rule.Dest}}) + set("name", rule.Name) + set("proto", rule.Proto) + set("src_ip", rule.SrcIP) + set("dest_ip", rule.DestIP) + set("src_port", rule.SrcPort) + set("dest_port", rule.DestPort) + set("target", rule.Target) + set("start_time", rule.StartTime) + set("stop_time", rule.StopTime) + set("weekdays", rule.Weekdays) + if rule.UTCTime { + set("utc_time", "1") } ops = append(ops, executor.Op{Kind: "uci_commit", Args: []string{"firewall"}}, - executor.Op{Kind: "initd", Args: []string{"firewall", "restart"}}, + executor.Op{Kind: "initd", Args: []string{"firewall", "reload"}}, ) + return ops +} - if err := executor.Apply(ops, nil); err != nil { +// AddFirewallRule creates a named traffic rule with snapshot, reload and +// rollback (AddFwdRule / ADR-003 pattern). +func AddFirewallRule(rule FirewallRuleAdd) (*FirewallProbe, bool, error) { + InvalidateKey("ucishow|firewall") + if !executor.ServiceEnabled("firewall") { + return ProbeFirewall(), false, fmt.Errorf("firewall not enabled") + } + if err := validateTrafficRule(rule); err != nil { + return ProbeFirewall(), false, err + } + existing := []string{} + for _, r := range ProbeFirewall().Rules { + existing = append(existing, r.Section) + } + section := nextTrafficRuleSection(existing) + + snap, err := executor.Snapshot("firewall") + if err != nil { + return nil, false, fmt.Errorf("snapshot firewall: %w", err) + } + rollback := func() { _ = executor.Restore("firewall", snap) - executor.Run(executor.Op{Kind: "initd", Args: []string{"firewall", "restart"}}) + _ = executor.Run(executor.Op{Kind: "initd", Args: []string{"firewall", "reload"}}) + // El restore cambia la config: el probe final debe leer de nuevo, + // no la cache poblada por la verificacion previa (#444). + InvalidateKey("ucishow|firewall") + } + if err := executor.Apply(trafficRuleOps(section, rule), nil); err != nil { + rollback() return ProbeFirewall(), true, err } - return ProbeFirewall(), false, nil + // La lectura de "existing" de arriba repoblo la cache de `uci show + // firewall` ANTES del alta: sin invalidar aqui, el healthcheck compara + // contra la foto previa, no ve la seccion nueva y hace rollback de una + // regla que si se aplico (reproducido en rt-lab, #444). + InvalidateKey("ucishow|firewall") + for _, r := range ProbeFirewall().Rules { + if r.Section == section { + return ProbeFirewall(), false, nil + } + } + rollback() + return ProbeFirewall(), true, fmt.Errorf("rule not present after reload, rolled back") } +// DeleteFirewallRule deletes one managed traffic rule by section. func DeleteFirewallRule(section string) (*FirewallProbe, bool, error) { - if section == "" { - return nil, false, fmt.Errorf("section required") + InvalidateKey("ucishow|firewall") + if !reFwRuleSection.MatchString(section) { + return ProbeFirewall(), false, fmt.Errorf("only NetGrip traffic rules can be deleted") } snap, err := executor.Snapshot("firewall") if err != nil { return nil, false, fmt.Errorf("snapshot firewall: %w", err) } + rollback := func() { + _ = executor.Restore("firewall", snap) + _ = executor.Run(executor.Op{Kind: "initd", Args: []string{"firewall", "reload"}}) + } ops := []executor.Op{ {Kind: "uci_delete", Args: []string{"firewall." + section}}, {Kind: "uci_commit", Args: []string{"firewall"}}, - {Kind: "initd", Args: []string{"firewall", "restart"}}, + {Kind: "initd", Args: []string{"firewall", "reload"}}, } if err := executor.Apply(ops, nil); err != nil { - _ = executor.Restore("firewall", snap) - executor.Run(executor.Op{Kind: "initd", Args: []string{"firewall", "restart"}}) + rollback() return ProbeFirewall(), true, err } + // Sin invalidar, la verificacion leeria la cache previa al borrado y + // daria la seccion por aun presente (#444). + InvalidateKey("ucishow|firewall") + for _, r := range ProbeFirewall().Rules { + if r.Section == section { + rollback() + return ProbeFirewall(), true, fmt.Errorf("rule still present after delete, rolled back") + } + } return ProbeFirewall(), false, nil } diff --git a/internal/modules/firewall_test.go b/internal/modules/firewall_test.go index 2ecf4d15..416f9401 100644 --- a/internal/modules/firewall_test.go +++ b/internal/modules/firewall_test.go @@ -2,8 +2,11 @@ package modules import ( "encoding/json" + "reflect" "strings" "testing" + + "github.com/gnacho/netgrip/internal/executor" ) // Real-shaped `uci show firewall` output: anonymous sections print as @@ -26,11 +29,21 @@ firewall.@zone[1].masq='1' firewall.@zone[1].network='wan' 'wan6' firewall.@rule[0]=rule firewall.@rule[0].name='allow-in' -firewall.@rule[0].src='wan' -firewall.@rule[0].dest='lan' firewall.@rule[0].proto='tcp' -firewall.@rule[0].dest_port='22' +firewall.@rule[0].src_ip='192.168.1.50' +firewall.@rule[0].dest_ip='10.0.0.9' +firewall.@rule[0].dest_port='80-443' firewall.@rule[0].target='ACCEPT' +firewall.netgrip_fw_1=rule +firewall.netgrip_fw_1.name='Block TV' +firewall.netgrip_fw_1.proto='tcp' +firewall.netgrip_fw_1.src_ip='192.168.1.51' +firewall.netgrip_fw_1.dest_port='443' +firewall.netgrip_fw_1.target='REJECT' +firewall.netgrip_fw_1.start_time='22:00:00' +firewall.netgrip_fw_1.stop_time='07:00:00' +firewall.netgrip_fw_1.weekdays='Mon Tue Wed Thu Fri Sun' +firewall.netgrip_fw_1.utc_time='1' firewall.guestzone=zone firewall.guestzone.name='guest' firewall.guestzone.input='REJECT' @@ -59,15 +72,51 @@ func TestParseFWZonesFromAnonymousSections(t *testing.T) { func TestParseFWRulesFrom(t *testing.T) { rules := parseFWRulesFrom(fwFixture) - if len(rules) != 1 { - t.Fatalf("want 1 rule, got %d: %+v", len(rules), rules) + if len(rules) != 2 { + t.Fatalf("want 2 rules, got %d: %+v", len(rules), rules) + } + var r *FWRule + for i := range rules { + if rules[i].Section == "@rule[0]" { + r = &rules[i] + } } - r := rules[0] - if r.Name != "allow-in" || r.Src != "wan" || r.Dest != "lan" || r.Proto != "tcp" || r.DestPort != "22" || r.Target != "ACCEPT" { + if r == nil { + t.Fatal("anonymous rule @rule[0] not parsed") + } + if r.Name != "allow-in" || r.Proto != "tcp" || r.SrcIP != "192.168.1.50" || r.DestIP != "10.0.0.9" || r.DestPort != "80-443" || r.Target != "ACCEPT" { t.Errorf("rule fields wrong: %+v", r) } - if r.Section != "@rule[0]" { - t.Errorf("rule section should be the anonymous id, got %q", r.Section) + if r.Managed { + t.Errorf("anonymous rule must not be managed: %+v", r) + } + if !r.Enabled { + t.Errorf("rule without enabled option defaults to enabled") + } +} + +func TestParseTrafficRulesFrom(t *testing.T) { + rules := parseFWRulesFrom(fwFixture) + var managed *FWRule + for i := range rules { + if rules[i].Managed { + managed = &rules[i] + } + } + if managed == nil { + t.Fatal("want one managed rule, got none") + } + if managed.Section != "netgrip_fw_1" { + t.Errorf("section wrong: %q", managed.Section) + } + if managed.StartTime != "22:00:00" || managed.StopTime != "07:00:00" { + t.Errorf("schedule times wrong: %+v", managed) + } + if managed.Weekdays != "Mon Tue Wed Thu Fri Sun" { + t.Errorf("weekdays wrong: %q", managed.Weekdays) + } + if !managed.UTCTime { + t.Errorf("utc_time not parsed") } } @@ -106,3 +155,152 @@ func TestParseFirewallNeverNil(t *testing.T) { t.Fatal("fixture must mimic real uci show output (no .type keys)") } } + +func TestTrafficRuleOpsFullSchedule(t *testing.T) { + rule := FirewallRuleAdd{ + Name: "Block TV", + Proto: "tcp", + SrcIP: "192.168.1.51", + DestPort: "443", + Target: "REJECT", + StartTime: "22:00", + StopTime: "07:00", + Weekdays: "Mon Tue Wed", + UTCTime: true, + } + ops := trafficRuleOps("netgrip_fw_1", rule) + + want := []executor.Op{ + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1", "rule"}}, + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1.name", "Block TV"}}, + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1.proto", "tcp"}}, + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1.src_ip", "192.168.1.51"}}, + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1.dest_port", "443"}}, + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1.target", "REJECT"}}, + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1.start_time", "22:00"}}, + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1.stop_time", "07:00"}}, + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1.weekdays", "Mon Tue Wed"}}, + {Kind: "uci_set", Args: []string{"firewall.netgrip_fw_1.utc_time", "1"}}, + {Kind: "uci_commit", Args: []string{"firewall"}}, + {Kind: "initd", Args: []string{"firewall", "reload"}}, + } + if !reflect.DeepEqual(ops, want) { + t.Fatalf("ops mismatch:\n got %+v\nwant %+v", ops, want) + } +} + +func TestTrafficRuleOpsOmitsEmpty(t *testing.T) { + rule := FirewallRuleAdd{Name: "Any to any deny", Target: "DROP"} + ops := trafficRuleOps("netgrip_fw_1", rule) + for _, op := range ops { + for _, arg := range op.Args { + if strings.Contains(arg, "dest_ip") || strings.Contains(arg, "src_port") || + strings.Contains(arg, "start_time") || strings.Contains(arg, "weekdays") || + strings.Contains(arg, "utc_time") { + t.Errorf("empty option must be omitted, got %v", op.Args) + } + } + } +} + +func TestTrafficRuleValidate(t *testing.T) { + base := FirewallRuleAdd{Name: "ok", Target: "ACCEPT", Proto: "tcp", SrcIP: "192.168.1.0/24", DestPort: "80-443"} + if err := validateTrafficRule(base); err != nil { + t.Errorf("valid rule rejected: %v", err) + } + + cases := []struct { + mut func(*FirewallRuleAdd) + label string + }{ + {func(r *FirewallRuleAdd) { r.Name = " " }, "empty name"}, + {func(r *FirewallRuleAdd) { r.Target = "ALLOW" }, "bad target"}, + {func(r *FirewallRuleAdd) { r.Proto = "icmp" }, "bad proto"}, + {func(r *FirewallRuleAdd) { r.SrcIP = "999.1.1.1" }, "bad src ip"}, + {func(r *FirewallRuleAdd) { r.SrcIP = "192.168.1.0/33" }, "bad src cidr"}, + {func(r *FirewallRuleAdd) { r.DestIP = "not-an-ip" }, "bad dest ip"}, + {func(r *FirewallRuleAdd) { r.DestPort = "0" }, "port below range"}, + {func(r *FirewallRuleAdd) { r.DestPort = "65536" }, "port above range"}, + {func(r *FirewallRuleAdd) { r.DestPort = "90-80" }, "reversed port range"}, + {func(r *FirewallRuleAdd) { r.StartTime = "25:00" }, "bad start hour"}, + {func(r *FirewallRuleAdd) { r.StartTime = "22:60" }, "bad start minute"}, + {func(r *FirewallRuleAdd) { r.StopTime = "07" }, "stop missing minutes"}, + {func(r *FirewallRuleAdd) { r.Weekdays = "Monday" }, "full weekday name"}, + {func(r *FirewallRuleAdd) { r.Weekdays = "Mon , Tue" }, "malformed weekdays"}, + } + for _, c := range cases { + r := base + c.mut(&r) + if err := validateTrafficRule(r); err == nil { + t.Errorf("%s: expected error, got none", c.label) + } + } +} + +func TestTrafficRuleLockout(t *testing.T) { + mgmt := []string{"192.168.1.0/24"} + + // allow rules never lock out. + if lockoutRisk(FirewallRuleAdd{Target: "ACCEPT", SrcIP: "0.0.0.0/0"}, mgmt) { + t.Error("allow rule must not be a lockout risk") + } + + // deny any source with no dest narrows -> risk. + if !lockoutRisk(FirewallRuleAdd{Target: "DROP"}, mgmt) { + t.Error("deny with any source must be a lockout risk") + } + if !lockoutRisk(FirewallRuleAdd{Target: "REJECT", SrcIP: "0.0.0.0/0"}, mgmt) { + t.Error("reject 0.0.0.0/0 must be a lockout risk") + } + + // source covering the management subnet -> risk. + if !lockoutRisk(FirewallRuleAdd{Target: "DROP", SrcIP: "192.168.0.0/16"}, mgmt) { + t.Error("deny from a supernet of the management subnet must be a risk") + } + if !lockoutRisk(FirewallRuleAdd{Target: "DROP", SrcIP: "192.168.1.0/24"}, mgmt) { + t.Error("deny from the management subnet itself must be a risk") + } + + // narrow source, or a dest that excludes the panel -> no risk. + if lockoutRisk(FirewallRuleAdd{Target: "DROP", SrcIP: "192.168.1.50"}, mgmt) { + t.Error("deny from a single host is not a lockout risk") + } + if lockoutRisk(FirewallRuleAdd{Target: "DROP", SrcIP: "10.0.0.0/8"}, mgmt) { + t.Error("deny from an unrelated subnet is not a lockout risk") + } + if lockoutRisk(FirewallRuleAdd{Target: "DROP", SrcIP: "0.0.0.0/0", DestIP: "10.0.0.9"}, mgmt) { + t.Error("deny narrowed to a dest host is not a lockout risk") + } + if lockoutRisk(FirewallRuleAdd{Target: "DROP", SrcIP: "0.0.0.0/0", DestPort: "443"}, mgmt) { + t.Error("deny narrowed to a dest port is not a lockout risk") + } +} + +func TestTrafficRuleNextSection(t *testing.T) { + cases := []struct { + existing []string + want string + }{ + {nil, "netgrip_fw_1"}, + {[]string{"netgrip_fw_1", "netgrip_fw_2"}, "netgrip_fw_3"}, + {[]string{"netgrip_fw_3", "netgrip_fw_1"}, "netgrip_fw_4"}, + {[]string{"allow-in", "netgrip_fw_5"}, "netgrip_fw_6"}, + {[]string{"netgrip_fwd_9"}, "netgrip_fw_1"}, + } + for _, c := range cases { + if got := nextTrafficRuleSection(c.existing); got != c.want { + t.Errorf("nextTrafficRuleSection(%v) = %q, want %q", c.existing, got, c.want) + } + } +} + +func TestTrafficRuleSectionGuard(t *testing.T) { + if reFwRuleSection.MatchString("netgrip_fw_1") == false { + t.Error("netgrip_fw_1 must match the managed prefix") + } + for _, s := range []string{"netgrip_fw_", "netgrip_fw_x", "netgrip_fwd_1", "allow-in", "@rule[0]", ""} { + if reFwRuleSection.MatchString(s) { + t.Errorf("%q must not match the managed prefix", s) + } + } +}