diff --git a/app/src/api.ts b/app/src/api.ts index 5258b47..049c1d3 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -1032,6 +1032,42 @@ const realApi = { body: JSON.stringify({ entry }), }), + // UPnP / NAT-PMP (#448) + upnp: () => + request("/api/upnp"), + upnpStatus: () => + request("/api/upnp/status"), + upnpAction: (action: string) => + request>("/api/upnp/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }), + upnpInstall: () => + request("/api/upnp/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: true }), + }), + upnpUninstall: () => + request("/api/upnp/uninstall", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: true }), + }), + upnpRuleAdd: (rule: import("./types").UpnpRuleConfig) => + request>("/api/upnp/rule", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(rule), + }), + upnpRuleDelete: (section: string) => + request>("/api/upnp/rule", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ section }), + }), + }; /** API pública: delega en `src/demo` cuando el modo demo está activo. diff --git a/app/src/components/Shell.tsx b/app/src/components/Shell.tsx index 377ad07..a18dc3c 100644 --- a/app/src/components/Shell.tsx +++ b/app/src/components/Shell.tsx @@ -26,7 +26,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" | "ports" | "forwards" | "tools" | "diagnostics" | "fleet" | "storage" | "system" | "dpi" | "about"; +export type Page = "overview" | "internet" | "wan" | "clients" | "coverage" | "wifi" | "lan" | "services" | "traffic" | "banip" | "upnp" | "ports" | "forwards" | "tools" | "diagnostics" | "fleet" | "storage" | "system" | "dpi" | "about"; const NAV_ICONS: Record = { overview: LayoutDashboard, @@ -39,6 +39,7 @@ const NAV_ICONS: Record = { services: Blocks, traffic: ChartPie, banip: ShieldBan, + upnp: ArrowLeftRight, ports: ArrowLeftRight, forwards: Forward, tools: Wrench, @@ -220,7 +221,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") && !isRouter) return "gatewayOnly"; + if ((id === "internet" || id === "wan" || id === "forwards" || id === "dpi" || id === "banip" || id === "upnp") && !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"; diff --git a/app/src/demo/data.ts b/app/src/demo/data.ts index 4dd1dd1..a9c38e4 100644 --- a/app/src/demo/data.ts +++ b/app/src/demo/data.ts @@ -929,3 +929,23 @@ export const demoBanip: T.BanipProbe = { allowlist: ["192.168.1.0/24", "vpn.casa.example"], blocklist: ["203.0.113.66"], }; + +export const demoUpnp: T.UpnpProbe = { + installed: true, + enabled: false, + running: false, + applicable: true, + secure_mode: true, + natpmp: true, + upnp: true, + lease_file: "/var/run/miniupnpd.leases", + leases: [ + { proto: "TCP", ext_port: 32400, int_addr: "192.168.1.40", int_port: 32400, lease_time: 5400, desc: "Plex Media Server" }, + { proto: "UDP", ext_port: 51820, int_addr: "192.168.1.50", int_port: 51820, lease_time: 0, desc: "WireGuard" }, + { proto: "TCP", ext_port: 8080, int_addr: "192.168.1.60", int_port: 80, lease_time: 120, desc: "web server" }, + ], + rules: [ + { section: "@perm_rule[0]", action: "allow", int_addr: "0.0.0.0/0", int_ports: "1024-65535", ext_ports: "1024-65535", comment: "Allow high ports", managed: false }, + { section: "netgrip_upnp_1", action: "deny", int_addr: "192.168.1.66", int_ports: "", ext_ports: "", comment: "smart TV", managed: true }, + ], +}; diff --git a/app/src/demo/index.ts b/app/src/demo/index.ts index 9218da2..f4de058 100644 --- a/app/src/demo/index.ts +++ b/app/src/demo/index.ts @@ -47,6 +47,7 @@ const state = { firewall: structuredClone(D.demoFirewall), nlbwmon: { ...D.demoNlbwmon }, banip: structuredClone(D.demoBanip), + upnp: structuredClone(D.demoUpnp), offload: { ...D.demoOffload }, mode: { ...D.demoMode }, igmp: { ...D.demoIgmp }, @@ -452,6 +453,60 @@ export const demoApi: typeof api = { if (i >= 0) target.splice(i, 1); return { status: "applied" as const, rolled_back: false, state: state.banip }; }, + upnp: () => get(state.upnp), + upnpStatus: async () => { + await wait(100, 250); + return { + installed: state.upnp.installed, + enabled: state.upnp.enabled, + running: state.upnp.running, + applicable: state.upnp.applicable, + }; + }, + upnpAction: async (action: string) => { + await wait(800, 1500); + if (action === "start" || action === "reload" || action === "restart" || action === "enable") { + state.upnp.enabled = true; + state.upnp.running = true; + } else { + state.upnp.running = false; + if (action === "disable") state.upnp.enabled = false; + } + return { status: "applied" as const, rolled_back: false, state: state.upnp }; + }, + upnpInstall: async () => { + await wait(1500, 3000); + state.upnp.installed = true; + state.upnp.enabled = false; + state.upnp.running = false; + return state.upnp; + }, + upnpUninstall: async () => { + await wait(1500, 3000); + state.upnp.installed = false; + state.upnp.running = false; + state.upnp.enabled = false; + return state.upnp; + }, + upnpRuleAdd: async (rule: T.UpnpRuleConfig) => { + await wait(800, 1500); + const n = state.upnp.rules.filter((r) => r.section.startsWith("netgrip_upnp_")).length + 1; + state.upnp.rules.push({ + section: `netgrip_upnp_${n}`, + action: rule.action, + int_addr: rule.int_addr, + int_ports: rule.int_ports ?? "", + ext_ports: rule.ext_ports ?? "", + comment: rule.comment ?? "", + managed: true, + }); + return { status: "applied" as const, rolled_back: false, state: state.upnp }; + }, + upnpRuleDelete: async (section: string) => { + await wait(800, 1500); + state.upnp.rules = state.upnp.rules.filter((r) => r.section !== section); + return { status: "applied" as const, rolled_back: false, state: state.upnp }; + }, firewall: () => get(state.firewall), addFirewallRule: async (rule) => { state.firewall.rules.push({ section: `rule_${state.firewall.rules.length}`, ...rule }); diff --git a/app/src/locales/en.ts b/app/src/locales/en.ts index ca85074..0e79bfc 100644 --- a/app/src/locales/en.ts +++ b/app/src/locales/en.ts @@ -861,6 +861,55 @@ export default { dosInvalidTcp: "Invalid TCP flags", dosNote: "Counters since the banIP service started. A threshold of 0 means that safeguard is disabled.", }, + upnp: { + stateActive: "Active", + stateStopped: "Stopped", + stateDisabled: "Disabled", + stateChecking: "Checking…", + enable: "Enable", + enableConfirmTitle: "Enable UPnP/NAT-PMP?", + enableConfirmBody: "UPnP lets any device on your network open ports to the internet without asking you. It is a known abuse vector: a compromised app can expose services. It is enabled restricted to the LAN (secure mode), so each device can only open ports towards itself.", + introDesc: "UPnP and NAT-PMP let devices on your LAN open ports to the internet on their own (consoles, games, cameras). Convenient, but risky: any app on the network can expose something without asking.", + riskNote: "UPnP is an abuse vector. Enable it only if you need it and keep the per-client ACL to limit who can open ports.", + secureModeOn: "Secure mode on: each device can only open ports towards its own IP (LAN-restricted).", + cardTitle: "UPnP / NAT-PMP", + notGatewayTitle: "UPnP only runs on the gateway", + notGatewayBody: "This router is not the internet gateway, so there is no WAN to open ports on. UPnP lives on the router that faces the internet.", + notInstalledTitle: "UPnP/NAT-PMP is not installed", + notInstalledBody: "miniupnpd opens ports to the internet for your devices (UPnP and NAT-PMP) in a controlled way. It is installed from the official OpenWrt packages.", + install: "Install UPnP", + installConfirmTitle: "Install miniupnpd?", + installConfirmBody: "The package is downloaded from the official OpenWrt repository. It is disabled by default: it opens no ports until you enable it.", + installOk: "UPnP installed", + uninstall: "Uninstall", + uninstallConfirmTitle: "Uninstall UPnP/NAT-PMP?", + uninstallConfirmBody: "The service is stopped and the package removed, but the UCI config and the ACL stay on disk: a reinstall keeps your configuration.", + uninstallOk: "UPnP uninstalled", + actionOk: "Done", + actionFailed: "UPnP action failed", + mappingsTitle: "Active mappings", + mappingsEmpty: "No open mappings", + mappingsNote: "Mappings are created by the devices themselves (and expire on their own). They cannot be removed here: they are managed by the client that requested them.", + colProto: "Proto", + colExtPort: "Ext. port", + colIntAddr: "Internal IP", + colIntPort: "Int. port", + colLease: "Lease", + colDesc: "Description", + rulesTitle: "Per-client ACL", + rulesEmpty: "No ACL rules", + rulesNote: "Allow/deny rules are evaluated before a mapping is accepted. System rules (not managed) are shown read-only.", + ruleAction: "Action", + ruleAllow: "Allow", + ruleDeny: "Deny", + ruleIntAddr: "Internal IP or network", + ruleIntPorts: "Internal ports", + ruleExtPorts: "External ports", + ruleComment: "Comment", + ruleAdd: "Add", + ruleRemove: "Remove", + ruleReadOnly: "read-only", + }, services: { groupVpn: "VPN", groupVpnDesc: "To get into your home from outside", diff --git a/app/src/locales/es.ts b/app/src/locales/es.ts index 349cc10..13b58ff 100644 --- a/app/src/locales/es.ts +++ b/app/src/locales/es.ts @@ -861,6 +861,55 @@ export default { dosInvalidTcp: "Banderas TCP inválidas", dosNote: "Contadores desde que el servicio banIP arrancó. Un umbral de 0 significa que esa protección está desactivada.", }, + upnp: { + stateActive: "Activo", + stateStopped: "Parado", + stateDisabled: "Desactivado", + stateChecking: "Comprobando…", + enable: "Activar", + enableConfirmTitle: "¿Activar UPnP/NAT-PMP?", + enableConfirmBody: "UPnP permite a cualquier dispositivo de tu red abrir puertos hacia internet sin pedirte permiso. Es un vector conocido de abuso: una app comprometida puede exponer servicios. Se activa restringido a la LAN (secure mode) para que cada equipo solo pueda abrir puertos hacia sí mismo.", + introDesc: "UPnP y NAT-PMP dejan que los dispositivos de tu LAN abran puertos hacia internet solos (consolas, juegos, cámaras). Cómodo, pero arriesgado: cualquier app de la red puede exponer algo sin preguntar.", + riskNote: "UPnP es un vector de abuso. Actívalo solo si lo necesitas y mantén la ACL por cliente para limitar quién puede abrir puertos.", + secureModeOn: "Modo seguro activo: cada equipo solo puede abrir puertos hacia su propia IP (restringido a LAN).", + cardTitle: "UPnP / NAT-PMP", + notGatewayTitle: "UPnP solo funciona en el gateway", + notGatewayBody: "Este router no es la puerta de salida a internet, así que no hay WAN donde abrir puertos. UPnP vive en el router que da salida a internet.", + notInstalledTitle: "UPnP/NAT-PMP no está instalado", + notInstalledBody: "miniupnpd abre puertos hacia internet para tus dispositivos (UPnP y NAT-PMP) de forma controlada. Se instala desde los paquetes oficiales de OpenWrt.", + install: "Instalar UPnP", + installConfirmTitle: "¿Instalar miniupnpd?", + installConfirmBody: "El paquete se descarga del repositorio oficial de OpenWrt. Queda desactivado por defecto: no abre puertos hasta que lo actives.", + installOk: "UPnP instalado", + uninstall: "Desinstalar", + uninstallConfirmTitle: "¿Desinstalar UPnP/NAT-PMP?", + uninstallConfirmBody: "El servicio se para y el paquete se quita, pero la config UCI y la ACL se conservan en disco: una reinstalación mantiene tu configuración.", + uninstallOk: "UPnP desinstalado", + actionOk: "Hecho", + actionFailed: "Falló la acción de UPnP", + mappingsTitle: "Mapeos activos", + mappingsEmpty: "No hay mapeos abiertos", + mappingsNote: "Los mapeos los crean los propios dispositivos (y expiran solos). No se pueden borrar desde aquí: los gestiona el cliente que los solicitó.", + colProto: "Proto", + colExtPort: "Puerto ext.", + colIntAddr: "IP interna", + colIntPort: "Puerto int.", + colLease: "Lease", + colDesc: "Descripción", + rulesTitle: "ACL por cliente", + rulesEmpty: "Sin reglas ACL", + rulesNote: "Las reglas allow/deny se evalúan antes de aceptar un mapeo. Las reglas del sistema (sin gestionar) se muestran en solo lectura.", + ruleAction: "Acción", + ruleAllow: "Permitir", + ruleDeny: "Denegar", + ruleIntAddr: "IP o red interna", + ruleIntPorts: "Puertos internos", + ruleExtPorts: "Puertos externos", + ruleComment: "Comentario", + ruleAdd: "Añadir", + ruleRemove: "Quitar", + ruleReadOnly: "solo lectura", + }, services: { groupVpn: "VPN", groupVpnDesc: "Para entrar a tu casa desde fuera", diff --git a/app/src/pages/Internet.tsx b/app/src/pages/Internet.tsx index d377a52..c335b73 100644 --- a/app/src/pages/Internet.tsx +++ b/app/src/pages/Internet.tsx @@ -3,6 +3,7 @@ import { WanPage } from "./Wan"; import { ForwardsPage } from "./Forwards"; import { DpiPage } from "./Dpi"; import { BanipPage } from "./Banip"; +import { UpnpPage } from "./Upnp"; /** * Internet (#487): la pantalla de salida a Internet para la puerta de enlace. @@ -24,6 +25,7 @@ export function InternetPage({ mwan, onMwanChange, fwd, onFwdChange }: { Sistema. */} + diff --git a/app/src/pages/Upnp.tsx b/app/src/pages/Upnp.tsx new file mode 100644 index 0000000..ba351c3 --- /dev/null +++ b/app/src/pages/Upnp.tsx @@ -0,0 +1,329 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowLeftRight, Globe, Plus, TriangleAlert, Trash2 } from "lucide-react"; +import { api } from "../api"; +import type { UpnpProbe, UpnpRuleConfig, UpnpStatus } from "../types"; +import { Banner, Button, Card, ConfirmDialog, EmptyState, Field, Input, Pill, SegmentedControl, SkeletonRows, Toggle, useToast } from "../components/ui"; + +const fmtInt = new Intl.NumberFormat(); + +/** Formatea los segundos restantes de un lease: "2h 5m", "1m 30s" o "—" sin + * expiración (lease_time 0). */ +function fmtLease(sec: number): string { + if (sec <= 0) return "—"; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +/** + * UPnP / NAT-PMP (#448): miniupnpd como paquete opcional. Mapeos activos en + * solo lectura (los dueños son los clientes que los solicitan), ACL por + * cliente vía perm_rule, y activación con confirmación de riesgo porque UPnP + * es un vector de abuso. secure_mode queda fijado en el backend al activar. + */ +export function UpnpPage() { + const { t } = useTranslation(); + const { push } = useToast(); + const [probe, setProbe] = useState(); + const [st, setSt] = useState(); + const [loadError, setLoadError] = useState(false); + const [busy, setBusy] = useState(false); + const [confirmEnable, setConfirmEnable] = useState(false); + const [confirmInstall, setConfirmInstall] = useState(false); + const [confirmUninstall, setConfirmUninstall] = useState(false); + + const load = useCallback(async () => { + setLoadError(false); + api.upnpStatus().then(setSt).catch(() => {}); + try { + setProbe(await api.upnp()); + } catch { + setLoadError(true); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const run = useCallback(async (fn: () => Promise<{ state?: UpnpProbe; error?: string }>) => { + setBusy(true); + try { + const res = await fn(); + if (res.state) setProbe(res.state); + if (res.error) { + push({ tone: "danger", text: t("upnp.actionFailed"), detail: res.error }); + } else { + push({ tone: "ok", text: t("upnp.actionOk") }); + } + } catch (e) { + push({ tone: "danger", text: t("upnp.actionFailed"), detail: e instanceof Error ? e.message : String(e) }); + } finally { + setBusy(false); + load(); + } + }, [load, push, t]); + + const onToggle = (next: boolean) => { + if (busy) return; + if (next) setConfirmEnable(true); + else run(() => api.upnpAction("disable")); + }; + + const notInstalledView = ( + <> + } + title={t("upnp.notInstalledTitle")} + body={t("upnp.notInstalledBody")} + action={} + /> + setConfirmInstall(false)} + onConfirm={() => { + setConfirmInstall(false); + setBusy(true); + api.upnpInstall() + .then((p) => { setProbe(p); setSt({ installed: true, enabled: p.enabled, running: p.running, applicable: p.applicable }); push({ tone: "ok", text: t("upnp.installOk") }); }) + .catch((e) => push({ tone: "danger", text: t("upnp.actionFailed"), detail: e instanceof Error ? e.message : String(e) })) + .finally(() => setBusy(false)); + }} + title={t("upnp.installConfirmTitle")} + consequence={t("upnp.installConfirmBody")} + confirmLabel={t("upnp.install")} + busy={busy} + /> + + ); + + if (loadError && !probe && !st) { + return {t("common.retry")}} />; + } + + if (!probe) { + if (st && !st.applicable) { + return } title={t("upnp.notGatewayTitle")} body={t("upnp.notGatewayBody")} />; + } + if (st && !st.installed) return notInstalledView; + const stActive = !!st && st.enabled && st.running; + const statusPending = !st; + return ( +
+
+ + {statusPending ? t("upnp.stateChecking") : stActive ? t("upnp.stateActive") : st?.enabled ? t("upnp.stateStopped") : t("upnp.stateDisabled")} + +
+

