Author SHA1 Message Date
christianmanivong d55b036a8e Merge pull request 'feat: read what a Linux kernel has built and loaded, once for every driver' (#4) from feat/kernel-facts into main 2026-10-05 04:36:42 +00:00
christianmanivong 536ffcf6e1 feat: read what a Linux kernel has built and loaded, once for every driver
A kernel CVE's exploitability often hangs on code that is not there: a module
neither loaded nor shipped, an option the kernel was built without. netOrk's
KB precondition vocabulary asks exactly that (kernel_module, kernel_config).

Reading it is the same on every Linux host, so the command and its parse live
here and a driver supplies only the transport:

- KERNEL_FACTS_COMMAND: one read-only POSIX sh line, no privileges. Release,
  /proc/modules, modules.builtin, modules.dep and the build configuration
  (/boot/config-* or /proc/config.gz). The report is framed, gzipped and
  base64-encoded, so nothing in it can look like a shell prompt to a
  screen-scraping transport, and ~300 kB of configuration crosses as a fifth.
- parse_kernel_facts(): a section the command could not print comes back None,
  never empty -- "could not read" and "read, and nothing there" must stay apart.
- module_name(): no path, no .ko suffix, "-" folded to "_", as the kernel does.
- KernelFactsMixin, in the template form: get_kernel_facts() is concrete,
  _run_kernel_facts_command() is the driver's hook. Mixed in by the drivers
  that can, not by OSDriver -- a Windows host is an OS driver too, and
  hasattr(driver, "get_kernel_facts") has to stay truthful.
- KernelFactsDict in models.py. Version 2.1.0.
2026-10-05 06:17:30 +02:00
christianmanivong 97e7ede131 Merge pull request 'feat: a new VM's CPU model can be chosen, from a list the hypervisor offers' (#3) from feat/vm-cpu-type into main 2026-10-04 15:42:15 +00:00
christianmanivong b5c40019af feat: a new VM's CPU model can be chosen, from a list the hypervisor offers
create_vm_from_cloud_init takes cpu_type. Proxmox gives a VM created without
one the kvm64 model, which has no AVX, so MongoDB 5.0 and later do not start
there, and netOrk's graylog role failed on every VM it provisioned
(netork#494). Which model is right depends on the cluster: host cannot
live-migrate between different CPUs, x86-64-v3 does not start on a CPU older
than Haswell. So the caller chooses.

get_vm_cpu_types() is the new, optional listing behind that choice. Each
VMCpuTypeDict names the model, says what it is for, lists the /proc/cpuinfo
flags the guest gets (so a caller can ask "does this give AVX?" without
knowing model names), whether the node at hand can run it, and which one is
the default. A hypervisor whose VMs have no per-VM CPU model (VMware sets CPU
compatibility per cluster) does not implement it and must reject any
cpu_type other than None.

Both declarations sit under TYPE_CHECKING like the rest of the contract, so
hasattr stays a truthful capability probe; the tests read the signature from
the source.
2026-10-04 12:02:38 +02:00
christianmanivong 36b7852bce Merge pull request 'feat: port forwards are a firewall reader too, and only the WAN's' (#2) from feature/port-forwards-shared into main 2026-10-03 14:31:01 +00:00
christianmanivong 31949eca0a feat: port forwards are a firewall reader too, and only the WAN's
get_port_forwards was declared on ResidentialGatewayDriver alone, as if a
port forward were a home-router feature. A firewall forwards ports just the
same (OPNsense calls it destination NAT), and netOrk asks both: is this host
reachable from the internet, which CVEs are exposed. The declaration moves to
NatVpnMixin, where the two roles already overlap, and PortForwardDict next to
NATTranslationDict.

The contract now says what counts. Destination NAT between internal networks
and rules that only exempt traffic are not port forwards: callers read every
entry as "reachable from outside". "ANY" forwards every protocol and an
external port of 0 every port -- a whole host forwarded is the most exposed
case and must not fall out for lack of a port number.

Declaration only, under TYPE_CHECKING: nothing changes at runtime.
2026-10-03 16:30:32 +02:00
christianmanivong 7b491164a2 Merge pull request 'feat!: a VM's vmid is a string, and its config can describe its hardware' (#1) from feature/vmid-as-string into main 2026-10-01 18:59:36 +00:00
christianmanivong f3fa75bbca feat: add_lag_interfaces, one logical row per trunk group
Some switches list only their member ports, each tagged with the trunk
it belongs to, and never the trunk itself. procurve over CLI is one:
`show interfaces brief` has `3-Trk3` and `4-Trk3` but no `Trk3`. Its
REST path already built the trunk row itself, in code no other driver
could reach.

Grouping members by `trunk_group` into one entry per group is the same
for every vendor, so it lives here once. The entry is up/enabled if any
member is, its speed is the members' sum, and `lag_members` is in port
order. A LAG the driver already reported is left alone.

`lag_mode` is set only when the driver passes it. netOrk shows a missing
mode as "static trunk", but a guessed "trunk" would label an LACP group
wrongly, and a label that looks sure when nothing is known is worse.

A free function, not a SwitchDriver method: role bases are declarations
only (test_role_contracts), like normalize_cidr beside DhcpServerMixin.
2026-09-25 10:17:18 +02:00
13 changed files with 700 additions and 47 deletions
+17
View File
@@ -73,6 +73,12 @@ is a thin bundle over them — `PackageManagementMixin`, `HealthMetricsMixin`,
`HostRebootMixin` (`reboot_host`) is mixed into `DeviceTypeDriver` itself, since any
device may be restartable; like the others it only declares.
`KernelFactsMixin` (`get_kernel_facts`) is the exception that is mixed in by a driver
rather than by a role base: what a Linux kernel has built and loaded is read the same way
everywhere, so the command and its parse are concrete here and a driver supplies only
`_run_kernel_facts_command`. `OSDriver` does not carry it — a Windows host is an OS driver
too, and `hasattr(driver, "get_kernel_facts")` has to stay truthful.
A function class may use the **template form** — public method concrete, the
device-specific part a `_hook` declared under `if TYPE_CHECKING` — *when the base
genuinely does work* on the result: normalising, sorting, validating, or orchestrating
@@ -227,6 +233,11 @@ class PfSenseDriver(FirewallDriver):
def get_vpn_tunnels(self):
# return Dict[str, VPNTunnelDict]
...
def get_port_forwards(self):
# return List[PortForwardDict] — forwards from the WAN only, never a
# redirect between internal networks (shared with home gateways)
...
```
### Hypervisor
@@ -242,6 +253,12 @@ class ProxmoxDriver(HypervisorDriver):
def create_vm_snapshot(self, name, snapshot, description="", include_memory=False):
...
def get_vm_cpu_types(self):
# optional — return List[VMCpuTypeDict]: the CPU models a new VM may get
# on this node, each with its cpuinfo flags and whether the node can run
# it; the name goes to create_vm_from_cloud_init(cpu_type=...)
...
```
### OS / Linux
+7
View File
@@ -40,6 +40,7 @@ instead of being restated on every role that happens to need it:
* :class:`~napalm_device_types.health_metrics.HealthMetricsMixin`
* :class:`~napalm_device_types.host_reboot.HostRebootMixin`
* :class:`~napalm_device_types.interface_filter.InterfaceFilterMixin`
* :class:`~napalm_device_types.kernel.KernelFactsMixin`
* :class:`~napalm_device_types.mac_acl.MacAclMixin`
* :class:`~napalm_device_types.nat_vpn.NatVpnMixin`
* :class:`~napalm_device_types.packages.PackageManagementMixin`
@@ -63,6 +64,8 @@ from napalm_device_types.firewall_rules import FirewallRuleMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.host_reboot import HostRebootMixin
from napalm_device_types.interface_filter import InterfaceFilterMixin
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND, KernelFactsMixin, parse_kernel_facts
from napalm_device_types.lag import add_lag_interfaces
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.media import MediaDriver
from napalm_device_types.nat_vpn import NatVpnMixin
@@ -93,6 +96,9 @@ __all__ = [
"NatVpnMixin",
"OSDriver",
"PackageManagementMixin",
"KernelFactsMixin",
"KERNEL_FACTS_COMMAND",
"parse_kernel_facts",
"PhoneDriver",
"PingSweepMixin",
"PortSpec",
@@ -101,6 +107,7 @@ __all__ = [
"StorageDriver",
"SwitchDriver",
"UpdateMixin",
"add_lag_interfaces",
"driver_supports_ping",
"normalize_cidr",
"normalize_mac",
+29
View File
@@ -21,6 +21,7 @@ from napalm_device_types.models import (
StorageTargetDict,
StorageVolumeDict,
VMConfigDict,
VMCpuTypeDict,
VMDict,
VMProvisionResultDict,
VMStatusDict,
@@ -462,6 +463,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
ssh_public_keys: List[str] | None = None,
disk_resize_gb: int | None = None,
storage: str | None = None,
cpu_type: str | None = None,
download_timeout: int = 300,
timeout: int = 180,
) -> VMProvisionResultDict:
@@ -503,6 +505,13 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
disk on (a name returned by ``get_image_storages()``). If None,
the driver auto-detects the first enabled, node-available storage
whose content includes "images".
cpu_type (string | None) - virtual CPU model for the VM (a name
returned by ``get_vm_cpu_types()``). If None, the driver uses the
entry that listing marks ``default``. A driver without
``get_vm_cpu_types()`` offers no choice and must reject any
value other than None with ValueError; one that has it raises
ValueError for a name it does not list or that is not
``available`` on this node, before creating anything.
download_timeout (int) - maximum seconds to wait for the image download
(skipped entirely if already cached on the hypervisor). Default 300.
timeout (int) - maximum seconds to wait for the remaining provisioning
@@ -618,3 +627,23 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
as ``create_vm_from_cloud_init``'s ``storage`` argument.
"""
...
def get_vm_cpu_types(self) -> List[VMCpuTypeDict]:
"""
List the virtual CPU models a new VM may be given on the node it will
be created on.
Optional: a hypervisor whose VMs have no per-VM CPU model (VMware
sets CPU compatibility per cluster) does not implement it, and a
caller then offers no choice.
Every model the driver knows is listed, also those this node's CPU
cannot run -- marked ``available: False`` -- so a picker can show why
an option is missing. Exactly one entry is ``default``: the model
``create_vm_from_cloud_init`` uses when ``cpu_type`` is None.
Returns:
List[VMCpuTypeDict] - each entry's ``name`` is directly usable as
``create_vm_from_cloud_init``'s ``cpu_type`` argument.
"""
...
+168
View File
@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
"""What the running kernel has built and loaded.
A kernel CVE's exploitability often hangs on code that is simply not there --
a module that is neither loaded nor shipped, a subsystem the kernel was built
without. Reading that is identical on every Linux host, so the command and its
parse live here once and a driver only carries the command across: SSH,
an API's exec endpoint, whatever it has.
The command is read-only and needs no privileges. It frames its report and
sends it gzipped and base64-encoded, for two reasons: nothing in the payload can
then look like a shell prompt to a screen-scraping transport, and a kernel's
build configuration (~300 kB on a distribution kernel) crosses as a fifth of
that.
What the four lists mean for a module, and why "not loaded" alone is never
"absent": a module that is not loaded can still be loaded on demand -- by an
attacker too, where autoloading reaches it. Only a module that is neither
loaded, nor compiled in, nor shipped for this kernel is one it cannot have.
"""
from __future__ import annotations
import base64
import binascii
import gzip
import zlib
from typing import Dict, List, Optional, TYPE_CHECKING
from napalm_device_types.models import KernelFactsDict
_BEGIN = "KFACTS_BEGIN"
_END = "KFACTS_END"
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two
#: halves so that a transport which echoes the command does not show them early.
KERNEL_FACTS_COMMAND = (
"r=$(uname -r); m=/lib/modules/$r; "
"printf '%s%s\\n' KFACTS_ BEGIN; "
"{ echo '[release]'; echo \"$r\"; "
"if [ -r /proc/modules ]; then echo '[loaded]'; cut -d' ' -f1 /proc/modules; fi; "
"if [ -r $m/modules.builtin ]; then echo '[builtin]'; cat $m/modules.builtin; fi; "
"if [ -r $m/modules.dep ]; then echo '[available]'; cut -d: -f1 $m/modules.dep; fi; "
"if [ -r /boot/config-$r ]; then echo '[config]'; grep '^CONFIG_' /boot/config-$r; "
"elif [ -r /proc/config.gz ]; then echo '[config]'; zcat /proc/config.gz | grep '^CONFIG_'; fi; "
"} 2>/dev/null | gzip -c | base64; "
"printf '%s%s\\n' KFACTS_ END"
)
def module_name(raw: str) -> str:
"""A module as the kernel names it: no path, no ``.ko`` suffix, ``_`` for ``-``.
``kernel/net/can/can-raw.ko.zst`` and ``can_raw`` are the same module; the
kernel itself treats dash and underscore alike.
"""
base = raw.strip().rsplit("/", 1)[-1]
suffix = base.find(".ko")
if suffix != -1:
base = base[:suffix]
return base.replace("-", "_").lower()
def _report(output: str) -> str:
lines = [line.strip() for line in output.splitlines()]
try:
start = lines.index(_BEGIN)
end = lines.index(_END, start)
except ValueError:
raise ValueError("no kernel facts in the output") from None
try:
packed = base64.b64decode("".join(lines[start + 1 : end]), validate=True)
return gzip.decompress(packed).decode()
except (binascii.Error, OSError, EOFError, zlib.error, UnicodeDecodeError) as exc:
raise ValueError(f"the kernel facts could not be decoded: {exc}") from exc
def _sections(report: str) -> Dict[str, List[str]]:
sections: Dict[str, List[str]] = {}
current: Optional[List[str]] = None
for line in report.splitlines():
line = line.strip()
if line.startswith("[") and line.endswith("]"):
current = sections.setdefault(line[1:-1], [])
elif line and current is not None:
current.append(line)
return sections
def _config(lines: List[str]) -> Dict[str, str]:
config: Dict[str, str] = {}
for line in lines:
option, sep, value = line.partition("=")
if not sep:
continue
if len(value) >= 2 and value[0] == value[-1] == '"':
value = value[1:-1]
config[option] = value
return config
def parse_kernel_facts(output: str) -> KernelFactsDict:
"""Parse what :data:`KERNEL_FACTS_COMMAND` printed.
A section the command did not print -- the file was missing or unreadable --
comes back ``None``, never empty.
:raises ValueError: when the output carries no intact report.
"""
sections = _sections(_report(output))
def names(key: str) -> Optional[List[str]]:
if key not in sections:
return None
return sorted({module_name(line) for line in sections[key]})
release = sections.get("release") or [""]
return {
"release": release[0],
"loaded": names("loaded"),
"builtin": names("builtin"),
"available": names("available"),
"config": _config(sections["config"]) if "config" in sections else None,
}
class KernelFactsMixin:
"""Adds :meth:`get_kernel_facts` to a driver that can run a command on a Linux host.
The template form (README, "Function classes"): the reading and its parse are
the same everywhere, so they are concrete here, and a driver supplies only
:meth:`_run_kernel_facts_command` -- how a command reaches its host. Mixed in
by the drivers that can, not by :class:`~napalm_device_types.os.OSDriver`:
a Windows host is an OS driver too and has no Linux kernel to read, and
``hasattr(driver, "get_kernel_facts")`` has to stay a truthful answer.
"""
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def _run_kernel_facts_command(self, command: str) -> str:
"""Run *command* on the host with ``sh`` and return what it printed."""
...
def get_kernel_facts(self) -> KernelFactsDict:
"""
Returns what the running kernel has built and loaded.
* release (string) - ``uname -r``
* loaded (list or None) - loaded modules, from ``/proc/modules``
* builtin (list or None) - modules compiled into the kernel image
* available (list or None) - modules shipped for this kernel
* config (dict or None) - the build configuration's set options
``None`` means the source could not be read.
Example::
{
"release": "6.1.0-25-amd64",
"loaded": ["nf_tables", "tipc"],
"builtin": ["tcp_cubic"],
"available": ["can_raw", "nf_tables", "tipc"],
"config": {"CONFIG_TIPC": "m", "CONFIG_HZ": "250"},
}
:raises ValueError: if the host's output carried no intact report.
"""
return parse_kernel_facts(self._run_kernel_facts_command(KERNEL_FACTS_COMMAND))
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""Logical LAG entries for ``get_interfaces()``, built from their member ports.
Some switches list only physical ports, each tagged with the trunk it belongs
to, and never the trunk itself. Turning those tags into one row per trunk is
the same for every vendor, so it lives here once; a driver only has to set
``trunk_group`` on member ports and, where the device says so, pass the mode.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
def _port_order(name: str) -> List[Any]:
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", name)]
def add_lag_interfaces(
interfaces: Dict[str, Dict[str, Any]],
lag_modes: Optional[Dict[str, str]] = None,
) -> Dict[str, Dict[str, Any]]:
"""Return *interfaces* plus one logical entry per ``trunk_group``.
The LAG entry is up/enabled if any member is, its speed is the members'
sum, and ``lag_members`` lists them in port order. A LAG the driver
already reported is left as it is. *interfaces* itself is not modified.
:param lag_modes: ``{lag_name: "lacp" | "trunk"}``. A LAG without a known
mode gets no ``lag_mode`` key rather than a guessed one.
"""
result = dict(interfaces)
groups: Dict[str, List[str]] = {}
for name, iface in interfaces.items():
group = iface.get("trunk_group")
if group:
groups.setdefault(group, []).append(name)
for group, members in groups.items():
if group in result:
continue
members = sorted(members, key=_port_order)
lag: Dict[str, Any] = {
"is_up": any(interfaces[m].get("is_up") for m in members),
"is_enabled": any(interfaces[m].get("is_enabled") for m in members),
"description": f"LAG ({', '.join(members)})",
"last_flapped": -1.0,
"speed": sum(float(interfaces[m].get("speed") or 0) for m in members),
"mtu": -1,
"mac_address": "",
"lag_members": members,
}
if lag_modes and group in lag_modes:
lag["lag_mode"] = lag_modes[group]
result[group] = lag
return result
+51 -10
View File
@@ -298,6 +298,39 @@ class NATTranslationDict(TypedDict):
age: float
class KernelFactsDict(TypedDict):
"""What the running kernel has built and loaded (``KernelFactsMixin.get_kernel_facts``).
``None`` means *could not be read*; an empty list means *read, and there is
nothing*. The difference is what lets a consumer say "this module cannot be
loaded on this kernel" rather than "we did not look".
Module names are normalised by :func:`napalm_device_types.kernel.module_name`:
no path, no ``.ko`` suffix, ``-`` folded to ``_``.
"""
release: str # uname -r
loaded: Optional[List[str]] # /proc/modules
builtin: Optional[List[str]] # modules.builtin -- compiled into the kernel image
available: Optional[List[str]] # modules.dep -- shipped as loadable modules
config: Optional[Dict[str, str]] # build configuration, set options only; quotes stripped
class PortForwardDict(TypedDict):
"""A port the WAN side can reach, forwarded to a host inside.
Shared by firewalls and home gateways (``NatVpnMixin.get_port_forwards``).
"""
name: str
protocol: str # "TCP" or "UDP"
external_port: int
internal_ip: str
internal_port: int
enabled: bool
remote_host: NotRequired[str] # restrict forward to a specific remote source
class SecurityZoneDict(TypedDict):
interfaces: List[str]
policy: str
@@ -470,16 +503,6 @@ class WANStatusDict(TypedDict):
link_status: NotRequired[str] # physical line state, e.g. "Up" / "Down"
class PortForwardDict(TypedDict):
name: str
protocol: str # "TCP" or "UDP"
external_port: int
internal_ip: str
internal_port: int
enabled: bool
remote_host: NotRequired[str] # restrict forward to a specific remote source
class HostDict(TypedDict):
mac: str
ip: str
@@ -907,6 +930,24 @@ class StorageTargetDict(TypedDict):
available_gb: float # Free capacity in gigabytes
class VMCpuTypeDict(TypedDict):
"""A virtual CPU model a new VM may be given (``create_vm_from_cloud_init``'s
``cpu_type`` argument), judged against the specific node the VM will be
created on.
``features`` uses the flag names of Linux's ``/proc/cpuinfo`` (``avx``,
``avx2``, ``aes`` ...), so a caller can ask "does this model give the guest
AVX?" without knowing the hypervisor's model names. A model that passes the
host CPU through lists that CPU's own flags.
"""
name: str # Model name, usable directly as create_vm_from_cloud_init(cpu_type=...)
description: str # One line on what the model is for, for a picker
features: List[str] # cpuinfo flags the guest is guaranteed to see
available: bool # False when this node's CPU cannot run the model
default: bool # The model create_vm_from_cloud_init uses when cpu_type is None
# ---------------------------------------------------------------------------
# Ping sweep (shared across device types)
# ---------------------------------------------------------------------------
+43 -3
View File
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""Address translation and VPN tunnels.
A home gateway does a subset of what a firewall does, and these two readers
are where the sets overlap exactly.
A home gateway does a subset of what a firewall does, and these readers are
where the sets overlap exactly.
Declared under ``if TYPE_CHECKING``: these are contracts, not placeholders.
Nothing exists at runtime until a concrete driver implements it, so mixing
@@ -13,7 +13,7 @@ from __future__ import annotations
from typing import Dict, List, TYPE_CHECKING
from napalm_device_types.models import NATTranslationDict, VPNTunnelDict
from napalm_device_types.models import NATTranslationDict, PortForwardDict, VPNTunnelDict
class NatVpnMixin:
@@ -47,6 +47,46 @@ class NatVpnMixin:
"""
...
def get_port_forwards(self) -> List[PortForwardDict]:
"""
Returns the port forwards that let traffic in from the WAN.
A port forward here means destination NAT on an interface facing
the internet: whoever reaches the external port is let through to
``internal_ip``. A redirect between internal networks is
destination NAT as well, but it is **not** a port forward and must
be left out -- callers read every entry as "this host is reachable
from outside". So are rules that only exempt traffic from
redirection.
Each entry contains:
* name (string) - the rule's description/name
* protocol (string) - ``"TCP"`` or ``"UDP"``; a rule for both is
two entries. ``"ANY"`` forwards every protocol
* external_port (int) - the WAN-side port; the first of a range,
``0`` for every port (a whole host forwarded)
* internal_ip (string) - the host the traffic is forwarded to
* internal_port (int) - the port on that host
* enabled (bool) - whether the rule is currently active
* remote_host (string, optional) - restricts the forward to a specific
remote source address; empty/absent means "any"
Example::
[
{
"name": "Webserver HTTPS",
"protocol": "TCP",
"external_port": 443,
"internal_ip": "192.168.1.10",
"internal_port": 443,
"enabled": True,
}
]
"""
...
def get_vpn_tunnels(self) -> Dict[str, VPNTunnelDict]:
"""
Returns the status of VPN tunnels.
+3 -33
View File
@@ -6,8 +6,9 @@ wireless access point in a single consumer device (e.g. AVM FritzBox,
ISP-supplied DSL/cable routers). This base class merges the relevant
subsets of :class:`~napalm_device_types.firewall.FirewallDriver` and
:class:`~napalm_device_types.access_point.AccessPointDriver` plus
gateway-specific operations (WAN status, port forwarding, connected
hosts).
gateway-specific operations (WAN status, connected hosts). Port
forwarding is shared with firewalls, in
:class:`~napalm_device_types.nat_vpn.NatVpnMixin`.
Usage::
@@ -25,7 +26,6 @@ from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.dhcp import DhcpServerMixin
from napalm_device_types.models import (
HostDict,
PortForwardDict,
RadioStatusDict,
SSIDDict,
WANStatusDict,
@@ -85,36 +85,6 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_port_forwards(self) -> List[PortForwardDict]:
"""
Returns the configured port forwarding (port mapping) rules.
Each entry contains:
* name (string) - the rule's description/name
* protocol (string) - ``"TCP"`` or ``"UDP"``
* external_port (int) - the WAN-side port
* internal_ip (string) - the LAN host the traffic is forwarded to
* internal_port (int) - the LAN-side port
* enabled (bool) - whether the rule is currently active
* remote_host (string, optional) - restricts the forward to a specific
remote source address; empty/absent means "any"
Example::
[
{
"name": "Webserver HTTPS",
"protocol": "TCP",
"external_port": 443,
"internal_ip": "192.168.1.10",
"internal_port": 443,
"enabled": True,
}
]
"""
...
def get_hosts(self) -> List[HostDict]:
"""
Returns the list of hosts known to the gateway (LAN clients).
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "napalm-device-types"
version = "2.0.0"
version = "2.1.0"
description = "Abstract device-type base classes for NAPALM drivers"
readme = "README.md"
requires-python = ">=3.10"
+151
View File
@@ -0,0 +1,151 @@
"""get_kernel_facts: what the running kernel has built and loaded.
A kernel CVE's preconditions ask whether a module is loaded or a build option
set. Reading that is the same on every Linux host -- one read-only command and
its parse -- so both live here once, and a driver only carries the command
across (#268 in netOrk).
"""
from __future__ import annotations
import base64
import gzip
import os
import subprocess
import pytest
from napalm_device_types import OSDriver
from napalm_device_types.kernel import (
KERNEL_FACTS_COMMAND,
KernelFactsMixin,
module_name,
parse_kernel_facts,
)
REPORT = """[release]
6.1.0-25-amd64
[loaded]
tipc
nf_tables
[builtin]
kernel/net/ipv4/tcp_cubic.ko
kernel/drivers/char/tpm/tpm-tis.ko
[available]
kernel/net/tipc/tipc.ko.xz
kernel/net/can/can-raw.ko.zst
kernel/net/netfilter/nf_tables.ko
[config]
CONFIG_TIPC=m
CONFIG_BPF_JIT=y
CONFIG_DEFAULT_HOSTNAME="(none)"
CONFIG_HZ=250
"""
def _wire(report: str, *, noise: str = "") -> str:
"""The report as the command prints it: framed, gzipped, base64 in lines."""
payload = base64.encodebytes(gzip.compress(report.encode())).decode()
return f"{noise}KFACTS_BEGIN\n{payload}KFACTS_END\n"
class TestParsing:
def test_every_section_is_read(self):
facts = parse_kernel_facts(_wire(REPORT))
assert facts["release"] == "6.1.0-25-amd64"
assert facts["loaded"] == ["nf_tables", "tipc"]
assert facts["builtin"] == ["tcp_cubic", "tpm_tis"]
assert facts["available"] == ["can_raw", "nf_tables", "tipc"]
assert facts["config"] == {
"CONFIG_TIPC": "m",
"CONFIG_BPF_JIT": "y",
"CONFIG_DEFAULT_HOSTNAME": "(none)",
"CONFIG_HZ": "250",
}
def test_a_section_never_printed_is_none_not_empty(self):
"""``None`` is "could not read"; an empty list would claim "read it,
and there is nothing" -- and that is what turns a module into
``not_met`` downstream."""
facts = parse_kernel_facts(_wire("[release]\n6.1.0\n[loaded]\n"))
assert facts["loaded"] == []
assert facts["builtin"] is None
assert facts["available"] is None
assert facts["config"] is None
def test_whatever_surrounds_the_frame_is_ignored(self):
"""A screen-scraping transport may echo the command or a banner."""
noise = "Last login: today\nprintf '%s%s\\n' KFACTS_ BEGIN; ...\n"
assert parse_kernel_facts(_wire(REPORT, noise=noise))["release"] == "6.1.0-25-amd64"
def test_output_without_the_frame_raises(self):
with pytest.raises(ValueError):
parse_kernel_facts("sh: gzip: not found\n")
def test_a_damaged_payload_raises(self):
with pytest.raises(ValueError):
parse_kernel_facts("KFACTS_BEGIN\nnot base64 at all!\nKFACTS_END\n")
class TestModuleNames:
@pytest.mark.parametrize(
"raw, name",
[
("tipc", "tipc"),
("kernel/net/tipc/tipc.ko", "tipc"),
("kernel/net/tipc/tipc.ko.zst", "tipc"),
("kernel/net/can/can-raw.ko.xz", "can_raw"),
("CAN-RAW", "can_raw"),
(" nf_tables ", "nf_tables"),
],
)
def test_dash_and_underscore_are_one_name(self, raw, name):
"""The kernel treats ``-`` and ``_`` in module names as the same."""
assert module_name(raw) == name
class TestTheCommand:
def test_the_frame_is_not_in_the_command_itself(self):
"""An echoing transport prints the command back; the markers must only
appear once the command has run."""
assert "KFACTS_BEGIN" not in KERNEL_FACTS_COMMAND
assert "KFACTS_END" not in KERNEL_FACTS_COMMAND
def test_it_writes_nothing(self):
for verb in ("modprobe", "insmod", "rmmod", "sudo", " > ", ">>"):
assert verb not in KERNEL_FACTS_COMMAND
@pytest.mark.skipif(os.uname().sysname != "Linux", reason="reads a Linux kernel")
def test_it_runs_and_parses_on_this_host(self):
out = subprocess.run(
["sh", "-c", KERNEL_FACTS_COMMAND], capture_output=True, text=True, timeout=60
).stdout
facts = parse_kernel_facts(out)
assert facts["release"] == os.uname().release
assert facts["loaded"] is None or all(isinstance(m, str) for m in facts["loaded"])
class TestTheTemplate:
def test_a_driver_supplies_only_the_transport(self):
class Driver(KernelFactsMixin):
def _run_kernel_facts_command(self, command: str) -> str:
self.sent = command
return _wire(REPORT)
driver = Driver()
facts = driver.get_kernel_facts()
assert driver.sent == KERNEL_FACTS_COMMAND
assert facts["release"] == "6.1.0-25-amd64"
def test_not_every_os_driver_has_it(self):
"""A Windows host is an OSDriver too, and has no Linux kernel to read:
``hasattr`` has to stay a truthful answer, so the drivers that can mix
this in themselves."""
assert not issubclass(OSDriver, KernelFactsMixin)
assert not hasattr(OSDriver, "get_kernel_facts")
+76
View File
@@ -0,0 +1,76 @@
"""Tests for add_lag_interfaces — one logical row per trunk group."""
from napalm_device_types import add_lag_interfaces
def _port(is_up: bool = True, is_enabled: bool = True, speed: float = 1000.0, trunk_group: str = "") -> dict:
port = {
"is_up": is_up,
"is_enabled": is_enabled,
"description": "",
"last_flapped": -1.0,
"speed": speed,
"mtu": -1,
"mac_address": "",
}
if trunk_group:
port["trunk_group"] = trunk_group
return port
def test_adds_one_row_per_trunk_group():
ifaces = {
"1": _port(),
"3": _port(trunk_group="Trk3"),
"4": _port(trunk_group="Trk3"),
"10": _port(trunk_group="Trk6"),
"7": _port(trunk_group="Trk6"),
}
result = add_lag_interfaces(ifaces)
assert result["Trk3"]["lag_members"] == ["3", "4"]
# Members in natural port order, not string order ("7" before "10").
assert result["Trk6"]["lag_members"] == ["7", "10"]
assert result["Trk6"]["description"] == "LAG (7, 10)"
assert "Trk1" not in result
def test_state_is_derived_from_members():
ifaces = {
"3": _port(is_up=False, speed=1000.0, trunk_group="Trk3"),
"4": _port(is_up=True, speed=1000.0, trunk_group="Trk3"),
"6": _port(is_up=False, is_enabled=False, trunk_group="Trk6"),
}
result = add_lag_interfaces(ifaces)
assert result["Trk3"]["is_up"] is True
assert result["Trk3"]["is_enabled"] is True
assert result["Trk3"]["speed"] == 2000.0
assert result["Trk6"]["is_up"] is False
assert result["Trk6"]["is_enabled"] is False
def test_lag_mode_only_when_known():
"""The UI reads a missing mode as "static trunk"; guessing would mislabel LACP."""
ifaces = {"3": _port(trunk_group="Trk3"), "6": _port(trunk_group="Trk6")}
result = add_lag_interfaces(ifaces, lag_modes={"Trk3": "lacp"})
assert result["Trk3"]["lag_mode"] == "lacp"
assert "lag_mode" not in result["Trk6"]
def test_keeps_a_lag_the_driver_already_reported():
ifaces = {
"3": _port(trunk_group="Trk3"),
"Trk3": {**_port(), "description": "uplink", "lag_members": ["3"]},
}
result = add_lag_interfaces(ifaces)
assert result["Trk3"]["description"] == "uplink"
def test_does_not_modify_its_input():
ifaces = {"3": _port(trunk_group="Trk3")}
add_lag_interfaces(ifaces)
assert list(ifaces) == ["3"]
+39
View File
@@ -0,0 +1,39 @@
"""get_port_forwards: what the WAN side may reach inside, on any gateway.
The reader used to be declared on ``ResidentialGatewayDriver`` only, as if a
port forward were a home-router feature. A firewall forwards ports just the
same -- OPNsense calls it destination NAT -- and the two consumers that ask
(is this host reachable from the internet, which CVEs are exposed) need the
answer from both. The declaration therefore lives where the two roles overlap,
next to the NAT translations reader.
"""
from __future__ import annotations
import inspect
from napalm_device_types import FirewallDriver, ResidentialGatewayDriver
from napalm_device_types.nat_vpn import NatVpnMixin
def test_a_firewall_and_a_gateway_share_the_declaration():
assert issubclass(FirewallDriver, NatVpnMixin)
assert issubclass(ResidentialGatewayDriver, NatVpnMixin)
assert "def get_port_forwards(self) -> List[PortForwardDict]" in inspect.getsource(NatVpnMixin)
def test_it_is_declared_once():
assert "def get_port_forwards" not in inspect.getsource(ResidentialGatewayDriver)
def test_absent_until_a_driver_implements_it():
assert not hasattr(FirewallDriver, "get_port_forwards")
assert not hasattr(ResidentialGatewayDriver, "get_port_forwards")
def test_the_contract_says_what_counts():
"""A redirect between two internal networks is destination NAT too, and
would make an internal host look reachable from the internet."""
source = inspect.getsource(NatVpnMixin)
assert "from the WAN" in source
assert "between internal networks" in source
+58
View File
@@ -0,0 +1,58 @@
"""A new VM's virtual CPU model can be chosen, from a list the hypervisor offers.
Proxmox gives a VM created without a ``cpu`` argument the ``kvm64`` model,
which has no AVX -- and MongoDB 5.0 and later will not start without it. Which
model is right depends on the cluster (``host`` cannot live-migrate between
different CPUs, ``x86-64-v3`` does not start on a CPU older than Haswell), so
the caller chooses, from entries that say what each model provides and whether
the node at hand can run it.
The declarations sit under ``TYPE_CHECKING`` (see test_role_contracts), so the
signature is read from the source rather than from the class.
"""
from __future__ import annotations
import ast
import inspect
from typing import List, get_type_hints
import napalm_device_types.hypervisor as hypervisor_module
from napalm_device_types import HypervisorDriver
from napalm_device_types.models import VMCpuTypeDict
def _declared(name: str) -> ast.FunctionDef:
tree = ast.parse(inspect.getsource(hypervisor_module))
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == name:
return node
raise AssertionError(f"HypervisorDriver does not declare {name}()")
class TestCreateVmTakesACpuType:
def test_cpu_type_is_an_optional_keyword(self):
fn = _declared("create_vm_from_cloud_init")
kwonly = {arg.arg: default for arg, default in zip(fn.args.kwonlyargs, fn.args.kw_defaults)}
assert "cpu_type" in kwonly
default = kwonly["cpu_type"]
assert isinstance(default, ast.Constant) and default.value is None
class TestCpuTypeListing:
def test_is_declared(self):
assert _declared("get_vm_cpu_types").returns is not None
def test_absent_until_a_driver_implements_it(self):
"""netOrk probes capabilities with hasattr; a hypervisor without a
choice of CPU model must not seem to offer one."""
assert not hasattr(HypervisorDriver, "get_vm_cpu_types")
def test_entry_shape(self):
assert get_type_hints(VMCpuTypeDict) == {
"name": str,
"description": str,
"features": List[str],
"available": bool,
"default": bool,
}