diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index e17f0491..9bea374c 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -19,6 +19,7 @@ import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; import { collectProcessesMetrics } from "./widgets/processes-collector.js"; import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; +import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; import { createSocks5Connection } from "../utils/socks5-helper.js"; async function resolveJumpHost( @@ -1782,6 +1783,39 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ login_stats = await collectLoginStats(client); } catch (e) {} + let firewall: { + type: "iptables" | "nftables" | "none"; + status: "active" | "inactive" | "unknown"; + chains: Array<{ + name: string; + policy: string; + rules: Array<{ + chain: string; + target: string; + protocol: string; + source: string; + destination: string; + dport?: string; + sport?: string; + state?: string; + interface?: string; + extra?: string; + }>; + }>; + } = { + type: "none", + status: "unknown", + chains: [], + }; + try { + firewall = await collectFirewallMetrics(client); + } catch (e) { + statsLogger.debug("Failed to collect firewall metrics", { + operation: "firewall_metrics_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + const result = { cpu, memory, @@ -1791,6 +1825,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ processes, system, login_stats, + firewall, }; metricsCache.set(host.id, result); diff --git a/src/backend/ssh/widgets/firewall-collector.ts b/src/backend/ssh/widgets/firewall-collector.ts new file mode 100644 index 00000000..1043ee39 --- /dev/null +++ b/src/backend/ssh/widgets/firewall-collector.ts @@ -0,0 +1,254 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import type { + FirewallMetrics, + FirewallChain, + FirewallRule, +} from "../../../types/stats-widgets.js"; + +function parseIptablesRule(line: string): FirewallRule | null { + if (!line.startsWith("-A ")) return null; + + const rule: FirewallRule = { + chain: "", + target: "", + protocol: "all", + source: "0.0.0.0/0", + destination: "0.0.0.0/0", + }; + + const chainMatch = line.match(/^-A\s+(\S+)/); + if (chainMatch) { + rule.chain = chainMatch[1]; + } + + const targetMatch = line.match(/-j\s+(\S+)/); + if (targetMatch) { + rule.target = targetMatch[1]; + } + + const protocolMatch = line.match(/-p\s+(\S+)/); + if (protocolMatch) { + rule.protocol = protocolMatch[1]; + } + + const sourceMatch = line.match(/-s\s+(\S+)/); + if (sourceMatch) { + rule.source = sourceMatch[1]; + } + + const destMatch = line.match(/-d\s+(\S+)/); + if (destMatch) { + rule.destination = destMatch[1]; + } + + const dportMatch = line.match(/--dport\s+(\S+)/); + if (dportMatch) { + rule.dport = dportMatch[1]; + } + + const sportMatch = line.match(/--sport\s+(\S+)/); + if (sportMatch) { + rule.sport = sportMatch[1]; + } + + const stateMatch = line.match(/--state\s+(\S+)/); + if (stateMatch) { + rule.state = stateMatch[1]; + } + + const interfaceMatch = line.match(/-i\s+(\S+)/); + if (interfaceMatch) { + rule.interface = interfaceMatch[1]; + } + + return rule; +} + +function parseIptablesOutput(output: string): FirewallChain[] { + const chains: Map = new Map(); + const lines = output.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + + const policyMatch = trimmed.match(/^:(\S+)\s+(\S+)/); + if (policyMatch) { + const [, chainName, policy] = policyMatch; + chains.set(chainName, { + name: chainName, + policy: policy, + rules: [], + }); + continue; + } + + const rule = parseIptablesRule(trimmed); + if (rule) { + let chain = chains.get(rule.chain); + if (!chain) { + chain = { + name: rule.chain, + policy: "ACCEPT", + rules: [], + }; + chains.set(rule.chain, chain); + } + chain.rules.push(rule); + } + } + + return Array.from(chains.values()); +} + +function parseNftablesOutput(output: string): FirewallChain[] { + const chains: FirewallChain[] = []; + let currentChain: FirewallChain | null = null; + + const lines = output.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + + const chainMatch = trimmed.match( + /chain\s+(\S+)\s*\{?\s*(?:type\s+\S+\s+hook\s+(\S+))?/, + ); + if (chainMatch) { + if (currentChain) { + chains.push(currentChain); + } + currentChain = { + name: chainMatch[1].toUpperCase(), + policy: "ACCEPT", + rules: [], + }; + continue; + } + + if (currentChain && trimmed.startsWith("policy ")) { + const policyMatch = trimmed.match(/policy\s+(\S+)/); + if (policyMatch) { + currentChain.policy = policyMatch[1].toUpperCase(); + } + continue; + } + + if (currentChain && trimmed && !trimmed.startsWith("}")) { + const rule: FirewallRule = { + chain: currentChain.name, + target: "", + protocol: "all", + source: "0.0.0.0/0", + destination: "0.0.0.0/0", + }; + + if (trimmed.includes("accept")) rule.target = "ACCEPT"; + else if (trimmed.includes("drop")) rule.target = "DROP"; + else if (trimmed.includes("reject")) rule.target = "REJECT"; + + const tcpMatch = trimmed.match(/tcp\s+dport\s+(\S+)/); + if (tcpMatch) { + rule.protocol = "tcp"; + rule.dport = tcpMatch[1]; + } + + const udpMatch = trimmed.match(/udp\s+dport\s+(\S+)/); + if (udpMatch) { + rule.protocol = "udp"; + rule.dport = udpMatch[1]; + } + + const saddrMatch = trimmed.match(/saddr\s+(\S+)/); + if (saddrMatch) { + rule.source = saddrMatch[1]; + } + + const daddrMatch = trimmed.match(/daddr\s+(\S+)/); + if (daddrMatch) { + rule.destination = daddrMatch[1]; + } + + const iifMatch = trimmed.match(/iif\s+"?(\S+)"?/); + if (iifMatch) { + rule.interface = iifMatch[1].replace(/"/g, ""); + } + + const ctStateMatch = trimmed.match(/ct\s+state\s+(\S+)/); + if (ctStateMatch) { + rule.state = ctStateMatch[1].toUpperCase(); + } + + if (rule.target) { + currentChain.rules.push(rule); + } + } + + if (trimmed === "}") { + if (currentChain) { + chains.push(currentChain); + currentChain = null; + } + } + } + + if (currentChain) { + chains.push(currentChain); + } + + return chains; +} + +export async function collectFirewallMetrics( + client: Client, +): Promise { + try { + const iptablesResult = await execCommand( + client, + "iptables-save 2>/dev/null", + 15000, + ); + + if (iptablesResult.stdout && iptablesResult.stdout.includes("*filter")) { + const chains = parseIptablesOutput(iptablesResult.stdout); + const hasRules = chains.some((c) => c.rules.length > 0); + + return { + type: "iptables", + status: hasRules ? "active" : "inactive", + chains: chains.filter( + (c) => + c.name === "INPUT" || c.name === "OUTPUT" || c.name === "FORWARD", + ), + }; + } + + const nftResult = await execCommand( + client, + "nft list ruleset 2>/dev/null", + 15000, + ); + + if (nftResult.stdout && nftResult.stdout.trim()) { + const chains = parseNftablesOutput(nftResult.stdout); + const hasRules = chains.some((c) => c.rules.length > 0); + + return { + type: "nftables", + status: hasRules ? "active" : "inactive", + chains, + }; + } + + return { + type: "none", + status: "unknown", + chains: [], + }; + } catch { + return { + type: "none", + status: "unknown", + chains: [], + }; + } +} diff --git a/src/locales/en.json b/src/locales/en.json index b80a8466..79be5fd8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1731,7 +1731,25 @@ "executingQuickAction": "Executing {{name}}...", "quickActionSuccess": "{{name}} completed successfully", "quickActionFailed": "{{name}} failed", - "quickActionError": "Failed to execute {{name}}" + "quickActionError": "Failed to execute {{name}}", + "firewall": { + "title": "Firewall", + "active": "Active", + "inactive": "Inactive", + "notDetected": "Not Detected", + "policy": "Policy", + "rules": "rules", + "noRules": "No rules", + "noData": "No firewall data available", + "action": "Action", + "protocol": "Proto", + "port": "Port", + "source": "Source", + "accept": "ACCEPT", + "drop": "DROP", + "reject": "REJECT", + "anywhere": "Anywhere" + } }, "auth": { "tagline": "SSH SERVER MANAGER", diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index f7040ae4..407dc177 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -6,7 +6,33 @@ export type WidgetType = | "uptime" | "processes" | "system" - | "login_stats"; + | "login_stats" + | "firewall"; + +export interface FirewallRule { + chain: string; + target: string; + protocol: string; + source: string; + destination: string; + dport?: string; + sport?: string; + state?: string; + interface?: string; + extra?: string; +} + +export interface FirewallChain { + name: string; + policy: string; + rules: FirewallRule[]; +} + +export interface FirewallMetrics { + type: "iptables" | "nftables" | "none"; + status: "active" | "inactive" | "unknown"; + chains: FirewallChain[]; +} export interface StatsConfig { enabledWidgets: WidgetType[]; diff --git a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx index a87813b5..e1fdf48c 100644 --- a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx +++ b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx @@ -33,6 +33,7 @@ import { ProcessesWidget, SystemWidget, LoginStatsWidget, + FirewallWidget, } from "./widgets"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; @@ -265,6 +266,11 @@ export function ServerStats({ ); + case "firewall": + return ( + + ); + default: return null; } diff --git a/src/ui/desktop/apps/features/server-stats/widgets/FirewallWidget.tsx b/src/ui/desktop/apps/features/server-stats/widgets/FirewallWidget.tsx new file mode 100644 index 00000000..1aee7fa6 --- /dev/null +++ b/src/ui/desktop/apps/features/server-stats/widgets/FirewallWidget.tsx @@ -0,0 +1,213 @@ +import React from "react"; +import { Shield, ShieldOff, ShieldCheck, ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { ServerMetrics } from "@/ui/main-axios.ts"; +import type { + FirewallMetrics, + FirewallChain, + FirewallRule, +} from "@/types/stats-widgets"; + +interface FirewallWidgetProps { + metrics: ServerMetrics | null; + metricsHistory: ServerMetrics[]; +} + +function RuleRow({ rule }: { rule: FirewallRule }) { + const { t } = useTranslation(); + + const getTargetStyle = (target: string) => { + switch (target.toUpperCase()) { + case "ACCEPT": + return "text-green-400"; + case "DROP": + return "text-red-400"; + case "REJECT": + return "text-orange-400"; + default: + return "text-muted-foreground"; + } + }; + + const getTargetLabel = (target: string) => { + switch (target.toUpperCase()) { + case "ACCEPT": + return t("serverStats.firewall.accept"); + case "DROP": + return t("serverStats.firewall.drop"); + case "REJECT": + return t("serverStats.firewall.reject"); + default: + return target; + } + }; + + const formatSource = () => { + if (rule.interface) { + return rule.interface; + } + if (rule.state) { + return rule.state; + } + if (rule.source === "0.0.0.0/0") { + return t("serverStats.firewall.anywhere"); + } + return rule.source; + }; + + return ( +
+
+ {getTargetLabel(rule.target)} +
+
+ {rule.protocol.toUpperCase()} +
+
+ {rule.dport || "-"} +
+
+ {formatSource()} +
+
+ ); +} + +function ChainSection({ chain }: { chain: FirewallChain }) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = React.useState(true); + + const getPolicyStyle = (policy: string) => { + switch (policy.toUpperCase()) { + case "ACCEPT": + return "text-green-400"; + case "DROP": + return "text-red-400"; + case "REJECT": + return "text-orange-400"; + default: + return "text-muted-foreground"; + } + }; + + return ( +
+ + {isOpen && ( + <> + {chain.rules.length > 0 ? ( +
+
+
{t("serverStats.firewall.action")}
+
{t("serverStats.firewall.protocol")}
+
{t("serverStats.firewall.port")}
+
{t("serverStats.firewall.source")}
+
+
+ {chain.rules.map((rule, idx) => ( + + ))} +
+
+ ) : ( +
+ {t("serverStats.firewall.noRules")} +
+ )} + + )} +
+ ); +} + +export function FirewallWidget({ metrics }: FirewallWidgetProps) { + const { t } = useTranslation(); + + const firewall = ( + metrics as ServerMetrics & { firewall?: FirewallMetrics } + )?.firewall; + + const getStatusIcon = () => { + if (!firewall || firewall.type === "none") { + return ; + } + if (firewall.status === "active") { + return ; + } + return ; + }; + + const getStatusText = () => { + if (!firewall || firewall.type === "none") { + return t("serverStats.firewall.notDetected"); + } + if (firewall.status === "active") { + return t("serverStats.firewall.active"); + } + return t("serverStats.firewall.inactive"); + }; + + return ( +
+
+ {getStatusIcon()} +

+ {t("serverStats.firewall.title")} +

+ {firewall && firewall.type !== "none" && ( + + {firewall.type} + + )} +
+ +
+ + {getStatusText()} + +
+ + {firewall && firewall.chains.length > 0 ? ( +
+ {firewall.chains.map((chain) => ( + + ))} +
+ ) : ( +
+

+ {t("serverStats.firewall.noData")} +

+
+ )} +
+ ); +} diff --git a/src/ui/desktop/apps/features/server-stats/widgets/index.ts b/src/ui/desktop/apps/features/server-stats/widgets/index.ts index 5f47b6dc..8bb4599d 100644 --- a/src/ui/desktop/apps/features/server-stats/widgets/index.ts +++ b/src/ui/desktop/apps/features/server-stats/widgets/index.ts @@ -6,3 +6,4 @@ export { UptimeWidget } from "./UptimeWidget.tsx"; export { ProcessesWidget } from "./ProcessesWidget.tsx"; export { SystemWidget } from "./SystemWidget.tsx"; export { LoginStatsWidget } from "./LoginStatsWidget.tsx"; +export { FirewallWidget } from "./FirewallWidget.tsx"; diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx index df3ec54d..06c19f27 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx @@ -317,6 +317,7 @@ export function HostManagerEditor({ "processes", "system", "login_stats", + "firewall", ]), ) .default([ @@ -327,6 +328,7 @@ export function HostManagerEditor({ "uptime", "system", "login_stats", + "firewall", ]), statusCheckEnabled: z.boolean().default(true), statusCheckInterval: z.number().min(5).max(3600).default(30), @@ -342,6 +344,7 @@ export function HostManagerEditor({ "uptime", "system", "login_stats", + "firewall", ], statusCheckEnabled: true, statusCheckInterval: 30, diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatisticsTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatisticsTab.tsx index cd9b3e52..2f44ff94 100644 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatisticsTab.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatisticsTab.tsx @@ -239,6 +239,7 @@ export function HostStatisticsTab({ "processes", "system", "login_stats", + "firewall", ] as const ).map((widget) => (
@@ -266,6 +267,8 @@ export function HostStatisticsTab({ {widget === "system" && t("serverStats.systemInfo")} {widget === "login_stats" && t("serverStats.loginStats")} + {widget === "firewall" && + t("serverStats.firewall.title")}
))}