{t("upnp.introDesc")}

+ + {notInstalledView} +
+ ); + } + + if (!probe.applicable) { + return } title={t("upnp.notGatewayTitle")} body={t("upnp.notGatewayBody")} />; + } + + if (!probe.installed) return notInstalledView; + + const active = probe.enabled && probe.running; + + return ( +
+ {/* Estado + toggle + uninstall */} +
+ + {active ? t("upnp.stateActive") : probe.enabled ? t("upnp.stateStopped") : t("upnp.stateDisabled")} + + {probe.natpmp && NAT-PMP} + {probe.upnp && UPnP} + + + +
+ + {/* Confirmación de riesgo (activar) + desinstalar */} + setConfirmEnable(false)} + onConfirm={() => { setConfirmEnable(false); run(() => api.upnpAction("enable")); }} + title={t("upnp.enableConfirmTitle")} + consequence={t("upnp.enableConfirmBody")} + confirmLabel={t("upnp.enable")} + busy={busy} + /> + setConfirmUninstall(false)} + onConfirm={() => { + setConfirmUninstall(false); + setBusy(true); + api.upnpUninstall() + .then((p) => { setProbe(p); push({ tone: "ok", text: t("upnp.uninstallOk") }); }) + .catch((e) => push({ tone: "danger", text: t("upnp.actionFailed"), detail: e instanceof Error ? e.message : String(e) })) + .finally(() => setBusy(false)); + }} + title={t("upnp.uninstallConfirmTitle")} + consequence={t("upnp.uninstallConfirmBody")} + confirmLabel={t("upnp.uninstall")} + busy={busy} + /> + + {/* Riesgo + secure_mode */} + + {t("upnp.riskNote")} + +

