feat: NAPALM driver for Windows over PowerShell Remoting
Talks PSRP over WinRM via pypsrp, one runspace pool per connection. Each getter sends one PowerShell script that projects cmdlet results onto flat fields and ends in ConvertTo-Json, so the Python side parses JSON, not text. Covers facts, interfaces, IP addresses, ARP, routes and services, plus service start/stop/restart/enable/disable. Service names are validated and quoted as PowerShell verbatim strings, typographic quotes included. Fixtures are synthetic: they pin down the JSON the scripts are designed to emit. tools/harvest.py records the real output from a host. Refs christianmanivong/netork#300
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""NAPALM driver for Windows hosts via PowerShell Remoting (WinRM)."""
|
||||
|
||||
from napalm_windows.windows import WindowsDriver
|
||||
|
||||
__all__ = ["WindowsDriver"]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""PowerShell Remoting (PSRP) over WinRM — the driver's only path to the host.
|
||||
|
||||
One runspace pool stays open for the lifetime of the connection, so a poll
|
||||
that sends a dozen scripts pays the WinRM handshake once. Everything the
|
||||
driver knows about pypsrp lives in this module; the driver itself sees a
|
||||
``run(script) -> str`` seam, which is what its tests replace and what an SSH
|
||||
transport (Windows OpenSSH) would implement later.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
|
||||
from pypsrp.exceptions import AuthenticationError, WinRMError, WinRMTransportError
|
||||
from pypsrp.powershell import PowerShell, RunspacePool
|
||||
from pypsrp.wsman import WSMan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PowerShellError(Exception):
|
||||
"""A script ran but wrote to PowerShell's error stream."""
|
||||
|
||||
|
||||
class PsrpTransport:
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
*,
|
||||
port: int,
|
||||
ssl: bool,
|
||||
cert_validation: bool,
|
||||
auth: str,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.username = username
|
||||
self._password = password
|
||||
self.port = port
|
||||
self.ssl = ssl
|
||||
self.cert_validation = cert_validation
|
||||
self.auth = auth
|
||||
self.timeout = timeout
|
||||
self._wsman: WSMan | None = None
|
||||
self._pool: RunspacePool | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
scheme = "https" if self.ssl else "http"
|
||||
return f"<PsrpTransport {self.username}@{scheme}://{self.host}:{self.port}>"
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._pool is not None
|
||||
|
||||
def open(self) -> None:
|
||||
# encryption="auto" gives message-level encryption on plain HTTP when
|
||||
# the auth protocol supports it (NTLM/Kerberos), so 5985 does not mean
|
||||
# credentials or output travel in the clear.
|
||||
self._wsman = WSMan(
|
||||
self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
password=self._password,
|
||||
ssl=self.ssl,
|
||||
auth=self.auth,
|
||||
cert_validation=self.cert_validation,
|
||||
connection_timeout=self.timeout,
|
||||
read_timeout=self.timeout,
|
||||
operation_timeout=max(self.timeout - 10, 20),
|
||||
encryption="auto",
|
||||
)
|
||||
pool = RunspacePool(self._wsman)
|
||||
try:
|
||||
pool.open()
|
||||
except AuthenticationError as exc:
|
||||
self._drop()
|
||||
raise ConnectionException(f"Authentication failed for {self.username}: {exc}") from exc
|
||||
except WinRMTransportError as exc:
|
||||
self._drop()
|
||||
if exc.code == 401:
|
||||
raise ConnectionException(
|
||||
f"Authentication failed for {self.username}: HTTP 401"
|
||||
) from exc
|
||||
raise ConnectionException(f"WinRM error from {self.host}:{self.port}: {exc}") from exc
|
||||
except (requests.RequestException, WinRMError, OSError) as exc:
|
||||
self._drop()
|
||||
raise ConnectionException(
|
||||
f"Cannot reach WinRM on {self.host}:{self.port}: {exc}"
|
||||
) from exc
|
||||
self._pool = pool
|
||||
|
||||
def run(self, script: str) -> str:
|
||||
if self._pool is None:
|
||||
raise ConnectionClosedException("WinRM connection is not open")
|
||||
ps = PowerShell(self._pool)
|
||||
ps.add_script(script)
|
||||
output = ps.invoke()
|
||||
if ps.had_errors:
|
||||
message = "; ".join(str(e) for e in ps.streams.error).strip()
|
||||
raise PowerShellError(message or "PowerShell reported an error")
|
||||
return "\n".join(str(o) for o in output if o is not None)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._pool is not None:
|
||||
try:
|
||||
self._pool.close()
|
||||
except Exception: # a dead connection is closed enough
|
||||
logger.debug("Closing runspace pool on %s failed", self.host, exc_info=True)
|
||||
self._drop()
|
||||
|
||||
def _drop(self) -> None:
|
||||
if self._wsman is not None:
|
||||
try:
|
||||
self._wsman.close()
|
||||
except Exception:
|
||||
logger.debug("Closing WSMan session on %s failed", self.host, exc_info=True)
|
||||
self._wsman = None
|
||||
self._pool = None
|
||||
@@ -0,0 +1,460 @@
|
||||
# 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}
|
||||
Reference in New Issue
Block a user