# Licensed under the Apache License, Version 2.0 """NAPALM driver for Windows hosts. Talks PowerShell Remoting over WinRM (see :mod:`napalm_windows.transport`). Each getter sends one script that projects its cmdlet results onto flat, primitive fields and ends in ``ConvertTo-Json``; the Python side only normalises formats. Two Windows PowerShell 5.1 quirks shape every script: * ``ConvertTo-Json`` writes enums as integers and ``DateTime`` as ``"\\/Date(...)\\/"`` — so enums are cast with ``[string]`` and times are converted to numbers before they leave the host. * A one-element array arrives as a bare object — so list results go through ``-InputObject @(...)``, and :func:`_as_list` guards the rest. """ from __future__ import annotations import ipaddress import json import logging import re 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_windows.transport import PowerShellError, PsrpTransport logger = logging.getLogger(__name__) WINRM_HTTP_PORT = 5985 WINRM_HTTPS_PORT = 5986 # --------------------------------------------------------------------------- # PowerShell scripts — one per getter # --------------------------------------------------------------------------- _PS_FACTS = r""" $ErrorActionPreference = 'Stop' $cs = Get-CimInstance -ClassName Win32_ComputerSystem $os = Get-CimInstance -ClassName Win32_OperatingSystem $bios = Get-CimInstance -ClassName Win32_BIOS $cv = Get-ItemProperty -ErrorAction SilentlyContinue ` -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' [pscustomobject]@{ hostname = $env:COMPUTERNAME dns_hostname = $cs.DNSHostName domain = $cs.Domain part_of_domain = [bool]$cs.PartOfDomain manufacturer = $cs.Manufacturer model = $cs.Model serial = $bios.SerialNumber caption = $os.Caption version = $os.Version display_version = $cv.DisplayVersion ubr = $cv.UBR uptime = [int64]((Get-Date) - $os.LastBootUpTime).TotalSeconds interfaces = @(Get-NetAdapter | ForEach-Object { $_.Name }) } | ConvertTo-Json -Compress -Depth 3 """ _PS_INTERFACES = r""" $ErrorActionPreference = 'Stop' ConvertTo-Json -Compress -Depth 3 -InputObject @( Get-NetAdapter | ForEach-Object { [pscustomobject]@{ name = $_.Name description = $_.InterfaceDescription status = [string]$_.Status mtu = $_.MtuSize speed = $_.Speed mac = $_.MacAddress } } ) """ _PS_INTERFACES_IP = r""" $ErrorActionPreference = 'Stop' ConvertTo-Json -Compress -Depth 3 -InputObject @( Get-NetIPAddress | ForEach-Object { [pscustomobject]@{ interface = $_.InterfaceAlias address = $_.IPAddress prefix = [int]$_.PrefixLength family = [string]$_.AddressFamily } } ) """ _PS_ARP = r""" $ErrorActionPreference = 'Stop' ConvertTo-Json -Compress -Depth 3 -InputObject @( Get-NetNeighbor -AddressFamily IPv4 | ForEach-Object { [pscustomobject]@{ interface = $_.InterfaceAlias ip = $_.IPAddress mac = $_.LinkLayerAddress state = [string]$_.State } } ) """ _PS_ROUTES = r""" $ErrorActionPreference = 'Stop' ConvertTo-Json -Compress -Depth 3 -InputObject @( Get-NetRoute | ForEach-Object { [pscustomobject]@{ destination = $_.DestinationPrefix next_hop = $_.NextHop interface = $_.InterfaceAlias metric = [int]$_.RouteMetric protocol = [string]$_.Protocol } } ) """ _PS_SERVICES = r""" $ErrorActionPreference = 'Stop' ConvertTo-Json -Compress -Depth 3 -InputObject @( Get-CimInstance -ClassName Win32_Service | ForEach-Object { [pscustomobject]@{ name = $_.Name state = [string]$_.State start_mode = [string]$_.StartMode pid = [int]$_.ProcessId } } ) """ _SERVICE_COMMANDS = { "start": "Start-Service -Name {name}", "stop": "Stop-Service -Name {name}", "restart": "Restart-Service -Name {name}", "enable": "Set-Service -Name {name} -StartupType Automatic", "disable": "Set-Service -Name {name} -StartupType Disabled", } # Service key names, not display names: letters, digits and the few symbols # real services use (MSSQL$SQLEXPRESS, CDPUserSvc_4a5b6, {GUID}-style names). _SERVICE_NAME_RE = re.compile(r"^[\w.$@{}\-]+$", re.ASCII) # PowerShell accepts these as single-quote delimiters alongside the ASCII one. _PS_SINGLE_QUOTES = ("'", "‘", "’", "‚", "‛") _ROUTE_PROTOCOLS = { "local": "connected", "routeradvertisement": "connected", "netmgmt": "static", "dhcp": "dhcp", "ospf": "ospf", "bgp": "bgp", "rip": "rip", } _ENABLED_START_MODES = {"auto", "boot", "system"} _DEAD_NEIGHBOUR_STATES = {"unreachable", "incomplete"} # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _as_list(value: Any) -> list[Any]: """Undo ConvertTo-Json unwrapping a one-element array into a bare value.""" if value is None: return [] if isinstance(value, list): return value return [value] def _ps_quote(value: str) -> str: """Quote ``value`` as a PowerShell single-quoted (verbatim) string.""" for q in _PS_SINGLE_QUOTES: value = value.replace(q, q + q) return f"'{value}'" def _mac(value: str | None) -> str: """``00-15-5d-01-02-03`` → ``00:15:5D:01:02:03``.""" if not value: return "" return value.replace("-", ":").upper() def _is_real_neighbour_mac(mac: str) -> bool: if not mac or mac == "00:00:00:00:00:00": return False # Broadcast and multicast share the group bit in the first octet. return not int(mac.split(":", 1)[0], 16) & 1 def _is_noise_route(destination: str) -> bool: """Multicast, limited broadcast and loopback — present on every host.""" try: net = ipaddress.ip_network(destination, strict=False) except ValueError: return False if net.is_multicast or net.is_loopback: return True return str(net) == "255.255.255.255/32" # --------------------------------------------------------------------------- # Driver # --------------------------------------------------------------------------- class WindowsDriver(OSDriver): """NAPALM driver for Windows Server 2016+ and Windows 10/11. ``optional_args``: * ``port`` — WinRM port, default 5986 (HTTPS). * ``winrm_ssl`` — force HTTPS on/off; default is HTTPS unless port is 5985. * ``winrm_auth`` — pypsrp auth protocol, default ``negotiate``. * ``ssl_verify`` — validate the WinRM certificate, default ``True``. """ TYPE_LABEL = "Windows" VENDOR = "Microsoft" DRIVER_NAME = "windows" USES_SSH = False # Updates install during shutdown and boot; a Windows reboot routinely # takes several minutes rather than a Linux host's ninety seconds. REBOOT_SETTLE_SECONDS = 180 #: Read by netOrk when the device has no port of its own. default_port = WINRM_HTTPS_PORT PORT_SPECS = [ PortSpec("http", WINRM_HTTP_PORT, ("/wsman",), weight=6.0), PortSpec("https", WINRM_HTTPS_PORT, ("/wsman",), weight=6.0), ] # WinRM answers through http.sys: "Server: Microsoft-HTTPAPI/2.0". Discovery # only reaches 5985 when port 80 is silent, so an IIS on 80 is the header # it sees instead. HTTP_FINGERPRINT = [ FingerprintRule("microsoft-httpapi", weight=5.0), FingerprintRule("microsoft-iis", weight=3.0), ] # sysDescr: "Hardware: ... Software: Windows Version 6.3 (Build 17763 ...)" SNMP_FINGERPRINT = [ FingerprintRule("software: windows", weight=8.0), ] # Banner: "SSH-2.0-OpenSSH_for_Windows_8.1" SSH_FINGERPRINT = [ FingerprintRule("openssh_for_windows", weight=8.0), ] def __init__( self, hostname: str, username: str, password: str, timeout: int = 60, optional_args: dict | None = None, ) -> None: self.hostname = hostname self.username = username self.password = password self.timeout = timeout optional_args = optional_args or {} self.port: int = int(optional_args.get("port") or WINRM_HTTPS_PORT) self.ssl: bool = bool(optional_args.get("winrm_ssl", self.port != WINRM_HTTP_PORT)) self.cert_validation: bool = bool(optional_args.get("ssl_verify", True)) self.auth: str = optional_args.get("winrm_auth", "negotiate") self._transport: PsrpTransport | None = None # ------------------------------------------------------------------ # Connection management # ------------------------------------------------------------------ def open(self) -> None: transport = PsrpTransport( self.hostname, self.username, self.password, port=self.port, ssl=self.ssl, cert_validation=self.cert_validation, auth=self.auth, timeout=self.timeout, ) transport.open() self._transport = transport def close(self) -> None: if self._transport is not None: self._transport.close() self._transport = None def is_alive(self) -> dict[str, bool]: return {"is_alive": bool(self._transport and self._transport.is_open)} def _run_ps(self, script: str) -> Any: """Run ``script`` and return its JSON output parsed, or ``None`` if empty.""" out = self._run_raw(script).strip() return json.loads(out) if out else None def _run_raw(self, script: str) -> str: if self._transport is None: raise ConnectionClosedException("WinRM connection is not open") return self._transport.run(script) # ------------------------------------------------------------------ # NAPALM getters # ------------------------------------------------------------------ def get_facts(self) -> dict[str, Any]: raw = self._run_ps(_PS_FACTS) or {} hostname = raw.get("dns_hostname") or raw.get("hostname") or "" domain = raw.get("domain") or "" fqdn = f"{hostname}.{domain}" if raw.get("part_of_domain") and domain else hostname version = raw.get("version") or "" ubr = raw.get("ubr") build = version.rsplit(".", 1)[-1] if version else "" if ubr is not None: build = f"{build}.{ubr}" os_version = " ".join( p for p in ((raw.get("caption") or "").strip(), raw.get("display_version")) if p ) if build: os_version = f"{os_version} (build {build})" return { "hostname": hostname, "fqdn": fqdn, "vendor": raw.get("manufacturer") or self.VENDOR, "model": raw.get("model") or "", "serial_number": raw.get("serial") or "", "os_version": os_version, "uptime": int(raw.get("uptime") or 0), "interface_list": [str(i) for i in _as_list(raw.get("interfaces"))], # Full build including the update revision — what CVE matching # needs, since Windows fixes ship as UBR bumps, not version bumps. "running_kernel": f"{version}.{ubr}" if ubr is not None else version, } def get_interfaces(self) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for a in _as_list(self._run_ps(_PS_INTERFACES)): status = (a.get("status") or "").lower() result[a["name"]] = { "is_up": status == "up", "is_enabled": status != "disabled", "description": a.get("description") or "", "last_flapped": -1.0, "speed": float(a.get("speed") or 0) / 1_000_000, "mtu": int(a.get("mtu") or 0), "mac_address": _mac(a.get("mac")), } return result def get_interfaces_ip(self) -> dict[str, dict[str, dict[str, dict[str, int]]]]: result: dict[str, dict[str, dict[str, dict[str, int]]]] = {} for entry in _as_list(self._run_ps(_PS_INTERFACES_IP)): # Link-local IPv6 carries its zone index ("fe80::1%6"). address = (entry.get("address") or "").split("%", 1)[0] try: if ipaddress.ip_address(address).is_loopback: continue except ValueError: continue family = "ipv6" if (entry.get("family") or "").lower() == "ipv6" else "ipv4" iface = result.setdefault(entry["interface"], {}) iface.setdefault(family, {})[address] = {"prefix_length": int(entry["prefix"])} return result def get_arp_table(self, vrf: str = "") -> list[dict[str, Any]]: entries = [] for n in _as_list(self._run_ps(_PS_ARP)): if (n.get("state") or "").lower() in _DEAD_NEIGHBOUR_STATES: continue mac = _mac(n.get("mac")) if not _is_real_neighbour_mac(mac): continue entries.append({"interface": n["interface"], "mac": mac, "ip": n["ip"], "age": 0.0}) return entries def get_route_to( self, destination: str = "", protocol: str = "", longer: bool = False, ) -> dict[str, list[dict[str, Any]]]: routes: dict[str, list[dict[str, Any]]] = {} for r in _as_list(self._run_ps(_PS_ROUTES)): network = r["destination"] if _is_noise_route(network): continue if destination and network != destination: continue raw_proto = (r.get("protocol") or "").lower() proto = _ROUTE_PROTOCOLS.get(raw_proto, raw_proto) if protocol and proto != protocol.lower(): continue next_hop = r.get("next_hop") or "" if next_hop in ("0.0.0.0", "::"): next_hop = "" routes.setdefault(network, []).append( { "protocol": proto, "family": "ipv6" if ":" in network else "ipv4", "current_active": True, "last_active": False, "age": -1, "next_hop": next_hop, "outgoing_interface": r.get("interface") or "", "selected_next_hop": True, "preference": int(r.get("metric") or 0), "routing_table": "global", "protocol_attributes": {}, } ) return routes # ------------------------------------------------------------------ # Services # ------------------------------------------------------------------ def get_services(self) -> list[ServiceDict]: services: list[ServiceDict] = [] for s in _as_list(self._run_ps(_PS_SERVICES)): running = (s.get("state") or "").lower() == "running" services.append( { "name": s["name"], "running": running, "enabled": (s.get("start_mode") or "").lower() in _ENABLED_START_MODES, "pid": int(s.get("pid") or 0) if running else 0, } ) return services def manage_service(self, name: str, action: str) -> dict[str, Any]: template = _SERVICE_COMMANDS.get(action) if template is None: raise ValueError( f"Unsupported action {action!r}; use one of {sorted(_SERVICE_COMMANDS)}" ) if not _SERVICE_NAME_RE.match(name or ""): raise ValueError(f"Invalid service name {name!r}") command = template.format(name=_ps_quote(name)) try: output = self._run_raw(f"{command} -ErrorAction Stop | Out-Null") except PowerShellError as exc: return {"success": False, "output": str(exc)} return {"success": True, "output": output}