{t("upnp.introDesc")}

+ {probe.secure_mode && ( +

{t("upnp.secureModeOn")}

+ )} + + {/* Mapeos activos */} + + {probe.leases.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {probe.leases.map((l, i) => ( + + + + + + + + + ))} + +
{t("upnp.colProto")}{t("upnp.colExtPort")}{t("upnp.colIntAddr")}{t("upnp.colIntPort")}{t("upnp.colLease")}{t("upnp.colDesc")}
{l.proto}{fmtInt.format(l.ext_port)}{l.int_addr}{fmtInt.format(l.int_port)}{fmtLease(l.lease_time)}{l.desc || "—"}
+ )} +

{t("upnp.mappingsNote")}

+
+ + {/* ACL */} + { setProbe(p); load(); }} onError={(m) => push({ tone: "danger", text: t("upnp.actionFailed"), detail: m })} /> +
+ ); +} + +// ── ACL por cliente ───────────────────────────────────────────────────────── + +function RulesCard({ probe, busy, onChanged, onError }: { probe: UpnpProbe; busy: boolean; onChanged: (p: UpnpProbe) => void; onError: (msg: string) => void }) { + const { t } = useTranslation(); + const [saving, setSaving] = useState(false); + const [draft, setDraft] = useState({ action: "allow", int_addr: "", int_ports: "", ext_ports: "", comment: "" }); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (!draft.int_addr.trim()) return; + setSaving(true); + try { + const res = await api.upnpRuleAdd({ ...draft, int_ports: draft.int_ports?.trim(), ext_ports: draft.ext_ports?.trim(), comment: draft.comment?.trim() }); + if (res.error) onError(res.error); + else { setDraft({ action: "allow", int_addr: "", int_ports: "", ext_ports: "", comment: "" }); onChanged(res.state); } + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + const remove = async (section: string) => { + setSaving(true); + try { + const res = await api.upnpRuleDelete(section); + if (res.error) onError(res.error); + else onChanged(res.state); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + return ( + +
+ + size="sm" + ariaLabel={t("upnp.ruleAction")} + value={draft.action} + onChange={(a) => setDraft((d) => ({ ...d, action: a }))} + options={[ + { value: "allow", label: t("upnp.ruleAllow") }, + { value: "deny", label: t("upnp.ruleDeny") }, + ]} + /> +
+ + setDraft((d) => ({ ...d, int_addr: e.target.value }))} placeholder="192.168.1.66" /> + +
+
+ + setDraft((d) => ({ ...d, int_ports: e.target.value }))} placeholder="1024-65535" /> + +
+
+ + setDraft((d) => ({ ...d, ext_ports: e.target.value }))} placeholder="1024-65535" /> + +
+
+ + setDraft((d) => ({ ...d, comment: e.target.value }))} /> + +
+ + + +
    + {probe.rules.length === 0 &&
  • {t("upnp.rulesEmpty")}
  • } + {probe.rules.map((r) => ( +
  • + {r.action === "allow" ? t("upnp.ruleAllow") : t("upnp.ruleDeny")} + + {r.int_addr} + {r.int_ports && :{r.int_ports}} + {r.ext_ports && (ext {r.ext_ports})} + {r.comment && · {r.comment}} + + {r.managed ? ( + + ) : ( + {t("upnp.ruleReadOnly")} + )} +
  • + ))} +
