feat: report what listens, and which Windows service it is

One script: Get-NetTCPConnection -State Listen, Get-NetUDPEndpoint, the
processes holding them and Win32_Service's PIDs. A process running one
service names it; a shared svchost runs several, and which of them holds
the socket Windows does not say, so it names none. http.sys sockets
(WinRM, IIS, SMB) belong to the System process.

The fixture is synthetic: no Windows host was at hand. tools/harvest.py
records the real output. For netOrk#673.
This commit is contained in:
Christian Manivong committed 2026-10-07 07:20:27 +02:00
1 parent 1ce42ef099
commit a6814ad92e
7 files changed
+185 -2

No files matched your search

+76 -1
View File
@@ -24,7 +24,7 @@ from typing import Any
from napalm.base.exceptions import ConnectionClosedException
from napalm_device_types import FingerprintRule, OSDriver, PortSpec
from napalm_device_types.models import ServiceDict
from napalm_device_types.models import ListeningSocketDict, ListeningSocketsDict, ServiceDict
from napalm_windows.transport import PowerShellError, PsrpTransport
@@ -134,6 +134,42 @@ ConvertTo-Json -Compress -Depth 3 -InputObject @(
)
"""
# Every listening TCP and bound UDP socket, the processes holding them and the
# services those processes run. Sockets owned by http.sys (WinRM, IIS) belong to
# the System process; a shared svchost runs several services in one process.
_PS_LISTENING = r"""
$ErrorActionPreference = 'Stop'
$tcp = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue)
$udp = @(Get-NetUDPEndpoint -ErrorAction SilentlyContinue)
$ids = @(@($tcp) + @($udp) | ForEach-Object { [int]$_.OwningProcess } | Sort-Object -Unique)
ConvertTo-Json -Compress -Depth 3 -InputObject ([pscustomobject]@{
tcp = @($tcp | ForEach-Object {
[pscustomobject]@{
address = [string]$_.LocalAddress
port = [int]$_.LocalPort
pid = [int]$_.OwningProcess
}
})
udp = @($udp | ForEach-Object {
[pscustomobject]@{
address = [string]$_.LocalAddress
port = [int]$_.LocalPort
pid = [int]$_.OwningProcess
}
})
processes = @(if ($ids.Count) {
Get-Process -Id $ids -ErrorAction SilentlyContinue | ForEach-Object {
[pscustomobject]@{ pid = [int]$_.Id; name = $_.ProcessName }
}
})
services = @(
Get-CimInstance -ClassName Win32_Service -Filter 'ProcessId <> 0' | ForEach-Object {
[pscustomobject]@{ pid = [int]$_.ProcessId; name = $_.Name }
}
)
})
"""
_SERVICE_COMMANDS = {
"start": "Start-Service -Name {name}",
"stop": "Stop-Service -Name {name}",
@@ -198,6 +234,27 @@ def _is_real_neighbour_mac(mac: str) -> bool:
return not int(mac.split(":", 1)[0], 16) & 1
def _listening_socket(
proto: str, entry: dict[str, Any], names: dict[int, str], owners: dict[int, list[str]]
) -> ListeningSocketDict:
"""One socket of ``_PS_LISTENING``. A process running one service names it; a
shared svchost runs several, and which of them holds the socket Windows does
not say. PID 0 is no process."""
pid = int(entry.get("pid") or 0)
address, _, zone = str(entry.get("address") or "").partition("%")
services = owners.get(pid, [])
return {
"proto": proto,
"address": address,
"port": int(entry.get("port") or 0),
"interface": zone or None,
"process": names.get(pid) if pid else None,
"pid": pid or None,
"unit": services[0] if len(services) == 1 else None,
"container_id": None,
}
def _is_noise_route(destination: str) -> bool:
"""Multicast, limited broadcast and loopback — present on every host."""
try:
@@ -444,6 +501,24 @@ class WindowsDriver(OSDriver):
)
return services
def get_listening_sockets(self) -> ListeningSocketsDict:
"""Every listening TCP and bound UDP socket, with the process and the
Windows service behind it (``Get-NetTCPConnection``, ``Get-NetUDPEndpoint``,
``Win32_Service``). WinRM needs an administrator, who sees every
process, so the reading is ``attributed``."""
raw = self._run_ps(_PS_LISTENING) or {}
names = {int(p["pid"]): str(p["name"]) for p in _as_list(raw.get("processes"))}
owners: dict[int, list[str]] = {}
for service in _as_list(raw.get("services")):
owners.setdefault(int(service["pid"]), []).append(str(service["name"]))
sockets = [
_listening_socket(proto, entry, names, owners)
for proto in ("tcp", "udp")
for entry in _as_list(raw.get(proto))
]
sockets.sort(key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or ""))
return {"attributed": True, "sockets": sockets}
def manage_service(self, name: str, action: str) -> dict[str, Any]:
template = _SERVICE_COMMANDS.get(action)
if template is None: