diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/PortCheckTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/PortCheckTests.cs new file mode 100644 index 000000000..61610987e --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/PortCheckTests.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.Maui.Cli.Models; +using Microsoft.Maui.Cli.Output; +using Microsoft.Maui.Cli.Providers.Port; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +public class PortCheckTests +{ + [Fact] + public void PortCheckResult_JsonShape_HasExpectedProperties() + { + var result = new PortCheckResult + { + Port = 8080, + InUse = true, + Listeners = + [ + new PortListenerResult + { + Pid = 1234, + ProcessName = "dotnet", + Address = "0.0.0.0", + Family = "ipv4", + State = "listen", + } + ] + }; + + var json = JsonSerializer.Serialize(result, MauiCliJsonContext.Default.PortCheckResult); +using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal(8080, root.GetProperty("port").GetInt32()); + Assert.True(root.GetProperty("in_use").GetBoolean()); + + var listener = root.GetProperty("listeners")[0]; + Assert.Equal(1234, listener.GetProperty("pid").GetInt32()); + Assert.Equal("dotnet", listener.GetProperty("process_name").GetString()); + Assert.Equal("0.0.0.0", listener.GetProperty("address").GetString()); + Assert.Equal("ipv4", listener.GetProperty("family").GetString()); + Assert.Equal("listen", listener.GetProperty("state").GetString()); +} + +[Fact] +public void PortCheckResult_Free_SerializesCorrectly() +{ + var result = new PortCheckResult { Port = 80, InUse = false }; + var json = JsonSerializer.Serialize(result, MauiCliJsonContext.Default.PortCheckResult); +using var doc = JsonDocument.Parse(json); + + Assert.False(doc.RootElement.GetProperty("in_use").GetBoolean()); + Assert.Equal(0, doc.RootElement.GetProperty("listeners").GetArrayLength()); +} + +[Fact] +public void ParseLsofOutput_SingleListener_ReturnsCorrectInfo() +{ + var output = "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + + "dotnet 1234 user 10u IPv4 12345 0t0 TCP *:8080 (LISTEN)\n"; + var result = UnixPortInspector.ParseLsofOutput(output, 8080); + + Assert.Single(result); + Assert.Equal(1234, result[0].Pid); + Assert.Equal("dotnet", result[0].ProcessName); + Assert.Equal("0.0.0.0", result[0].Address); +} + +[Fact] +public void ParseLsofOutput_IPv6_ReturnsIpv6Family() +{ + var output = "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + + "nginx 999 root 5u IPv6 54321 0t0 TCP *:443 (LISTEN)\n"; + var result = UnixPortInspector.ParseLsofOutput(output, 443); + + Assert.Single(result); + Assert.Equal("ipv6", result[0].Family); + Assert.Equal("::", result[0].Address); +} + +[Fact] +public void ParseSsOutput_IPv6Wildcard_ReturnsIpv6Address() +{ + var output = "LISTEN 0 128 [::]:8080 [::]:* users:((\"dotnet\",pid=1234,fd=5))\n"; + var result = UnixPortInspector.ParseSsOutput(output, 8080); + + Assert.Single(result); + Assert.Equal("ipv6", result[0].Family); + Assert.Equal("::", result[0].Address); +} + +[Fact] +public void ParseLsofOutput_WrongPort_ReturnsEmpty() +{ + var output = "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + + "dotnet 1234 user 10u IPv4 12345 0t0 TCP *:9090 (LISTEN)\n"; + var result = UnixPortInspector.ParseLsofOutput(output, 8080); + + Assert.Empty(result); +} + +[Fact] +public void ParseSsOutput_WithProcess_ExtractsPidAndName() +{ + var output = "LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:((\"dotnet\",pid=1234,fd=5))\n"; + var result = UnixPortInspector.ParseSsOutput(output, 8080); + + Assert.Single(result); + Assert.Equal(1234, result[0].Pid); + Assert.Equal("dotnet", result[0].ProcessName); + Assert.Equal("0.0.0.0", result[0].Address); +} + +[Fact] +public void ParseSsOutput_WrongPort_ReturnsEmpty() +{ + var output = "LISTEN 0 128 0.0.0.0:9090 0.0.0.0:* users:((\"dotnet\",pid=1234,fd=5))\n"; + var result = UnixPortInspector.ParseSsOutput(output, 8080); + + Assert.Empty(result); +} + +[Fact] +public void ParseNetstatOutput_Tcp4_ExtractsListener() +{ + var output = "Proto Recv-Q Send-Q Local Address Foreign Address (state)\n" + + "tcp4 0 0 127.0.0.1.8080 *.* LISTEN\n"; + var result = UnixPortInspector.ParseNetstatOutput(output, 8080); + + Assert.Single(result); + Assert.Equal("127.0.0.1", result[0].Address); + Assert.Equal("ipv4", result[0].Family); +} + +[Fact] +public void ParseNetstatOutput_Wildcard_MapsToAllInterfaces() +{ + var output = "Proto Recv-Q Send-Q Local Address Foreign Address (state)\n" + + "tcp46 0 0 *.19223 *.* LISTEN\n"; + var result = UnixPortInspector.ParseNetstatOutput(output, 19223); + + Assert.Single(result); + Assert.Equal("::", result[0].Address); + Assert.Equal("ipv6", result[0].Family); +} + +[Fact] +public void ParseNetstatOutput_WrongPort_ReturnsEmpty() +{ + var output = "Proto Recv-Q Send-Q Local Address Foreign Address (state)\n" + + "tcp4 0 0 127.0.0.1.9090 *.* LISTEN\n"; + var result = UnixPortInspector.ParseNetstatOutput(output, 8080); + + Assert.Empty(result); +} + +[Theory] +[InlineData(0x5000u, 80)] +[InlineData(0x901Fu, 8080)] +[InlineData(0xBB01u, 443)] +public void GetPortFromNetworkDword_ReturnsCorrectPort(uint networkDword, int expectedPort) +{ + // The formula: ((dword & 0xFF) << 8) | ((dword >> 8) & 0xFF) + var actual = PortInspectorHelpers.GetPortFromNetworkDword(networkDword); + Assert.Equal(expectedPort, actual); +} +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/PortCommands.cs b/src/Cli/Microsoft.Maui.Cli/Commands/PortCommands.cs new file mode 100644 index 000000000..0ba8c5789 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Commands/PortCommands.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; +using Microsoft.Maui.Cli.Errors; +using Microsoft.Maui.Cli.Models; +using Microsoft.Maui.Cli.Providers.Port; + +namespace Microsoft.Maui.Cli.Commands; + +internal static class PortCommands +{ + public static Command Create() + { + var portCommand = new Command("port", "Diagnose TCP port usage."); + portCommand.Add(CreateCheckCommand()); + return portCommand; + } + + private static Command CreateCheckCommand() + { + var portArg = new Argument("port") { Description = "TCP port number to check (1-65535)." }; + + var checkCommand = new Command("check", "Check which process holds a TCP port.") + { + portArg, + }; + + checkCommand.SetAction((ParseResult parseResult) => + { + var port = parseResult.GetValue(portArg); + var formatter = Program.GetFormatter(parseResult); + + if (port is < 1 or > 65535) + { + formatter.WriteError(new MauiToolException(ErrorCodes.InvalidArgument, $"Port must be between 1 and 65535, got {port}.")); + return 2; + } + + IPortInspector inspector; + try + { + if (OperatingSystem.IsWindows()) + { + inspector = new WindowsPortInspector(); + } + else + { +#pragma warning disable CA1416 + inspector = new UnixPortInspector(); +#pragma warning restore CA1416 + } + } + catch (Exception ex) + { + formatter.WriteError(new MauiToolException(ErrorCodes.PortEnumerationFailed, ex.Message)); + return 2; + } + + List raw; + try + { + raw = [.. inspector.GetListeners(port)]; + } + catch (Exception ex) + { + formatter.WriteError(new MauiToolException(ErrorCodes.PortEnumerationFailed, ex.Message)); + return 2; + } + + var listeners = raw.Select(l => new PortListenerResult + { + Pid = l.Pid, + ProcessName = l.ProcessName, + Address = l.Address, + Family = l.Family, + State = l.State, + }).ToList(); + + var result = new PortCheckResult { Port = port, InUse = listeners.Count > 0, Listeners = listeners }; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + if (useJson) + { + formatter.Write(result); + } + else + { + if (!result.InUse) + { + formatter.WriteSuccess($"Port {port} is free."); + } + else + { + formatter.WriteInfo($"Port {port} is in use:"); + foreach (var l in result.Listeners) + { + var processInfo = l.Pid > 0 ? $"PID {l.Pid} ({(string.IsNullOrEmpty(l.ProcessName) ? "unknown" : l.ProcessName)})" : "unknown process"; + formatter.WriteInfo($" {processInfo} {l.Address} [{l.Family}]"); + } + } + } + + return result.InUse ? 1 : 0; + }); + + return checkCommand; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs b/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs index 01dbd61b7..5cce163a9 100644 --- a/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs +++ b/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs @@ -20,6 +20,7 @@ public static class ErrorCodes public const string InvalidArgument = "E1004"; public const string DeviceNotFound = "E1006"; public const string PlatformNotSupported = "E1007"; + public const string PortEnumerationFailed = "E1008"; // Platform/SDK errors - JDK (E20xx) public const string JdkNotFound = "E2001"; diff --git a/src/Cli/Microsoft.Maui.Cli/Models/PortCheckResult.cs b/src/Cli/Microsoft.Maui.Cli/Models/PortCheckResult.cs new file mode 100644 index 000000000..3f2562131 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Models/PortCheckResult.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.Maui.Cli.Models; + +public sealed record PortCheckResult +{ + [JsonPropertyName("port")] + public int Port { get; init; } + + [JsonPropertyName("in_use")] + public bool InUse { get; init; } + + [JsonPropertyName("listeners")] + public List Listeners { get; init; } = []; +} + +public sealed record PortListenerResult +{ + [JsonPropertyName("pid")] + public int Pid { get; init; } + + [JsonPropertyName("process_name")] + public string ProcessName { get; init; } = string.Empty; + + [JsonPropertyName("address")] + public string Address { get; init; } = string.Empty; + + [JsonPropertyName("family")] + public string Family { get; init; } = "ipv4"; + + [JsonPropertyName("state")] + public string State { get; init; } = "listen"; +} diff --git a/src/Cli/Microsoft.Maui.Cli/Output/MauiCliJsonContext.cs b/src/Cli/Microsoft.Maui.Cli/Output/MauiCliJsonContext.cs index aa385ec35..a7410da83 100644 --- a/src/Cli/Microsoft.Maui.Cli/Output/MauiCliJsonContext.cs +++ b/src/Cli/Microsoft.Maui.Cli/Output/MauiCliJsonContext.cs @@ -61,4 +61,7 @@ namespace Microsoft.Maui.Cli.Output; [JsonSerializable(typeof(SimulatorEraseResult))] [JsonSerializable(typeof(SimulatorAppResult))] [JsonSerializable(typeof(SimulatorAppContainerResult))] +[JsonSerializable(typeof(PortCheckResult))] +[JsonSerializable(typeof(PortListenerResult))] +[JsonSerializable(typeof(List))] internal sealed partial class MauiCliJsonContext : JsonSerializerContext; diff --git a/src/Cli/Microsoft.Maui.Cli/Program.cs b/src/Cli/Microsoft.Maui.Cli/Program.cs index 06b31d598..2c7516553 100644 --- a/src/Cli/Microsoft.Maui.Cli/Program.cs +++ b/src/Cli/Microsoft.Maui.Cli/Program.cs @@ -108,6 +108,9 @@ internal static RootCommand BuildRootCommand() rootCommand.Add(AndroidCommands.Create()); rootCommand.Add(AppleCommands.Create()); + // Port diagnostics + rootCommand.Add(PortCommands.Create()); + // DevFlow automation commands (maui devflow ...) rootCommand.Add(DevFlow.DevFlowCommands.CreateDevFlowCommand(GlobalOptions.JsonOption)); diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Port/IPortInspector.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Port/IPortInspector.cs new file mode 100644 index 000000000..c35d34dd4 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Port/IPortInspector.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Cli.Providers.Port; + +internal interface IPortInspector +{ + IReadOnlyList GetListeners(int port); +} diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Port/PortInspectorHelpers.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Port/PortInspectorHelpers.cs new file mode 100644 index 000000000..6884bebd0 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Port/PortInspectorHelpers.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Cli.Providers.Port; + +internal static class PortInspectorHelpers +{ + internal static int GetPortFromNetworkDword(uint networkDword) + => (int)(((networkDword & 0xFF) << 8) | ((networkDword >> 8) & 0xFF)); +} diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Port/PortListenerInfo.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Port/PortListenerInfo.cs new file mode 100644 index 000000000..c09a8e71a --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Port/PortListenerInfo.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Cli.Providers.Port; + +internal sealed record PortListenerInfo(int Port, int Pid, string ProcessName, string Address, string Family, string State = "listen"); diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Port/UnixPortInspector.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Port/UnixPortInspector.cs new file mode 100644 index 000000000..2a2f03904 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Port/UnixPortInspector.cs @@ -0,0 +1,217 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Net.NetworkInformation; +using System.Text; + +namespace Microsoft.Maui.Cli.Providers.Port; + +internal sealed class UnixPortInspector : IPortInspector +{ + public IReadOnlyList GetListeners(int port) + { + var listeners = TryLsof(port); + if (listeners is not null) return listeners; + + if (OperatingSystem.IsLinux()) + { + listeners = TrySs(port); + if (listeners is not null) return listeners; + } + + if (OperatingSystem.IsMacOS()) + { + listeners = TryNetstat(port); + if (listeners is not null) return listeners; + } + + return FallbackIPGlobalProperties(port); + } + + private static List? TryLsof(int port) + { + try + { + var output = RunCommand("lsof", $"-nP -iTCP:{port} -sTCP:LISTEN"); + if (output is null) return null; + var result = ParseLsofOutput(output, port); + return result.Count > 0 ? result : null; + } + catch { return null; } + } + + private static List? TrySs(int port) + { + try + { + var output = RunCommand("ss", $"-tlnpH sport = :{port}"); + if (output is null) return null; + var result = ParseSsOutput(output, port); + return result.Count > 0 ? result : null; + } + catch { return null; } + } + + private static List? TryNetstat(int port) + { + try + { + var output = RunCommand("netstat", "-anp tcp"); + if (output is null) return null; + var result = ParseNetstatOutput(output, port); + return result.Count > 0 ? result : null; + } + catch { return null; } + } + + private static List FallbackIPGlobalProperties(int port) + { + var result = new List(); + var props = IPGlobalProperties.GetIPGlobalProperties(); + foreach (var ep in props.GetActiveTcpListeners()) + { + if (ep.Port != port) continue; + var family = ep.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? "ipv6" : "ipv4"; + result.Add(new PortListenerInfo(port, 0, string.Empty, ep.Address.ToString(), family)); + } + return result; + } + + internal static List ParseLsofOutput(string output, int port) + { + var result = new List(); + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 9) continue; + + // NAME column may be followed by "(LISTEN)" as a separate token + var namePart = parts[^1] == "(LISTEN)" && parts.Length >= 10 ? parts[^2] : parts[^1]; + if (!namePart.Contains(':')) continue; + + var colonIdx = namePart.LastIndexOf(':'); + var addrRaw = namePart[..colonIdx]; + var portRaw = namePart[(colonIdx + 1)..].TrimEnd(')').Trim(); + if (!int.TryParse(portRaw, out var parsedPort) || parsedPort != port) continue; + + var typeCol = parts.Length > 4 ? parts[4] : string.Empty; + var isIpv6 = typeCol == "IPv6"; + var addr = addrRaw == "*" ? (isIpv6 ? "::" : "0.0.0.0") : addrRaw; + var family = isIpv6 || addr.Contains(':') ? "ipv6" : "ipv4"; + + if (!int.TryParse(parts[1], out var pid)) continue; + result.Add(new PortListenerInfo(port, pid, parts[0], addr, family)); + } + return result; + } + + internal static List ParseSsOutput(string output, int port) + { + var result = new List(); + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) continue; + + var localAddr = parts.FirstOrDefault(p => p.EndsWith($":{port}")); + if (localAddr is null) continue; + + var colonIdx = localAddr.LastIndexOf(':'); + var addrPart = colonIdx >= 0 ? localAddr[..colonIdx] : "0.0.0.0"; + // ss prints IPv6 wildcards as "*" or "[::]"; honor those as "::" when bracketed or paired with ipv6 family hint + var isBracketed = addrPart.StartsWith('[') && addrPart.EndsWith(']'); + var addrCore = isBracketed ? addrPart[1..^1] : addrPart; + var hasIpv6Hint = isBracketed || (addrCore.Contains(':') && !addrCore.StartsWith("::ffff:")); + var addr = addrCore is "*" or "" + ? (hasIpv6Hint ? "::" : "0.0.0.0") + : addrCore; + var family = hasIpv6Hint ? "ipv6" : "ipv4"; + + int pid = 0; + string processName = string.Empty; + var usersIdx = line.IndexOf("users:((", StringComparison.Ordinal); + if (usersIdx >= 0) + { + var usersSection = line[(usersIdx + 8)..]; + var q1 = usersSection.IndexOf('"'); + var q2 = q1 >= 0 ? usersSection.IndexOf('"', q1 + 1) : -1; + if (q1 >= 0 && q2 > q1) processName = usersSection[(q1 + 1)..q2]; + + var pidIdx = usersSection.IndexOf("pid=", StringComparison.Ordinal); + if (pidIdx >= 0) + { + var pidStr = usersSection[(pidIdx + 4)..]; + var comma = pidStr.IndexOf(','); + var end = pidStr.IndexOf(')'); + var len = comma >= 0 && (end < 0 || comma < end) ? comma : end; + if (len > 0) int.TryParse(pidStr[..len], out pid); + } + } + result.Add(new PortListenerInfo(port, pid, processName, addr, family)); + } + return result; + } + + internal static List ParseNetstatOutput(string output, int port) + { + var result = new List(); + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 6) continue; + if (!parts[0].StartsWith("tcp", StringComparison.OrdinalIgnoreCase)) continue; + if (!parts[^1].Equals("LISTEN", StringComparison.OrdinalIgnoreCase)) continue; + + var localAddr = parts[3]; + var dotIdx = localAddr.LastIndexOf('.'); + if (dotIdx < 0) continue; + if (!int.TryParse(localAddr[(dotIdx + 1)..], out var parsedPort) || parsedPort != port) continue; + + var addrPart = localAddr[..dotIdx]; + var family = parts[0].Contains('6') ? "ipv6" : "ipv4"; + var addr = addrPart == "*" ? (family == "ipv6" ? "::" : "0.0.0.0") : addrPart; + result.Add(new PortListenerInfo(port, 0, string.Empty, addr, family)); + } + return result; + } + + private static string? RunCommand(string executable, string arguments) + { + try + { +using var process = new Process + { + StartInfo = new ProcessStartInfo(executable, arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + var stdout = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); }; + // Drain stderr to a sink we discard — required to prevent the child from + // blocking on a full stderr pipe (e.g., lsof permission-denied messages). + process.ErrorDataReceived += (_, _) => { }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(5000)) + { + try { process.Kill(entireProcessTree: true); } catch { /* best effort */ } + return null; + } + // Ensure async readers have flushed before we read ExitCode. + process.WaitForExit(); + + var output = stdout.ToString(); + return process.ExitCode == 0 || output.Length > 0 ? output : null; + } + catch { return null; } + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Port/WindowsPortInspector.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Port/WindowsPortInspector.cs new file mode 100644 index 000000000..2df3c39b0 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Port/WindowsPortInspector.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Microsoft.Maui.Cli.Providers.Port; + +[SupportedOSPlatform("windows")] +internal sealed class WindowsPortInspector : IPortInspector +{ + private const int AF_INET = 2; + private const int AF_INET6 = 23; + + private enum TcpTableClass + { + // TCP_TABLE_OWNER_PID_LISTENER = 3 (per Win32 IPHLPAPI MIB_TCP_TABLE_CLASS). + // Value 6 is OWNER_MODULE_LISTENER, which returns a different (~160-byte) row layout. + OwnerPidListener = 3, + } + + [StructLayout(LayoutKind.Sequential)] + private struct MibTcpRowOwnerPid + { + public uint dwState; + public uint dwLocalAddr; + public uint dwLocalPort; + public uint dwRemoteAddr; + public uint dwRemotePort; + public uint dwOwningPid; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MibTcp6RowOwnerPid + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[] ucLocalAddr; + public uint dwLocalScopeId; + public uint dwLocalPort; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[] ucRemoteAddr; + public uint dwRemoteScopeId; + public uint dwRemotePort; + public uint dwState; + public uint dwOwningPid; + } + + [DllImport("iphlpapi.dll", SetLastError = true)] + private static extern uint GetExtendedTcpTable(IntPtr pTcpTable, ref uint dwOutBufLen, bool sort, int ipVersion, TcpTableClass tblClass, uint reserved); + + public IReadOnlyList GetListeners(int port) + { + var result = new List(); + result.AddRange(GetListenersForFamily(port, AF_INET)); + result.AddRange(GetListenersForFamily(port, AF_INET6)); + return result; + } + + private List GetListenersForFamily(int port, int afFamily) + { + var result = new List(); + uint bufLen = 0; + GetExtendedTcpTable(IntPtr.Zero, ref bufLen, false, afFamily, TcpTableClass.OwnerPidListener, 0); + if (bufLen == 0) return result; + + const uint ERROR_INSUFFICIENT_BUFFER = 122; + IntPtr buffer = IntPtr.Zero; + try + { + uint ret = ERROR_INSUFFICIENT_BUFFER; + for (int attempt = 0; attempt < 4; attempt++) + { + if (buffer != IntPtr.Zero) Marshal.FreeHGlobal(buffer); + if (bufLen > (uint)int.MaxValue) return result; + buffer = Marshal.AllocHGlobal((int)bufLen); + ret = GetExtendedTcpTable(buffer, ref bufLen, false, afFamily, TcpTableClass.OwnerPidListener, 0); + if (ret == 0) break; + if (ret != ERROR_INSUFFICIENT_BUFFER) return result; + bufLen = bufLen == 0 ? 8192 : bufLen * 2; + } + if (ret != 0) return result; + + var count = Marshal.ReadInt32(buffer); + const int rowOffset = 4; + + if (afFamily == AF_INET) + { + var rowSize = Marshal.SizeOf(); + for (int i = 0; i < count; i++) + { + var row = Marshal.PtrToStructure(buffer + rowOffset + i * rowSize); + if (GetPortFromNetworkDword(row.dwLocalPort) != port) continue; + + var addr = new System.Net.IPAddress(row.dwLocalAddr).ToString(); + var processName = GetProcessName((int)row.dwOwningPid); + result.Add(new PortListenerInfo(port, (int)row.dwOwningPid, processName, addr, "ipv4")); + } + } + else + { + var rowSize = Marshal.SizeOf(); + for (int i = 0; i < count; i++) + { + var row = Marshal.PtrToStructure(buffer + rowOffset + i * rowSize); + if (GetPortFromNetworkDword(row.dwLocalPort) != port) continue; + + var addr = new System.Net.IPAddress(row.ucLocalAddr).ToString(); + var processName = GetProcessName((int)row.dwOwningPid); + result.Add(new PortListenerInfo(port, (int)row.dwOwningPid, processName, addr, "ipv6")); + } + } + } + finally + { + if (buffer != IntPtr.Zero) Marshal.FreeHGlobal(buffer); + } + return result; + } + + internal static int GetPortFromNetworkDword(uint networkDword) + => PortInspectorHelpers.GetPortFromNetworkDword(networkDword); + + private static string GetProcessName(int pid) + { + try + { +using var p = Process.GetProcessById(pid); + return p.ProcessName; + } + catch { return string.Empty; } + } +}