+

{t("upnp.rulesNote")}

+
+ ); +} diff --git a/app/src/types.ts b/app/src/types.ts index 5e22da1..44a2962 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -921,6 +921,57 @@ export interface BanipStatus { applicable: boolean; } +// UPnP / NAT-PMP (#448). miniupnpd owns its nft table; NetGrip reads UCI + the +// lease file only. Mappings are read-only (owned by the requesting client). +export interface UpnpLease { + proto: "TCP" | "UDP"; + ext_port: number; + int_addr: string; + int_port: number; + /** seconds remaining; 0 = no expiry */ + lease_time: number; + desc: string; +} + +/** One perm_rule (ACL) entry. */ +export interface UpnpRule { + section: string; + action: "allow" | "deny"; + int_addr: string; + int_ports: string; + ext_ports: string; + comment: string; + managed: boolean; +} + +export interface UpnpProbe { + installed: boolean; + enabled: boolean; + running: boolean; + applicable: boolean; + secure_mode: boolean; + natpmp: boolean; + upnp: boolean; + lease_file: string; + leases: UpnpLease[]; + rules: UpnpRule[]; +} + +export interface UpnpRuleConfig { + action: "allow" | "deny"; + int_addr: string; + int_ports?: string; + ext_ports?: string; + comment?: string; +} + +export interface UpnpStatus { + installed: boolean; + enabled: boolean; + running: boolean; + applicable: boolean; +} + export interface FWZone { name: string; input: string; diff --git a/internal/modules/upnp.go b/internal/modules/upnp.go new file mode 100644 index 0000000..2ebe9ea --- /dev/null +++ b/internal/modules/upnp.go @@ -0,0 +1,519 @@ +package modules + +import ( + "fmt" + "net" + "os" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/gnacho/netgrip/internal/executor" +) + +// UPnP/NAT-PMP integration (#448). miniupnpd owns its nft table; NetGrip only +// reads UCI (/etc/config/upnpd) and drives /etc/init.d/miniupnpd +// (start/stop/reload/restart/enable/disable). It never writes nft rules. +// +// Options used below are the ones the OpenWrt package ships (openwrt/packages +// net/miniupnpd, files/upnpd.config): enabled, enable_natpmp, enable_upnp, +// secure_mode, internal_iface, upnp_lease_file and the perm_rule ACL sections +// (action, ext_ports, int_addr, int_ports, comment). secure_mode=1 restricts +// a mapping to the requesting LAN host and is the "restricted to LAN" switch. + +const ( + upnpInitd = "/etc/init.d/miniupnpd" + upnpLeaseFileDef = "/var/run/miniupnpd.leases" + upnpRulePrefix = "netgrip_upnp_" +) + +// UpnpLease is one active port mapping read from the miniupnpd lease file. +type UpnpLease struct { + Proto string `json:"proto"` // "TCP" or "UDP" + ExtPort int `json:"ext_port"` + IntAddr string `json:"int_addr"` + IntPort int `json:"int_port"` + LeaseTime int64 `json:"lease_time"` // seconds remaining; 0 = no expiry + Desc string `json:"desc"` +} + +// UpnpRule is one perm_rule (ACL) entry. Miniupnpd applies these before a +// mapping is accepted: an allow rule permits, a deny rule rejects. +type UpnpRule struct { + Section string `json:"section"` + Action string `json:"action"` // "allow" or "deny" + IntAddr string `json:"int_addr"` + IntPorts string `json:"int_ports"` + ExtPorts string `json:"ext_ports"` + Comment string `json:"comment"` + Managed bool `json:"managed"` +} + +// UpnpProbe is the API view of the UPnP/NAT-PMP page. +type UpnpProbe struct { + Installed bool `json:"installed"` + Enabled bool `json:"enabled"` // upnpd.config.enabled + Running bool `json:"running"` + Applicable bool `json:"applicable"` // gateway with an uplink + SecureMode bool `json:"secure_mode"` + NatPmp bool `json:"natpmp"` // enable_natpmp + Upnp bool `json:"upnp"` // enable_upnp + LeaseFile string `json:"lease_file"` + Leases []UpnpLease `json:"leases"` + Rules []UpnpRule `json:"rules"` +} + +// UpnpStatus is the lightweight status for the Services overview card. +type UpnpStatus struct { + Installed bool `json:"installed"` + Enabled bool `json:"enabled"` + Running bool `json:"running"` + Applicable bool `json:"applicable"` +} + +// upnpInstalled reports whether the miniupnpd init script is present. +func upnpInstalled() bool { + _, err := os.Stat(upnpInitd) + return err == nil +} + +// upnpSections parses `uci show upnpd` once into its sections. +func upnpSections() map[string]uciSection { + out, err := exec.Command("uci", "show", "upnpd").Output() + if err != nil { + return nil + } + return parseUCIShow(string(out), "upnpd") +} + +// upnpConfigOpts returns the options of the "config" section (the named +// master section the stock config ships), falling back to whatever section +// carries upnpd options. +func upnpConfigOpts(sections map[string]uciSection) map[string][]string { + if s, ok := sections["config"]; ok { + return s.Options + } + for _, s := range sections { + if len(s.Options) > 0 { + return s.Options + } + } + return nil +} + +// upnpOpt returns the first value of an option, or "" when unset. +func upnpOpt(opts map[string][]string, key string) string { + if v := opts[key]; len(v) > 0 { + return v[0] + } + return "" +} + +// upnpRulesFromSections builds the ACL list from the parsed sections. Both +// anonymous (@perm_rule[N]) and named sections count; only the named ones +// prefixed netgrip_upnp_ are managed (removable) by the panel. +func upnpRulesFromSections(sections map[string]uciSection) []UpnpRule { + var out []UpnpRule + for name, s := range sections { + if s.Type != "perm_rule" { + continue + } + out = append(out, UpnpRule{ + Section: name, + Action: s.Option("action"), + IntAddr: s.Option("int_addr"), + IntPorts: s.Option("int_ports"), + ExtPorts: s.Option("ext_ports"), + Comment: s.Option("comment"), + Managed: strings.HasPrefix(name, upnpRulePrefix), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Section < out[j].Section }) + return out +} + +// upnpLeaseFilePath resolves the lease file from UCI with the package default +// as fallback. +func upnpLeaseFilePath(opts map[string][]string) string { + if v := upnpOpt(opts, "upnp_lease_file"); v != "" { + return v + } + return upnpLeaseFileDef +} + +// parseUpnpLeaseFile parses miniupnpd's lease file. Each line has the form +// PROTO:ext_port:int_addr:int_port:timestamp:desc where timestamp is unix time +// in seconds (0 = no expiry). The description is everything after the fifth +// colon, so it may itself contain colons. Malformed lines are skipped +// (fail-soft). Pure on its arguments so tests run off-router. +func parseUpnpLeaseFile(content string, now time.Time) []UpnpLease { + var out []UpnpLease + nowUnix := now.Unix() + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, ":", 6) + if len(parts) != 6 { + continue + } + proto := strings.ToUpper(parts[0]) + if proto != "TCP" && proto != "UDP" && proto != "UDPLITE" { + continue + } + ext, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + intPort, err := strconv.Atoi(parts[3]) + if err != nil { + continue + } + ts, _ := strconv.ParseInt(parts[4], 10, 64) + lease := int64(0) + if ts > 0 { + lease = ts - nowUnix + if lease < 0 { + lease = 0 + } + } + out = append(out, UpnpLease{ + Proto: proto, + ExtPort: ext, + IntAddr: parts[2], + IntPort: intPort, + LeaseTime: lease, + Desc: strings.TrimSpace(parts[5]), + }) + } + return out +} + +// ProbeUpnpStatus reads only installed/enabled/running, cheap on purpose (the +// Services page renders several cards at once). +func ProbeUpnpStatus() *UpnpStatus { + s := &UpnpStatus{Installed: upnpInstalled(), Applicable: hasUplink()} + if !s.Installed { + return s + } + s.Enabled = uciGet("upnpd.config.enabled") == "1" + s.Running = executor.ServiceRunning("miniupnpd") + return s +} + +// ProbeUpnp reads the full UPnP state. Every read is fail-soft: a missing +// lease file or uci package is an omitted field, never an error. +func ProbeUpnp() *UpnpProbe { + p := &UpnpProbe{ + Installed: upnpInstalled(), + Applicable: hasUplink(), + Leases: []UpnpLease{}, + Rules: []UpnpRule{}, + } + if !p.Installed { + return p + } + sections := upnpSections() + opts := upnpConfigOpts(sections) + p.Enabled = upnpOpt(opts, "enabled") == "1" + p.SecureMode = upnpOpt(opts, "secure_mode") != "0" + p.NatPmp = upnpOpt(opts, "enable_natpmp") != "0" + p.Upnp = upnpOpt(opts, "enable_upnp") != "0" + p.LeaseFile = upnpLeaseFilePath(opts) + p.Running = executor.ServiceRunning("miniupnpd") + p.Rules = upnpRulesFromSections(sections) + + if data, err := os.ReadFile(p.LeaseFile); err == nil { + p.Leases = parseUpnpLeaseFile(string(data), time.Now()) + } + return p +} + +// validUpnpActions are the service actions the page exposes. enable/disable +// toggle the enabled master switch in UCI; the rest map to the init script. +var validUpnpActions = map[string]bool{ + "start": true, "stop": true, "reload": true, "restart": true, + "enable": true, "disable": true, +} + +// UpnpAction runs one service action through the executor and verifies the +// resulting state, reversing the action as rollback when the healthcheck +// fails. +func UpnpAction(action string) (*UpnpProbe, bool, error) { + if !validUpnpActions[action] { + return ProbeUpnp(), false, fmt.Errorf("unsupported action %q", action) + } + if !upnpInstalled() { + return ProbeUpnp(), false, fmt.Errorf("upnpd is not installed") + } + before := ProbeUpnpStatus() + var ops []executor.Op + switch action { + case "enable": + snap, _ := executor.Snapshot("upnpd") + // secure_mode=1 restricts each mapping to the requesting LAN host. + // It is forced on here so enabling UPnP never exposes the whole LAN. + ops = []executor.Op{ + {Kind: "uci_set", Args: []string{"upnpd.config.enabled", "1"}}, + {Kind: "uci_set", Args: []string{"upnpd.config.secure_mode", "1"}}, + {Kind: "uci_commit", Args: []string{"upnpd"}}, + {Kind: "initd", Args: []string{"miniupnpd", "enable"}}, + {Kind: "initd", Args: []string{"miniupnpd", "start"}}, + } + if err := executor.Apply(ops, nil); err != nil { + if snap != "" { + _ = executor.Restore("upnpd", snap) + } + return ProbeUpnp(), true, err + } + return upnpActionVerify("enable", before) + case "disable": + snap, _ := executor.Snapshot("upnpd") + ops = []executor.Op{ + {Kind: "uci_set", Args: []string{"upnpd.config.enabled", "0"}}, + {Kind: "uci_commit", Args: []string{"upnpd"}}, + {Kind: "initd", Args: []string{"miniupnpd", "stop"}}, + {Kind: "initd", Args: []string{"miniupnpd", "disable"}}, + } + if err := executor.Apply(ops, nil); err != nil { + if snap != "" { + _ = executor.Restore("upnpd", snap) + } + return ProbeUpnp(), true, err + } + return upnpActionVerify("disable", before) + default: + ops = []executor.Op{{Kind: "initd", Args: []string{"miniupnpd", action}}} + if err := executor.Apply(ops, nil); err != nil { + return ProbeUpnp(), false, err + } + return upnpActionVerify(action, before) + } +} + +// upnpActionVerify healthchecks a service action by polling the light status. +// reload/restart keep the previous running state; start/enable must come up, +// stop/disable must go down. +func upnpActionVerify(action string, before *UpnpStatus) (*UpnpProbe, bool, error) { + want := before.Running + switch action { + case "start", "enable": + want = true + case "stop", "disable": + want = false + } + for i := 0; i < 30; i++ { + st := ProbeUpnpStatus() + if action == "enable" { + if st.Enabled && st.Running { + return ProbeUpnp(), false, nil + } + } else if action == "disable" { + if !st.Enabled && !st.Running { + return ProbeUpnp(), false, nil + } + } else if st.Running == want { + return ProbeUpnp(), false, nil + } + time.Sleep(time.Second) + } + probe := ProbeUpnp() + reverse := map[string]string{"start": "stop", "stop": "start", "enable": "disable", "disable": "enable"} + if r, ok := reverse[action]; ok { + _ = executor.Run(executor.Op{Kind: "initd", Args: []string{"miniupnpd", r}}) + if action == "enable" || action == "disable" { + val := "0" + if action == "disable" { + val = "1" + } + _ = executor.Run(executor.Op{Kind: "uci_set", Args: []string{"upnpd.config.enabled", val}}) + _ = executor.Run(executor.Op{Kind: "uci_commit", Args: []string{"upnpd"}}) + } + } + return probe, true, fmt.Errorf("healthcheck failed after %q, rolled back", action) +} + +// UpnpRuleConfig is the write model for a perm_rule (ACL entry). +type UpnpRuleConfig struct { + Action string `json:"action"` // "allow" or "deny" + IntAddr string `json:"int_addr"` + IntPorts string `json:"int_ports"` + ExtPorts string `json:"ext_ports"` + Comment string `json:"comment"` +} + +// reUpnpPortRange matches a port range as miniupnpd accepts them: a single +// port, "a-b" or "a:b". +var reUpnpPortRange = regexp.MustCompile(`^\d{1,5}([-:]\d{1,5})?$`) + +// reUpnpBadChars mirrors the executor's guard for UCI values. +var reUpnpBadChars = regexp.MustCompile("[;\n\r`]") + +func validUpnpIntAddr(s string) bool { + if net.ParseIP(s) != nil { + return true + } + _, _, err := net.ParseCIDR(s) + return err == nil +} + +func validUpnpComment(s string) bool { + return len(s) <= 128 && !reUpnpBadChars.MatchString(s) +} + +// upnpRuleOps builds the UCI ops that add one perm_rule. Rules use a named +// section (netgrip_upnp_) so re-applies are idempotent and the rule is +// removable, matching the ADR-003 preference for named sections. +func upnpRuleOps(section string, cfg UpnpRuleConfig) ([]executor.Op, error) { + if cfg.Action != "allow" && cfg.Action != "deny" { + return nil, fmt.Errorf("invalid action %q: use allow or deny", cfg.Action) + } + if !validUpnpIntAddr(cfg.IntAddr) { + return nil, fmt.Errorf("invalid int_addr %q: use an IP or CIDR", cfg.IntAddr) + } + for _, ports := range []string{cfg.IntPorts, cfg.ExtPorts} { + if ports != "" && !reUpnpPortRange.MatchString(ports) { + return nil, fmt.Errorf("invalid port range %q", ports) + } + } + if cfg.Comment != "" && !validUpnpComment(cfg.Comment) { + return nil, fmt.Errorf("invalid comment") + } + base := "upnpd." + section + ops := []executor.Op{ + {Kind: "uci_set", Args: []string{base, "perm_rule"}}, + {Kind: "uci_set", Args: []string{base + ".action", cfg.Action}}, + {Kind: "uci_set", Args: []string{base + ".int_addr", cfg.IntAddr}}, + } + if cfg.IntPorts != "" { + ops = append(ops, executor.Op{Kind: "uci_set", Args: []string{base + ".int_ports", cfg.IntPorts}}) + } + if cfg.ExtPorts != "" { + ops = append(ops, executor.Op{Kind: "uci_set", Args: []string{base + ".ext_ports", cfg.ExtPorts}}) + } + if cfg.Comment != "" { + ops = append(ops, executor.Op{Kind: "uci_set", Args: []string{base + ".comment", cfg.Comment}}) + } + ops = append(ops, + executor.Op{Kind: "uci_commit", Args: []string{"upnpd"}}, + executor.Op{Kind: "initd", Args: []string{"miniupnpd", "reload"}}, + ) + return ops, nil +} + +// upnpNextRuleSection returns the next free netgrip_upnp_ section name. +func upnpNextRuleSection(sections map[string]uciSection) string { + max := 0 + for name := range sections { + if !strings.HasPrefix(name, upnpRulePrefix) { + continue + } + if n, err := strconv.Atoi(strings.TrimPrefix(name, upnpRulePrefix)); err == nil && n > max { + max = n + } + } + return fmt.Sprintf("%s%d", upnpRulePrefix, max+1) +} + +// SetUpnpRule adds (add=true) or deletes (add=false) one ACL entry and +// reloads miniupnpd. Deletes only target the panel's own netgrip_upnp_* +// sections; stock rules are read-only. +func SetUpnpRule(add bool, cfg UpnpRuleConfig, section string) (*UpnpProbe, bool, error) { + if !upnpInstalled() { + return ProbeUpnp(), false, fmt.Errorf("upnpd is not installed") + } + if !add { + if !strings.HasPrefix(section, upnpRulePrefix) { + return ProbeUpnp(), false, fmt.Errorf("not a netgrip rule") + } + snap, _ := executor.Snapshot("upnpd") + ops := []executor.Op{ + {Kind: "uci_delete", Args: []string{"upnpd." + section}}, + {Kind: "uci_commit", Args: []string{"upnpd"}}, + {Kind: "initd", Args: []string{"miniupnpd", "reload"}}, + } + if err := executor.Apply(ops, nil); err != nil { + if snap != "" { + _ = executor.Restore("upnpd", snap) + } + return ProbeUpnp(), true, err + } + for _, r := range ProbeUpnp().Rules { + if r.Section == section { + if snap != "" { + _ = executor.Restore("upnpd", snap) + } + return ProbeUpnp(), true, fmt.Errorf("rule still present after delete, rolled back") + } + } + return ProbeUpnp(), false, nil + } + + sections := upnpSections() + section = upnpNextRuleSection(sections) + ops, err := upnpRuleOps(section, cfg) + if err != nil { + return ProbeUpnp(), false, err + } + snap, _ := executor.Snapshot("upnpd") + if err := executor.Apply(ops, nil); err != nil { + if snap != "" { + _ = executor.Restore("upnpd", snap) + } + return ProbeUpnp(), true, err + } + for _, r := range ProbeUpnp().Rules { + if r.Section == section { + return ProbeUpnp(), false, nil + } + } + if snap != "" { + _ = executor.Restore("upnpd", snap) + } + _ = executor.Run(executor.Op{Kind: "initd", Args: []string{"miniupnpd", "reload"}}) + return ProbeUpnp(), true, fmt.Errorf("healthcheck failed after rule change, rolled back") +} + +// InstallUpnp installs the miniupnpd package after explicit confirmation +// from the UI (disk/RAM impact is stated there). Never called implicitly. +func InstallUpnp(confirm bool) (*UpnpProbe, error) { + if !confirm { + return ProbeUpnp(), fmt.Errorf("installation needs explicit confirmation") + } + if upnpInstalled() { + return ProbeUpnp(), nil + } + if err := executor.Run(executor.Op{Kind: "pkg_add", Args: []string{"miniupnpd"}}); err != nil { + return ProbeUpnp(), fmt.Errorf("install miniupnpd: %w", err) + } + return ProbeUpnp(), nil +} + +// UninstallUpnp removes the miniupnpd package after explicit confirmation. +// The service is stopped and its rc.d autostart removed before the package, +// so no half-removed state is left behind. The UCI config (/etc/config/upnpd) +// stays on disk, as opkg keeps conffiles: the UI says so in the confirmation. +func UninstallUpnp(confirm bool) (*UpnpProbe, error) { + if !confirm { + return ProbeUpnp(), fmt.Errorf("uninstall needs explicit confirmation") + } + if !upnpInstalled() { + return ProbeUpnp(), nil + } + var ops []executor.Op + if executor.ServiceRunning("miniupnpd") { + ops = append(ops, executor.Op{Kind: "initd", Args: []string{"miniupnpd", "stop"}}) + } + if executor.ServiceEnabled("miniupnpd") { + ops = append(ops, executor.Op{Kind: "initd", Args: []string{"miniupnpd", "disable"}}) + } + ops = append(ops, executor.Op{Kind: "pkg_del", Args: []string{"miniupnpd"}}) + if err := executor.Apply(ops, nil); err != nil { + return ProbeUpnp(), fmt.Errorf("uninstall miniupnpd: %w", err) + } + return ProbeUpnp(), nil +} diff --git a/internal/modules/upnp_test.go b/internal/modules/upnp_test.go new file mode 100644 index 0000000..4c0fd0b --- /dev/null +++ b/internal/modules/upnp_test.go @@ -0,0 +1,169 @@ +package modules + +import ( + "testing" + "time" + + "github.com/gnacho/netgrip/internal/executor" +) + +// leaseFixture is the real /var/run/miniupnpd.leases format written by +// miniupnpd 2.x (upnpredirect.c lease_file_add): PROTO:ext:int:port:ts:desc +// with an absolute unix timestamp. The OpenWrt build defines neither +// SUPPORT_REMOTEHOST nor LEASEFILE_USE_REMAINING_TIME. +const leaseFixture = `TCP:5000:192.168.1.10:5000:1700000000:Plex Media Server +UDP:51820:192.168.1.20:51820:0:wireguard +TCP:8080:192.168.1.30:80:1700000300:web:8080 +` + +func TestParseUpnpLeaseFile(t *testing.T) { + now := time.Unix(1699999900, 0) + leases := parseUpnpLeaseFile(leaseFixture, now) + if len(leases) != 3 { + t.Fatalf("leases = %d, want 3", len(leases)) + } + l := leases[0] + if l.Proto != "TCP" || l.ExtPort != 5000 || l.IntAddr != "192.168.1.10" || l.IntPort != 5000 { + t.Errorf("lease[0] = %+v", l) + } + if l.LeaseTime != 100 { // 1700000000 - 1699999900 + t.Errorf("lease[0].lease_time = %d, want 100", l.LeaseTime) + } + if l.Desc != "Plex Media Server" { + t.Errorf("lease[0].desc = %q", l.Desc) + } + // A 0 timestamp means no expiry. + if leases[1].LeaseTime != 0 { + t.Errorf("lease[1].lease_time = %d, want 0", leases[1].LeaseTime) + } + // The description may itself contain colons. + if leases[2].Desc != "web:8080" { + t.Errorf("lease[2].desc = %q", leases[2].Desc) + } + // An already-expired lease clamps to 0. + if got := parseUpnpLeaseFile("TCP:1:192.168.1.1:1:100:old\n", time.Unix(1000, 0)); got[0].LeaseTime != 0 { + t.Errorf("expired lease_time = %d, want 0", got[0].LeaseTime) + } + // Malformed lines are skipped. + if got := parseUpnpLeaseFile("garbage\nno-colons\nTCP:1:2\n", now); len(got) != 0 { + t.Errorf("malformed parsed %d entries, want 0", len(got)) + } +} + +// upnpShowFixture is a trimmed `uci show upnpd` with the stock config plus +// one anonymous and one named perm_rule. +const upnpShowFixture = `upnpd.config=upnpd +upnpd.config.enabled='1' +upnpd.config.enable_natpmp='1' +upnpd.config.enable_upnp='1' +upnpd.config.secure_mode='1' +upnpd.config.internal_iface='lan' +upnpd.config.upnp_lease_file='/var/run/miniupnpd.leases' +upnpd.@perm_rule[0]=perm_rule +upnpd.@perm_rule[0].action='allow' +upnpd.@perm_rule[0].ext_ports='1024-65535' +upnpd.@perm_rule[0].int_addr='0.0.0.0/0' +upnpd.@perm_rule[0].int_ports='1024-65535' +upnpd.@perm_rule[0].comment='Allow high ports' +upnpd.netgrip_upnp_1=perm_rule +upnpd.netgrip_upnp_1.action='deny' +upnpd.netgrip_upnp_1.int_addr='192.168.1.66' +upnpd.netgrip_upnp_1.comment='blocked client' +` + +func TestUpnpConfigAndRules(t *testing.T) { + sections := parseUCIShow(upnpShowFixture, "upnpd") + opts := upnpConfigOpts(sections) + if got := upnpOpt(opts, "enabled"); got != "1" { + t.Errorf("enabled = %q", got) + } + if got := upnpOpt(opts, "upnp_lease_file"); got != "/var/run/miniupnpd.leases" { + t.Errorf("upnp_lease_file = %q", got) + } + if got := upnpLeaseFilePath(opts); got != "/var/run/miniupnpd.leases" { + t.Errorf("lease path = %q", got) + } + + rules := upnpRulesFromSections(sections) + if len(rules) != 2 { + t.Fatalf("rules = %d, want 2", len(rules)) + } + // Sorted by section name: @perm_rule[0] before netgrip_upnp_1. + if rules[0].Section != "@perm_rule[0]" || rules[0].Managed { + t.Errorf("rules[0] = %+v, want anonymous non-managed", rules[0]) + } + if rules[1].Section != "netgrip_upnp_1" || !rules[1].Managed { + t.Errorf("rules[1] = %+v, want managed netgrip rule", rules[1]) + } + if rules[1].Action != "deny" || rules[1].IntAddr != "192.168.1.66" || rules[1].Comment != "blocked client" { + t.Errorf("rules[1] fields = %+v", rules[1]) + } +} + +func TestUpnpLeaseFilePathDefault(t *testing.T) { + if got := upnpLeaseFilePath(nil); got != upnpLeaseFileDef { + t.Errorf("default lease path = %q", got) + } +} + +func TestUpnpRuleOps(t *testing.T) { + ops, err := upnpRuleOps("netgrip_upnp_1", UpnpRuleConfig{ + Action: "allow", + IntAddr: "192.168.1.0/24", + IntPorts: "1024-65535", + ExtPorts: "1024-65535", + Comment: "whole lan", + }) + if err != nil { + t.Fatalf("upnpRuleOps: %v", err) + } + // First op declares the named section type, last two commit + reload. + if ops[0].Kind != "uci_set" || ops[0].Args[0] != "upnpd.netgrip_upnp_1" || ops[0].Args[1] != "perm_rule" { + t.Errorf("ops[0] = %+v", ops[0]) + } + if ops[len(ops)-2].Kind != "uci_commit" || ops[len(ops)-1].Kind != "initd" { + t.Errorf("trailing ops = %+v / %+v", ops[len(ops)-2], ops[len(ops)-1]) + } + // Every write must validate against the executor allowlist. + for _, op := range ops { + if err := executor.Validate(op); err != nil { + t.Errorf("op %+v failed allowlist: %v", op, err) + } + } +} + +func TestUpnpRuleOpsValidation(t *testing.T) { + if _, err := upnpRuleOps("netgrip_upnp_1", UpnpRuleConfig{Action: "maybe", IntAddr: "192.168.1.1"}); err == nil { + t.Errorf("bad action accepted") + } + if _, err := upnpRuleOps("netgrip_upnp_1", UpnpRuleConfig{Action: "allow", IntAddr: "not-an-ip"}); err == nil { + t.Errorf("bad int_addr accepted") + } + if _, err := upnpRuleOps("netgrip_upnp_1", UpnpRuleConfig{Action: "allow", IntAddr: "192.168.1.1", ExtPorts: "1;2"}); err == nil { + t.Errorf("bad ext_ports accepted") + } + if _, err := upnpRuleOps("netgrip_upnp_1", UpnpRuleConfig{Action: "allow", IntAddr: "192.168.1.1", Comment: "a;b"}); err == nil { + t.Errorf("bad comment accepted") + } +} + +func TestUpnpNextRuleSection(t *testing.T) { + sections := parseUCIShow(upnpShowFixture, "upnpd") + if got := upnpNextRuleSection(sections); got != "netgrip_upnp_2" { + t.Errorf("next section = %q, want netgrip_upnp_2", got) + } + if got := upnpNextRuleSection(nil); got != "netgrip_upnp_1" { + t.Errorf("next section from empty = %q", got) + } +} + +func TestUpnpActionsValid(t *testing.T) { + for _, a := range []string{"start", "stop", "reload", "restart", "enable", "disable"} { + if !validUpnpActions[a] { + t.Errorf("action %q should be valid", a) + } + } + if validUpnpActions["destroy"] { + t.Errorf("action destroy should not be valid") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d490328..855acf8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -146,6 +146,13 @@ func New(rpcdURL, version string, secure bool, servingCert string) *Server { s.mux.HandleFunc("GET /api/banip/blocklist", s.requireAuth(s.handleBanipListGet)) s.mux.HandleFunc("POST /api/banip/blocklist", s.requireAuth(s.handleBanipListAdd)) s.mux.HandleFunc("DELETE /api/banip/blocklist", s.requireAuth(s.handleBanipListDelete)) + s.mux.HandleFunc("GET /api/upnp", s.requireAuth(s.handleUpnpGet)) + s.mux.HandleFunc("GET /api/upnp/status", s.requireAuth(s.handleUpnpStatus)) + s.mux.HandleFunc("POST /api/upnp/action", s.requireAuth(s.handleUpnpAction)) + s.mux.HandleFunc("POST /api/upnp/install", s.requireAuth(s.handleUpnpInstall)) + s.mux.HandleFunc("POST /api/upnp/uninstall", s.requireAuth(s.handleUpnpUninstall)) + s.mux.HandleFunc("POST /api/upnp/rule", s.requireAuth(s.handleUpnpRuleAdd)) + s.mux.HandleFunc("DELETE /api/upnp/rule", s.requireAuth(s.handleUpnpRuleDelete)) s.mux.HandleFunc("GET /api/sqm", s.requireAuth(s.handleSQMGet)) s.mux.HandleFunc("POST /api/sqm", s.requireAuth(s.handleSQMSet)) s.mux.HandleFunc("POST /api/sqm/test", s.requireAuth(s.handleBufferbloatTest)) @@ -1086,6 +1093,86 @@ func (s *Server) handleBanipListDelete(w http.ResponseWriter, r *http.Request) { writeModuleResult(w, probe, rolledBack, err) } +func (s *Server) handleUpnpGet(w http.ResponseWriter, _ *http.Request) { + // No cache: the probe is two cheap forks (uci show + lease file read). + writeJSON(w, modules.ProbeUpnp()) +} + +func (s *Server) handleUpnpStatus(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, modules.ProbeUpnpStatus()) +} + +type upnpActionRequest struct { + Action string `json:"action"` +} + +func (s *Server) handleUpnpAction(w http.ResponseWriter, r *http.Request) { + var req upnpActionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + probe, rolledBack, err := modules.UpnpAction(req.Action) + writeModuleResult(w, probe, rolledBack, err) +} + +type upnpInstallRequest struct { + Confirm bool `json:"confirm"` +} + +func (s *Server) handleUpnpInstall(w http.ResponseWriter, r *http.Request) { + var req upnpInstallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + probe, err := modules.InstallUpnp(req.Confirm) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, probe) +} + +func (s *Server) handleUpnpUninstall(w http.ResponseWriter, r *http.Request) { + var req upnpInstallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + probe, err := modules.UninstallUpnp(req.Confirm) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, probe) +} + +type upnpRuleRequest struct { + modules.UpnpRuleConfig + Section string `json:"section"` +} + +func (s *Server) handleUpnpRuleAdd(w http.ResponseWriter, r *http.Request) { + var req upnpRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + probe, rolledBack, err := modules.SetUpnpRule(true, req.UpnpRuleConfig, "") + writeModuleResult(w, probe, rolledBack, err) +} + +func (s *Server) handleUpnpRuleDelete(w http.ResponseWriter, r *http.Request) { + var req upnpRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + probe, rolledBack, err := modules.SetUpnpRule(false, modules.UpnpRuleConfig{}, req.Section) + writeModuleResult(w, probe, rolledBack, err) +} + func (s *Server) handleSQMGet(w http.ResponseWriter, _ *http.Request) { writeJSON(w, modules.ProbeSQM()) }