diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 9bea374c..f9e20f8a 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 { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; import { createSocks5Connection } from "../utils/socks5-helper.js"; @@ -1783,6 +1784,25 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ login_stats = await collectLoginStats(client); } catch (e) {} + let ports: { + source: "ss" | "netstat" | "none"; + ports: Array<{ + protocol: "tcp" | "udp"; + localAddress: string; + localPort: number; + state?: string; + pid?: number; + process?: string; + }>; + } = { + source: "none", + ports: [], + }; + try { + ports = await collectPortsMetrics(client); + } catch (e) { + statsLogger.debug("Failed to collect ports metrics", { + operation: "ports_metrics_failed", let firewall: { type: "iptables" | "nftables" | "none"; status: "active" | "inactive" | "unknown"; @@ -1825,6 +1845,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ processes, system, login_stats, + ports, firewall, }; diff --git a/src/backend/ssh/widgets/ports-collector.ts b/src/backend/ssh/widgets/ports-collector.ts new file mode 100644 index 00000000..57e8161e --- /dev/null +++ b/src/backend/ssh/widgets/ports-collector.ts @@ -0,0 +1,155 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import type { PortsMetrics, ListeningPort } from "../../../types/stats-widgets.js"; + +function parseSsOutput(output: string): ListeningPort[] { + const ports: ListeningPort[] = []; + const lines = output.split("\n").slice(1); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parts = trimmed.split(/\s+/); + if (parts.length < 5) continue; + + const protocol = parts[0]?.toLowerCase(); + if (protocol !== "tcp" && protocol !== "udp") continue; + + const state = parts[1]; + const localAddr = parts[4]; + + if (!localAddr) continue; + + const lastColon = localAddr.lastIndexOf(":"); + if (lastColon === -1) continue; + + const address = localAddr.substring(0, lastColon); + const portStr = localAddr.substring(lastColon + 1); + const port = parseInt(portStr, 10); + + if (isNaN(port)) continue; + + const portEntry: ListeningPort = { + protocol: protocol as "tcp" | "udp", + localAddress: address.replace(/^\[|\]$/g, ""), + localPort: port, + state: protocol === "tcp" ? state : undefined, + }; + + const processInfo = parts[6]; + if (processInfo && processInfo.startsWith("users:")) { + const pidMatch = processInfo.match(/pid=(\d+)/); + const nameMatch = processInfo.match(/\("([^"]+)"/); + if (pidMatch) portEntry.pid = parseInt(pidMatch[1], 10); + if (nameMatch) portEntry.process = nameMatch[1]; + } + + ports.push(portEntry); + } + + return ports; +} + +function parseNetstatOutput(output: string): ListeningPort[] { + const ports: ListeningPort[] = []; + const lines = output.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parts = trimmed.split(/\s+/); + if (parts.length < 4) continue; + + const proto = parts[0]?.toLowerCase(); + if (!proto) continue; + + let protocol: "tcp" | "udp"; + if (proto.startsWith("tcp")) { + protocol = "tcp"; + } else if (proto.startsWith("udp")) { + protocol = "udp"; + } else { + continue; + } + + const localAddr = parts[3]; + if (!localAddr) continue; + + const lastColon = localAddr.lastIndexOf(":"); + if (lastColon === -1) continue; + + const address = localAddr.substring(0, lastColon); + const portStr = localAddr.substring(lastColon + 1); + const port = parseInt(portStr, 10); + + if (isNaN(port)) continue; + + const portEntry: ListeningPort = { + protocol, + localAddress: address, + localPort: port, + }; + + if (protocol === "tcp" && parts.length >= 6) { + portEntry.state = parts[5]; + } + + const pidProgram = parts[parts.length - 1]; + if (pidProgram && pidProgram.includes("/")) { + const [pidStr, process] = pidProgram.split("/"); + const pid = parseInt(pidStr, 10); + if (!isNaN(pid)) portEntry.pid = pid; + if (process) portEntry.process = process; + } + + ports.push(portEntry); + } + + return ports; +} + +export async function collectPortsMetrics( + client: Client, +): Promise { + try { + const ssResult = await execCommand( + client, + "ss -tulnp 2>/dev/null", + 15000, + ); + + if (ssResult.stdout && ssResult.stdout.includes("Local")) { + const ports = parseSsOutput(ssResult.stdout); + return { + source: "ss", + ports: ports.sort((a, b) => a.localPort - b.localPort), + }; + } + + const netstatResult = await execCommand( + client, + "netstat -tulnp 2>/dev/null", + 15000, + ); + + if (netstatResult.stdout && netstatResult.stdout.includes("Local")) { + const ports = parseNetstatOutput(netstatResult.stdout); + return { + source: "netstat", + ports: ports.sort((a, b) => a.localPort - b.localPort), + }; + } + + return { + source: "none", + ports: [], + }; + } catch { + return { + source: "none", + ports: [], + }; + } +} diff --git a/src/locales/en.json b/src/locales/en.json index 79be5fd8..9781e412 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1732,6 +1732,14 @@ "quickActionSuccess": "{{name}} completed successfully", "quickActionFailed": "{{name}} failed", "quickActionError": "Failed to execute {{name}}", + "ports": { + "title": "Listening Ports", + "protocol": "Protocol", + "port": "Port", + "address": "Address", + "state": "State", + "process": "Process", + "noData": "No listening ports data" "firewall": { "title": "Firewall", "active": "Active", diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index 407dc177..31477eb2 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -7,6 +7,20 @@ export type WidgetType = | "processes" | "system" | "login_stats" + | "ports"; + +export interface ListeningPort { + protocol: "tcp" | "udp"; + localAddress: string; + localPort: number; + state?: string; + pid?: number; + process?: string; +} + +export interface PortsMetrics { + source: "ss" | "netstat" | "none"; + ports: ListeningPort[]; | "firewall"; export interface FirewallRule { diff --git a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx index e1fdf48c..20ab7472 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, + PortsWidget, FirewallWidget, } from "./widgets"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; @@ -266,6 +267,10 @@ export function ServerStats({ ); + case "ports": + return ( + + case "firewall": return ( diff --git a/src/ui/desktop/apps/features/server-stats/widgets/PortsWidget.tsx b/src/ui/desktop/apps/features/server-stats/widgets/PortsWidget.tsx new file mode 100644 index 00000000..15fd1db9 --- /dev/null +++ b/src/ui/desktop/apps/features/server-stats/widgets/PortsWidget.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { Network } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { ServerMetrics } from "@/ui/main-axios.ts"; +import type { PortsMetrics, ListeningPort } from "@/types/stats-widgets"; + +interface PortsWidgetProps { + metrics: ServerMetrics | null; + metricsHistory: ServerMetrics[]; +} + +function PortRow({ port }: { port: ListeningPort }) { + const formatAddress = (addr: string) => { + if (addr === "0.0.0.0" || addr === "*" || addr === "::") { + return "*"; + } + return addr; + }; + + return ( +
+
+ {port.protocol.toUpperCase()} +
+
+ {port.localPort} +
+
+ {formatAddress(port.localAddress)} +
+
+ {port.state || "-"} +
+
+ {port.process || (port.pid ? `PID:${port.pid}` : "-")} +
+
+ ); +} + +export function PortsWidget({ metrics }: PortsWidgetProps) { + const { t } = useTranslation(); + + const portsData = ( + metrics as ServerMetrics & { ports?: PortsMetrics } + )?.ports; + + const tcpPorts = portsData?.ports.filter(p => p.protocol === "tcp") || []; + const udpPorts = portsData?.ports.filter(p => p.protocol === "udp") || []; + + return ( +
+
+ +

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

+ {portsData && portsData.source !== "none" && ( + + {portsData.source} + + )} +
+ +
+ + TCP: {tcpPorts.length} + + + UDP: {udpPorts.length} + +
+ + {portsData && portsData.ports.length > 0 ? ( +
+
+
{t("serverStats.ports.protocol")}
+
{t("serverStats.ports.port")}
+
{t("serverStats.ports.address")}
+
{t("serverStats.ports.state")}
+
{t("serverStats.ports.process")}
+
+
+ {portsData.ports.map((port, idx) => ( + + ))} +
+
+ ) : ( +
+

+ {t("serverStats.ports.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 8bb4599d..db72d0fe 100644 --- a/src/ui/desktop/apps/features/server-stats/widgets/index.ts +++ b/src/ui/desktop/apps/features/server-stats/widgets/index.ts @@ -6,4 +6,5 @@ export { UptimeWidget } from "./UptimeWidget.tsx"; export { ProcessesWidget } from "./ProcessesWidget.tsx"; export { SystemWidget } from "./SystemWidget.tsx"; export { LoginStatsWidget } from "./LoginStatsWidget.tsx"; +export { PortsWidget } from "./PortsWidget.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 06c19f27..752df715 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", + "ports", "firewall", ]), ) @@ -328,6 +329,7 @@ export function HostManagerEditor({ "uptime", "system", "login_stats", + "ports", "firewall", ]), statusCheckEnabled: z.boolean().default(true), @@ -344,6 +346,7 @@ export function HostManagerEditor({ "uptime", "system", "login_stats", + "ports", "firewall", ], statusCheckEnabled: true, 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 2f44ff94..e0f35a8b 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", + "ports", "firewall", ] as const ).map((widget) => ( @@ -267,6 +268,8 @@ export function HostStatisticsTab({ {widget === "system" && t("serverStats.systemInfo")} {widget === "login_stats" && t("serverStats.loginStats")} + {widget === "ports" && + t("serverStats.ports.title")} {widget === "firewall" && t("serverStats.firewall.title")}