Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,42 @@ const realApi = {
body: JSON.stringify({ entry }),
}),

// UPnP / NAT-PMP (#448)
upnp: () =>
request<import("./types").UpnpProbe>("/api/upnp"),
upnpStatus: () =>
request<import("./types").UpnpStatus>("/api/upnp/status"),
upnpAction: (action: string) =>
request<import("./types").ModuleResult<import("./types").UpnpProbe>>("/api/upnp/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
}),
upnpInstall: () =>
request<import("./types").UpnpProbe>("/api/upnp/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
}),
upnpUninstall: () =>
request<import("./types").UpnpProbe>("/api/upnp/uninstall", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
}),
upnpRuleAdd: (rule: import("./types").UpnpRuleConfig) =>
request<import("./types").ModuleResult<import("./types").UpnpProbe>>("/api/upnp/rule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(rule),
}),
upnpRuleDelete: (section: string) =>
request<import("./types").ModuleResult<import("./types").UpnpProbe>>("/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.
Expand Down
5 changes: 3 additions & 2 deletions app/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page, LucideIcon> = {
overview: LayoutDashboard,
Expand All @@ -39,6 +39,7 @@ const NAV_ICONS: Record<Page, LucideIcon> = {
services: Blocks,
traffic: ChartPie,
banip: ShieldBan,
upnp: ArrowLeftRight,
ports: ArrowLeftRight,
forwards: Forward,
tools: Wrench,
Expand Down Expand Up @@ -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";
Expand Down
20 changes: 20 additions & 0 deletions app/src/demo/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
};
55 changes: 55 additions & 0 deletions app/src/demo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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 });
Expand Down
49 changes: 49 additions & 0 deletions app/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions app/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions app/src/pages/Internet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,6 +25,7 @@ export function InternetPage({ mwan, onMwanChange, fwd, onFwdChange }: {
Sistema. */}
<WanPage mwan={mwan} onMwanChange={onMwanChange} hideMode />
<ForwardsPage fwd={fwd} onFwdChange={onFwdChange} />
<UpnpPage />
<DpiPage />
<BanipPage />
</div>
Expand Down
Loading
Loading