Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23c352aa8a | ||
|
|
a710a85de6 | ||
|
|
c947250179 | ||
|
|
ff6dbe0206 | ||
|
|
7aa731ad34 | ||
|
|
72679ff4f8 | ||
|
|
7b44f689bc | ||
|
|
ba2f18ea7d | ||
|
|
37c09d0fa0 | ||
|
|
8111c4cde3 | ||
|
|
e1ce72cc5c | ||
|
|
7ba84acad3 | ||
|
|
8eb7dcd871 | ||
|
|
95d9031c8d |
No files matched your search
@@ -81,8 +81,11 @@ too, and `hasattr(driver, "get_kernel_facts")` has to stay truthful.
|
||||
|
||||
`HostStatusMixin` (`get_host_status`) is mixed in the same way: whether a Linux host needs
|
||||
a reboot to finish an update (`/var/run/reboot-required`, `needs-restarting -r`, or a newer
|
||||
kernel of the running flavour installed) and whether it patches itself (unattended-upgrades,
|
||||
dnf-automatic). `package_updates` holds the shared apt and dnf parsers: apt's suites become
|
||||
kernel of the running flavour installed), whether it patches itself (unattended-upgrades,
|
||||
dnf-automatic) and when that last succeeded (apt's `upgrade-stamp`), and whether dpkg was
|
||||
left halfway (a journal in `/var/lib/dpkg/updates` or a half-configured package; unknown
|
||||
while a package manager is running), and how full `/`, `/boot` and `/var` are (`df -Pk`,
|
||||
each filesystem once, in bytes; how full is too full is the caller's decision). `package_updates` holds the shared apt and dnf parsers: apt's suites become
|
||||
an update's `origin`, a `-security` suite makes it a security update, and dnf's security
|
||||
advisories do the same.
|
||||
|
||||
@@ -96,6 +99,29 @@ without `ss` is read with `netstat -lntup` (OpenWrt's busybox, old net-tools); o
|
||||
cgroup names the procd service (`/services/<name>/<instance>`). A host with neither raises
|
||||
`ListeningSocketsUnavailable`.
|
||||
|
||||
`VirtualIpsMixin` (`get_virtual_ips`) reads the addresses a host's HA configuration lets
|
||||
float between machines: vip-manager (5.x's `--config` YAML, 1.x's `-ip=${VIP_IP}` resolved
|
||||
through the unit's environment) and keepalived (`virtual_ipaddress` of every
|
||||
`vrrp_instance`, `include` followed four levels deep). It reports what is *declared*, not
|
||||
what the host holds right now -- that is in its interface addresses. The same files hold
|
||||
the etcd password and `auth_pass`, so an awk program filters **on the host** and prints
|
||||
allowlisted fields only; a secret never reaches the caller. Root first, then without it.
|
||||
A mechanism it could not read completely is `None` ("did not look"), a host without it
|
||||
`[]`. The command is about 4 kB, so a driver sends it on an exec channel
|
||||
(`_run_virtual_ips_command(command, privileged=)`), not typed into a shell.
|
||||
|
||||
`OpenSshServerMixin` (`harden_ssh_server`, `get_ssh_server_algorithms`) takes algorithms
|
||||
out of what an OpenSSH server offers -- the ones ssh-audit flags. It reads the lists sshd
|
||||
really uses (`sshd -T`), takes out what was asked and writes the rest to
|
||||
`/etc/ssh/sshd_config.d/00-netork-ssh-hardening.conf`; only where sshd_config has no such
|
||||
`Include` is sshd_config itself rewritten, after a copy. It never removes the last algorithm
|
||||
of a class, nor -- given the managing client's lists -- the last one that client speaks.
|
||||
`sshd -t` must accept the result and `sshd -T` must show it; after the reload the caller's
|
||||
`verify` opens a new session, and if that fails the still-open one puts the old state back.
|
||||
A driver mixes it in and supplies `run_command`; a platform that regenerates sshd_config
|
||||
itself (an appliance with its own database) must not, or opts out with
|
||||
`harden_ssh_server = None`.
|
||||
|
||||
**Update readers raise when they cannot read.** `get_available_updates` returns an empty
|
||||
list only when nothing is pending; netOrk keeps "pending since" per package, and an empty
|
||||
list for "don't know" would reset it.
|
||||
|
||||
@@ -52,6 +52,7 @@ instead of being restated on every role that happens to need it:
|
||||
* :class:`~napalm_device_types.services.ServiceControlMixin`
|
||||
* :class:`~napalm_device_types.systemd.SystemdServicesMixin`
|
||||
* :class:`~napalm_device_types.updates.UpdateMixin`
|
||||
* :class:`~napalm_device_types.virtual_ips.VirtualIpsMixin`
|
||||
|
||||
Introspection -- :func:`~napalm_device_types.roles.roles_of`,
|
||||
:func:`~napalm_device_types.roles.role_keys_of` and
|
||||
@@ -113,7 +114,19 @@ from napalm_device_types.systemd import (
|
||||
parse_systemd_services,
|
||||
)
|
||||
from napalm_device_types.updates import UpdateMixin
|
||||
from napalm_device_types.virtual_ips import (
|
||||
VIRTUAL_IPS_COMMAND,
|
||||
VirtualIpsMixin,
|
||||
parse_virtual_ips,
|
||||
)
|
||||
from napalm_device_types.residential_gateway import ResidentialGatewayDriver
|
||||
from napalm_device_types.ssh_server import (
|
||||
SSH_ALGORITHM_CLASSES,
|
||||
OpenSshServerMixin,
|
||||
SshHardeningPlan,
|
||||
SshHardeningResult,
|
||||
plan_ssh_hardening,
|
||||
)
|
||||
from napalm_device_types.storage import StorageDriver
|
||||
from napalm_device_types.switch import SwitchDriver
|
||||
|
||||
@@ -170,6 +183,14 @@ __all__ = [
|
||||
"SystemdUnavailable",
|
||||
"parse_systemd_services",
|
||||
"UpdateMixin",
|
||||
"VIRTUAL_IPS_COMMAND",
|
||||
"VirtualIpsMixin",
|
||||
"OpenSshServerMixin",
|
||||
"SSH_ALGORITHM_CLASSES",
|
||||
"SshHardeningPlan",
|
||||
"SshHardeningResult",
|
||||
"plan_ssh_hardening",
|
||||
"parse_virtual_ips",
|
||||
"add_lag_interfaces",
|
||||
"driver_supports_ping",
|
||||
"normalize_cidr",
|
||||
|
||||
@@ -16,13 +16,40 @@ carries the command across.
|
||||
- A kernel newer than the running one is installed, of the same flavour. A
|
||||
Raspberry Pi carries ``rpi-v8`` and ``rpi-2712`` builds side by side, and only
|
||||
the running one's counts.
|
||||
- On FreeBSD, ``freebsd-version -k`` (the installed kernel) differs from
|
||||
``freebsd-version -r`` (the running one): a patch replaced the kernel.
|
||||
|
||||
It is ``None`` when none of these could be read, for example in a container
|
||||
without a ``/lib/modules`` of its own.
|
||||
|
||||
**Auto updates** is apt's ``APT::Periodic::Unattended-Upgrade`` (set, not "0",
|
||||
and ``apt-daily-upgrade.timer`` not disabled) or an enabled dnf-automatic timer.
|
||||
It is ``None`` on a host with neither apt nor dnf-automatic.
|
||||
It is ``None`` on a host with neither apt nor dnf-automatic. When they last ran
|
||||
successfully is ``/var/lib/apt/periodic/upgrade-stamp``, which
|
||||
``apt.systemd.daily`` touches only after unattended-upgrade succeeded.
|
||||
``update-success-stamp`` would say nothing: every ``apt-get update`` touches it,
|
||||
whoever runs it. How often they are meant to run is the same setting, in apt's
|
||||
units (a bare number is days).
|
||||
|
||||
**An interrupted package manager** is dpkg left halfway, the way apt checks it:
|
||||
numbered files left in ``/var/lib/dpkg/updates``, or a package in a state only
|
||||
a run cut off leaves behind (half-installed, unpacked, half-configured, waiting
|
||||
for triggers, or flagged for reinstallation). apt then refuses to install
|
||||
anything until ``dpkg --configure -a`` has run, while ``apt.systemd.daily``
|
||||
exits quietly every day (NetOrk/netork#738). While dpkg, apt, aptitude or
|
||||
unattended-upgrade is running, packages are halfway on purpose -- also between
|
||||
two of apt's dpkg calls -- so the answer is then ``None``, as on a host without
|
||||
dpkg. unattended-upgrade is told from Ubuntu's idle shutdown helper by its
|
||||
command line (:data:`BUSY_CHECK`).
|
||||
|
||||
**Filesystems** are how full ``/``, ``/boot`` and ``/var`` are, from POSIX
|
||||
``df -Pk``: where an upgrade unpacks a kernel, builds its initrd and keeps the
|
||||
package files. A kernel upgrade that ran out of room there left dpkg halfway on
|
||||
a Proxmox host whose root had been 94 % full for a week (NetOrk/netork#862). A
|
||||
directory that is not a mount of its own is reported as the filesystem it lives
|
||||
on, so each filesystem appears once. ``available`` is what df says an ordinary
|
||||
user can still write, which on ext4 is less than total minus used. How full is
|
||||
too full is not decided here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,7 +57,11 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import HostStatusDict
|
||||
from napalm_device_types.models import (
|
||||
FilesystemUsageDict,
|
||||
HostStatusDict,
|
||||
InterruptedPackageDict,
|
||||
)
|
||||
from napalm_device_types.terminal import strip_terminal_codes
|
||||
|
||||
_BEGIN = "HSTAT_BEGIN"
|
||||
@@ -38,6 +69,20 @@ _END = "HSTAT_END"
|
||||
_REBOOT_FILE = "/var/run/reboot-required"
|
||||
_APT_TIMER = "apt-daily-upgrade.timer"
|
||||
_DNF_TIMERS = ("dnf-automatic.timer", "dnf-automatic-install.timer")
|
||||
_UPGRADE_STAMP = "/var/lib/apt/periodic/upgrade-stamp"
|
||||
_DPKG_JOURNAL = "/var/lib/dpkg/updates"
|
||||
#: Where an upgrade writes: the kernel's modules, its initrd, the package files.
|
||||
_FILESYSTEMS = "/ /boot /var"
|
||||
#: Prints ``[dpkg-busy]`` while a process that leaves packages halfway is working:
|
||||
#: dpkg, apt, apt-get or aptitude by name, unattended-upgrade by its command line.
|
||||
#: Its name is cut to 15 characters, ``unattended-upgr``, the same as that of
|
||||
#: ``unattended-upgrade-shutdown --wait-for-signal``, which Ubuntu keeps running all
|
||||
#: the time and which installs nothing.
|
||||
BUSY_CHECK = (
|
||||
"{ ps -e -o comm= 2>/dev/null | grep -qxE 'dpkg|apt|apt-get|aptitude' || "
|
||||
"ps -e -o args= 2>/dev/null | grep -qE '(^|[ /])unattended-upgrade( |$)'; } "
|
||||
"&& echo '[dpkg-busy]'; "
|
||||
)
|
||||
|
||||
#: One line, POSIX ``sh``, read-only, no privileges. The frame markers are
|
||||
#: printed in two halves so that an echoing transport does not show them early.
|
||||
@@ -50,8 +95,18 @@ HOST_STATUS_COMMAND = (
|
||||
"if command -v needs-restarting >/dev/null 2>&1; then echo '[needs-restarting]'; "
|
||||
"needs-restarting -r >/dev/null 2>&1; echo $?; fi; "
|
||||
"echo '[kernel]'; uname -r; echo '[modules]'; ls -1 /lib/modules 2>/dev/null; "
|
||||
"if command -v freebsd-version >/dev/null 2>&1; then "
|
||||
"echo '[freebsd-kernel]'; freebsd-version -k; "
|
||||
"echo '[freebsd-running]'; freebsd-version -r; fi; "
|
||||
"if command -v apt-config >/dev/null 2>&1; then echo '[apt-config]'; "
|
||||
"apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade '; fi; "
|
||||
"apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade '; "
|
||||
f"echo '[upgrade-stamp]'; stat -c %Y {_UPGRADE_STAMP} 2>/dev/null; fi; "
|
||||
"if command -v dpkg-query >/dev/null 2>&1; then "
|
||||
f"{BUSY_CHECK}"
|
||||
f"echo '[dpkg-journal]'; ls -1 {_DPKG_JOURNAL} 2>/dev/null | head -n 20; "
|
||||
"echo '[dpkg-audit]'; dpkg-query -W -f='${db:Status-Abbrev} ${Package}\\n' 2>/dev/null "
|
||||
"| awk 'substr($0, 2, 1) ~ /[HUFWt]/ || substr($0, 3, 1) == \"R\"' | head -n 50; fi; "
|
||||
f"echo '[filesystems]'; df -Pk {_FILESYSTEMS}; "
|
||||
f"echo '[timers]'; for u in {_APT_TIMER} {' '.join(_DNF_TIMERS)}; do "
|
||||
'printf \'%s %s\\n\' "$u" "$(systemctl is-enabled "$u" 2>/dev/null)"; done; '
|
||||
"printf '%s%s\\n' HSTAT_ END; } 2>/dev/null | cat"
|
||||
@@ -59,6 +114,20 @@ HOST_STATUS_COMMAND = (
|
||||
|
||||
_PERIODIC = re.compile(r'^APT::Periodic::Unattended-Upgrade\s+"([^"]*)"')
|
||||
_OFF_STATES = frozenset({"disabled", "masked"})
|
||||
_INTERVAL = re.compile(r"(\d+)([smhd]?)")
|
||||
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "": 86400}
|
||||
_JOURNAL_ENTRY = re.compile(r"\d+")
|
||||
#: One line of ``df -P``: device, 1024-blocks, used, available, capacity, mountpoint.
|
||||
#: The device and the mountpoint may hold blanks; the four numbers between them do not.
|
||||
_DF_LINE = re.compile(r"^.+?\s+(\d+)\s+(\d+)\s+(\d+)\s+(?:\d+%|-)\s+(/.*)$")
|
||||
#: dpkg's second status letter -> its word, for the states a cut-off run leaves.
|
||||
_HALFWAY = {
|
||||
"H": "half-installed",
|
||||
"U": "unpacked",
|
||||
"F": "half-configured",
|
||||
"W": "triggers-awaited",
|
||||
"t": "triggers-pending",
|
||||
}
|
||||
|
||||
|
||||
def _sections(output: str) -> Dict[str, List[str]]:
|
||||
@@ -100,12 +169,26 @@ def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Optional[str]]]:
|
||||
"""FreeBSD's answer from freebsd-version, or None on any other system."""
|
||||
installed = (sections.get("freebsd-kernel") or [""])[0]
|
||||
running = (sections.get("freebsd-running") or [""])[0]
|
||||
if not (installed and running):
|
||||
return None
|
||||
if installed != running:
|
||||
return True, f"kernel {installed} installed, {running} running"
|
||||
return False, None
|
||||
|
||||
|
||||
def _reboot(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], Optional[str]]:
|
||||
if "reboot-required" in sections:
|
||||
return True, f"{_REBOOT_FILE} is present"
|
||||
needs = sections.get("needs-restarting")
|
||||
if needs and needs[0] == "1":
|
||||
return True, "needs-restarting -r reports a reboot"
|
||||
freebsd = _freebsd_reboot(sections)
|
||||
if freebsd is not None:
|
||||
return freebsd
|
||||
running = (sections.get("kernel") or [""])[0]
|
||||
modules = sections.get("modules") or []
|
||||
newer = kernel_reboot_pending(running, modules) if running and modules else None
|
||||
@@ -124,17 +207,70 @@ def _timer_states(sections: Dict[str, List[str]]) -> Dict[str, str]:
|
||||
return states
|
||||
|
||||
|
||||
def _periodic(sections: Dict[str, List[str]]) -> Optional[str]:
|
||||
"""The value of ``APT::Periodic::Unattended-Upgrade``, or None when it is not set."""
|
||||
lines = sections.get("apt-config") or []
|
||||
match = next(filter(None, (_PERIODIC.match(line) for line in lines)), None)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _auto_updates(sections: Dict[str, List[str]]) -> Optional[bool]:
|
||||
timers = _timer_states(sections)
|
||||
if any(timers.get(t) == "enabled" for t in _DNF_TIMERS):
|
||||
return True
|
||||
if "apt-config" not in sections:
|
||||
return None
|
||||
match = next(filter(None, (_PERIODIC.match(line) for line in sections["apt-config"])), None)
|
||||
switched_on = match is not None and match.group(1) not in ("", "0")
|
||||
switched_on = _periodic(sections) not in (None, "", "0")
|
||||
return switched_on and timers.get(_APT_TIMER) not in _OFF_STATES
|
||||
|
||||
|
||||
def _interval(sections: Dict[str, List[str]]) -> Optional[int]:
|
||||
"""apt's interval in seconds, the way ``apt.systemd.daily`` reads it; 0 for "always"."""
|
||||
value = _periodic(sections)
|
||||
if value == "always":
|
||||
return 0
|
||||
match = _INTERVAL.fullmatch(value or "")
|
||||
if not match or int(match.group(1)) == 0:
|
||||
return None
|
||||
return int(match.group(1)) * _UNIT_SECONDS[match.group(2)]
|
||||
|
||||
|
||||
def _last_success(sections: Dict[str, List[str]]) -> Optional[int]:
|
||||
stamp = (sections.get("upgrade-stamp") or [""])[0]
|
||||
return int(stamp) if stamp.isdigit() else None
|
||||
|
||||
|
||||
def _halfway(line: str) -> Optional[InterruptedPackageDict]:
|
||||
status, _, name = line.partition(" ")
|
||||
name = name.strip()
|
||||
state = "reinstall-required" if status[2:3] == "R" else _HALFWAY.get(status[1:2])
|
||||
return {"name": name, "state": state} if name and state else None
|
||||
|
||||
|
||||
def _dpkg(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], List[InterruptedPackageDict]]:
|
||||
if "dpkg-audit" not in sections or "dpkg-busy" in sections:
|
||||
return None, []
|
||||
packages = [p for p in map(_halfway, sections["dpkg-audit"]) if p is not None]
|
||||
journal = any(_JOURNAL_ENTRY.fullmatch(f) for f in sections.get("dpkg-journal") or [])
|
||||
return bool(packages) or journal, packages
|
||||
|
||||
|
||||
def _filesystems(sections: Dict[str, List[str]]) -> Optional[List[FilesystemUsageDict]]:
|
||||
"""Each filesystem df named, once and in its order; None when it named none."""
|
||||
found: Dict[str, FilesystemUsageDict] = {}
|
||||
for line in sections.get("filesystems") or []:
|
||||
match = _DF_LINE.match(line)
|
||||
if not match or int(match.group(1)) == 0:
|
||||
continue
|
||||
total, used, available = (int(match.group(i)) * 1024 for i in (1, 2, 3))
|
||||
mountpoint = match.group(4)
|
||||
found.setdefault(
|
||||
mountpoint,
|
||||
{"mountpoint": mountpoint, "total": total, "used": used, "available": available},
|
||||
)
|
||||
return list(found.values()) or None
|
||||
|
||||
|
||||
def parse_host_status(output: str) -> HostStatusDict:
|
||||
"""Parse what :data:`HOST_STATUS_COMMAND` printed.
|
||||
|
||||
@@ -142,10 +278,16 @@ def parse_host_status(output: str) -> HostStatusDict:
|
||||
"""
|
||||
sections = _sections(output)
|
||||
required, reason = _reboot(sections)
|
||||
interrupted, packages = _dpkg(sections)
|
||||
return {
|
||||
"reboot_required": required,
|
||||
"reboot_reason": reason,
|
||||
"auto_updates": _auto_updates(sections),
|
||||
"auto_updates_last_success": _last_success(sections),
|
||||
"auto_updates_interval": _interval(sections),
|
||||
"package_manager_interrupted": interrupted,
|
||||
"interrupted_packages": packages,
|
||||
"filesystems": _filesystems(sections),
|
||||
}
|
||||
|
||||
|
||||
@@ -166,11 +308,17 @@ class HostStatusMixin:
|
||||
|
||||
def get_host_status(self) -> HostStatusDict:
|
||||
"""
|
||||
Returns whether the host needs a reboot and whether it patches itself.
|
||||
Returns whether the host needs a reboot, whether it patches itself, and
|
||||
whether its package manager was left halfway.
|
||||
|
||||
* reboot_required (bool or None)
|
||||
* reboot_reason (string or None)
|
||||
* auto_updates (bool or None)
|
||||
* auto_updates_last_success (Unix time or None)
|
||||
* auto_updates_interval (seconds or None)
|
||||
* package_manager_interrupted (bool or None)
|
||||
* interrupted_packages (list of {name, state})
|
||||
* filesystems (list of {mountpoint, total, used, available} in bytes, or None)
|
||||
|
||||
:raises ValueError: if the host's output carried no intact report.
|
||||
"""
|
||||
|
||||
@@ -75,6 +75,27 @@ class UpdateDict(TypedDict):
|
||||
security: NotRequired[Optional[bool]]
|
||||
|
||||
|
||||
class InterruptedPackageDict(TypedDict):
|
||||
"""A package an interrupted package manager run left halfway."""
|
||||
|
||||
name: str
|
||||
#: dpkg's word for it: "half-installed", "unpacked", "half-configured",
|
||||
#: "triggers-awaited", "triggers-pending", or "reinstall-required".
|
||||
state: str
|
||||
|
||||
|
||||
class FilesystemUsageDict(TypedDict):
|
||||
"""How full one filesystem is, as ``df -Pk`` says, in bytes."""
|
||||
|
||||
#: Where it is mounted, e.g. "/" or "/boot".
|
||||
mountpoint: str
|
||||
total: int
|
||||
used: int
|
||||
#: What an ordinary user can still write: less than total minus used on a
|
||||
#: filesystem that keeps blocks for root (ext4 keeps 5 %).
|
||||
available: int
|
||||
|
||||
|
||||
class HostStatusDict(TypedDict):
|
||||
"""What a host says about its own patch state (``HostStatusMixin.get_host_status``)."""
|
||||
|
||||
@@ -84,6 +105,19 @@ class HostStatusDict(TypedDict):
|
||||
reboot_reason: Optional[str]
|
||||
#: True when the host installs updates on its own (unattended-upgrades, dnf-automatic).
|
||||
auto_updates: Optional[bool]
|
||||
#: True when a package manager run was cut off and has to be finished
|
||||
#: (``dpkg --configure -a``) before anything else installs; None when it
|
||||
#: cannot tell, or a package manager is running right now.
|
||||
package_manager_interrupted: NotRequired[Optional[bool]]
|
||||
#: The packages that run left halfway; empty when only its journal says so.
|
||||
interrupted_packages: NotRequired[List[InterruptedPackageDict]]
|
||||
#: When the host's own automatic updates last ran successfully (Unix time).
|
||||
auto_updates_last_success: NotRequired[Optional[int]]
|
||||
#: How often they are meant to run, in seconds; 0 for every time the timer fires.
|
||||
auto_updates_interval: NotRequired[Optional[int]]
|
||||
#: How full ``/``, ``/boot`` and ``/var`` are, each filesystem once; None when
|
||||
#: it could not be read.
|
||||
filesystems: NotRequired[Optional[List[FilesystemUsageDict]]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -366,6 +400,35 @@ class ListeningSocketsDict(TypedDict):
|
||||
sockets: List[ListeningSocketDict]
|
||||
|
||||
|
||||
class VirtualIpDict(TypedDict):
|
||||
"""An address the host's HA configuration lets float between machines.
|
||||
|
||||
Declared, not observed: whether the host holds the address right now is in
|
||||
its interface addresses, not here. Only these fields leave the host -- never
|
||||
a password, an etcd endpoint or a notify script.
|
||||
"""
|
||||
|
||||
mechanism: str # "vip-manager" | "keepalived"
|
||||
instance: str # the vrrp_instance name, or the vip-manager unit without ".service"
|
||||
address: str # normalised: "10.7.224.10", "fd00::10"
|
||||
prefix_length: Optional[int] # None where the configuration gives none
|
||||
interface: Optional[str]
|
||||
group_key: Optional[str] # vip-manager's trigger-key, or "vrid:<n>" for keepalived
|
||||
running: Optional[bool] # the unit is active
|
||||
|
||||
|
||||
class VirtualIpsDict(TypedDict):
|
||||
"""What ``VirtualIpsMixin.get_virtual_ips`` read, per mechanism.
|
||||
|
||||
A list is what was looked at -- ``[]`` a host without that mechanism. None is
|
||||
"did not look": a configuration it could not read, an include it could not
|
||||
follow, a host without systemd. A consumer keeps what it knew for a None.
|
||||
"""
|
||||
|
||||
vip_manager: Optional[List[VirtualIpDict]]
|
||||
keepalived: Optional[List[VirtualIpDict]]
|
||||
|
||||
|
||||
class PortForwardDict(TypedDict):
|
||||
"""A port the WAN side can reach, forwarded to a host inside.
|
||||
|
||||
@@ -813,64 +876,6 @@ class ApplyUpdatesResultDict(TypedDict):
|
||||
error: NotRequired[str] # error message if success is False
|
||||
|
||||
|
||||
class DockerContainerDict(TypedDict):
|
||||
"""A Docker container entry as returned by ``docker ps -a``."""
|
||||
|
||||
id: str # short container ID
|
||||
name: str # container name(s)
|
||||
image: str # image reference
|
||||
image_version: str # OCI org.opencontainers.image.version label; empty if absent
|
||||
command: str # entrypoint / command
|
||||
created: str # creation timestamp string
|
||||
status: str # human-readable status (e.g. "Up 3 hours")
|
||||
ports: str # port mapping string
|
||||
state: str # "running", "exited", "paused", etc.
|
||||
|
||||
|
||||
class DockerImageDict(TypedDict):
|
||||
"""A local Docker image entry as returned by ``docker images``."""
|
||||
|
||||
id: str # short image ID
|
||||
repository: str # image repository
|
||||
tag: str # image tag
|
||||
size: str # human-readable size string (e.g. "187MB")
|
||||
created: str # creation timestamp string
|
||||
version: str # OCI org.opencontainers.image.version label; empty if absent
|
||||
|
||||
|
||||
class DockerVolumeDict(TypedDict):
|
||||
"""A Docker volume entry as returned by ``docker volume ls``."""
|
||||
|
||||
name: str # volume name
|
||||
driver: str # volume driver (e.g. "local")
|
||||
mountpoint: str # host filesystem path
|
||||
scope: str # "local" or "global"
|
||||
|
||||
|
||||
class DockerNetworkDict(TypedDict):
|
||||
"""A Docker network entry as returned by ``docker network ls``."""
|
||||
|
||||
id: str # short network ID
|
||||
name: str # network name
|
||||
driver: str # network driver (e.g. "bridge", "host", "overlay")
|
||||
scope: str # network scope
|
||||
ipv6: str # "true" if IPv6 is enabled, "false" otherwise
|
||||
internal: str # "true" if the network is internal, "false" otherwise
|
||||
|
||||
|
||||
class DockerInfoDict(TypedDict):
|
||||
"""Return value of ``get_docker_info()``."""
|
||||
|
||||
available: bool # False if Docker is absent/inaccessible
|
||||
permission_denied: NotRequired[bool] # True when socket access is denied
|
||||
version: NotRequired[str] # Docker Engine version string
|
||||
containers: NotRequired[List[DockerContainerDict]]
|
||||
images: NotRequired[List[DockerImageDict]]
|
||||
volumes: NotRequired[List[DockerVolumeDict]]
|
||||
networks: NotRequired[List[DockerNetworkDict]]
|
||||
outdated_images: NotRequired[List[str]] # image names with a newer remote digest
|
||||
|
||||
|
||||
class ContainerEngineDict(TypedDict):
|
||||
"""One container engine a host offers (``container_engines()``).
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from napalm_device_types.health_metrics import HealthMetricsMixin
|
||||
from napalm_device_types.models import (
|
||||
CronJobDict,
|
||||
DeviceActionResultDict,
|
||||
DockerInfoDict,
|
||||
ProcessDict,
|
||||
SNMPConfigDict,
|
||||
UserDict,
|
||||
@@ -193,97 +192,6 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
|
||||
"""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Docker
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_docker_info(self) -> DockerInfoDict:
|
||||
"""
|
||||
Returns information about the local Docker environment.
|
||||
|
||||
If Docker is not installed or the current user lacks access to the
|
||||
Docker socket, returns ``{"available": False}``. When the user has
|
||||
no socket permission, ``permission_denied`` is additionally set to
|
||||
``True``.
|
||||
|
||||
When Docker is available the dict contains:
|
||||
|
||||
* available (bool) - always ``True``
|
||||
* version (string) - Docker Engine version string
|
||||
* containers (list) - all containers (running and stopped), each with:
|
||||
|
||||
* id (string) - short container ID
|
||||
* name (string) - container name(s)
|
||||
* image (string) - image reference
|
||||
* image_version (string) - OCI ``org.opencontainers.image.version`` label; empty if absent
|
||||
* command (string) - entrypoint / command string
|
||||
* created (string) - creation timestamp string
|
||||
* status (string) - human-readable status (e.g. ``"Up 3 hours"``)
|
||||
* ports (string) - port mapping string
|
||||
* state (string) - ``"running"``, ``"exited"``, ``"paused"``, etc.
|
||||
|
||||
* images (list) - local images, each with:
|
||||
|
||||
* id (string) - short image ID
|
||||
* repository (string) - image repository
|
||||
* tag (string) - image tag
|
||||
* size (string) - human-readable size string (e.g. ``"187MB"``)
|
||||
* created (string) - creation timestamp string
|
||||
* version (string) - OCI ``org.opencontainers.image.version`` label; empty if absent
|
||||
|
||||
* volumes (list) - Docker volumes, each with:
|
||||
|
||||
* name (string) - volume name
|
||||
* driver (string) - volume driver
|
||||
* mountpoint (string) - host filesystem path
|
||||
* scope (string) - ``"local"`` or ``"global"``
|
||||
|
||||
* networks (list) - Docker networks, each with:
|
||||
|
||||
* id (string) - short network ID
|
||||
* name (string) - network name
|
||||
* driver (string) - network driver (e.g. ``"bridge"``, ``"host"``, ``"overlay"``)
|
||||
* scope (string) - network scope
|
||||
* ipv6 (string) - ``"true"`` if IPv6 is enabled
|
||||
* internal (string) - ``"true"`` if the network is internal
|
||||
|
||||
* outdated_images (list of strings) - image names where the local digest
|
||||
differs from the latest remote digest; empty list if all images are
|
||||
current or update checks could not be performed.
|
||||
|
||||
Example::
|
||||
|
||||
# Docker not installed:
|
||||
{"available": False}
|
||||
|
||||
# Docker installed, no socket permission:
|
||||
{"available": False, "permission_denied": True}
|
||||
|
||||
# Docker available:
|
||||
{
|
||||
"available": True,
|
||||
"version": "Docker version 27.3.1, build ce12230",
|
||||
"containers": [
|
||||
{
|
||||
"id": "a1b2c3d4e5f6",
|
||||
"name": "my-app",
|
||||
"image": "nginx:latest",
|
||||
"image_version": "1.27.0",
|
||||
"command": "nginx -g 'daemon off;'",
|
||||
"created": "2026-05-28 10:00:00 +0000 UTC",
|
||||
"status": "Up 3 days",
|
||||
"ports": "0.0.0.0:80->80/tcp",
|
||||
"state": "running",
|
||||
},
|
||||
],
|
||||
"images": [...],
|
||||
"volumes": [],
|
||||
"networks": [...],
|
||||
"outdated_images": ["nginx:latest"],
|
||||
}
|
||||
"""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Generic device actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OpenSSH server hardening: taking algorithms out of what sshd offers.
|
||||
|
||||
A scanner such as ssh-audit names algorithms a server should stop offering --
|
||||
``hmac-sha1``, the NIST curves. Taking them out is the same on every host that
|
||||
runs OpenSSH, so the reading, the plan and the write live here once, and a
|
||||
driver only mixes in :class:`OpenSshServerMixin` and supplies ``run_command``.
|
||||
|
||||
**What the server offers is read, not assumed.** ``sshd -T`` prints the lists
|
||||
sshd actually uses, after its defaults, ``sshd_config``, every ``Include`` and
|
||||
first-match-wins. The new list is that one minus what was asked, so nothing the
|
||||
host already restricted comes back, and nothing it allows is dropped by
|
||||
accident.
|
||||
|
||||
**A drop-in, not an edit.** Where ``sshd_config`` includes
|
||||
``/etc/ssh/sshd_config.d/*.conf`` -- Debian and Ubuntu do, at the top -- the
|
||||
lists go to :data:`DROPIN_PATH`, which sorts first and so wins. sshd_config
|
||||
stays as the distribution shipped it, and removing the file undoes the change.
|
||||
Only without such an ``Include`` is sshd_config itself rewritten, after a copy.
|
||||
|
||||
**Nobody is locked out, by three checks.**
|
||||
|
||||
1. The plan never removes the last algorithm of a class, and -- given the
|
||||
lists of the client that manages the host -- never the last one that client
|
||||
speaks. Each one kept back is reported, with why.
|
||||
2. ``sshd -t`` must accept the result, and ``sshd -T`` must show the new lists.
|
||||
A file read before ours can set the same directive; ours is then valid and
|
||||
ignored, and that is reported rather than called a success.
|
||||
3. After the reload, *verify* -- the caller's -- opens a new session. The
|
||||
running one survives a reload, so if the new one cannot get in, this one
|
||||
puts the old state back and reloads again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from shlex import quote
|
||||
from typing import Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - for type checkers only
|
||||
from napalm_device_types.channel import CommandResult
|
||||
|
||||
#: The algorithm classes: key -> (``sshd -T`` keyword, sshd_config directive).
|
||||
SSH_ALGORITHM_CLASSES: Dict[str, Tuple[str, str]] = {
|
||||
"kex": ("kexalgorithms", "KexAlgorithms"),
|
||||
"hostkey": ("hostkeyalgorithms", "HostKeyAlgorithms"),
|
||||
"cipher": ("ciphers", "Ciphers"),
|
||||
"mac": ("macs", "MACs"),
|
||||
}
|
||||
|
||||
MAIN_CONFIG = "/etc/ssh/sshd_config"
|
||||
DROPIN_DIR = "/etc/ssh/sshd_config.d"
|
||||
#: ``00-`` so it sorts before every other drop-in: sshd keeps the first value it reads.
|
||||
DROPIN_PATH = f"{DROPIN_DIR}/00-netork-ssh-hardening.conf"
|
||||
#: What marks a line this module disabled in sshd_config.
|
||||
DISABLED_PREFIX = "# netork: "
|
||||
|
||||
_HEADER = (
|
||||
"# Written by netOrk: the SSH algorithms a security assessment flagged,\n"
|
||||
"# taken out of what this server offers. Delete this file to undo it.\n"
|
||||
)
|
||||
|
||||
_RELOAD = (
|
||||
"if systemctl is-active --quiet ssh 2>/dev/null; then systemctl reload ssh;"
|
||||
" elif systemctl is-active --quiet sshd 2>/dev/null; then systemctl reload sshd;"
|
||||
" else for f in /run/sshd.pid /var/run/sshd.pid; do"
|
||||
' [ -r "$f" ] && kill -HUP "$(cat "$f")" && exit 0; done; exit 0; fi'
|
||||
)
|
||||
|
||||
|
||||
def _directive_key(line: str) -> Optional[str]:
|
||||
"""The algorithm class a global sshd_config line sets, if it sets one."""
|
||||
parts = line.split(None, 1)
|
||||
if not parts:
|
||||
return None
|
||||
word = parts[0].lower()
|
||||
for kind, (keyword, _directive) in SSH_ALGORITHM_CLASSES.items():
|
||||
if word == keyword:
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
def parse_sshd_effective(text: str) -> Dict[str, List[str]]:
|
||||
"""The four algorithm lists from ``sshd -T`` output, in the server's order.
|
||||
|
||||
:raises ValueError: if a list is missing -- not sshd -T output, or cut short.
|
||||
"""
|
||||
found: Dict[str, List[str]] = {}
|
||||
for raw in text.splitlines():
|
||||
parts = raw.strip().split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
kind = _directive_key(parts[0])
|
||||
if kind and kind not in found:
|
||||
found[kind] = [a for a in parts[1].split(",") if a]
|
||||
missing = [k for k in SSH_ALGORITHM_CLASSES if k not in found]
|
||||
if missing:
|
||||
raise ValueError(f"sshd -T printed no {', '.join(missing)} list")
|
||||
return found
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Refusal:
|
||||
"""One algorithm that was asked to go and stays, with why.
|
||||
|
||||
*reason* is ``"last"`` (the class would be empty), ``"client"`` (the managing
|
||||
client would share none of the class with the server) or ``"not_offered"``
|
||||
(the server does not offer it, so there is nothing to remove).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
algorithm: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SshHardeningPlan:
|
||||
keep: Dict[str, List[str]]
|
||||
remove: Dict[str, List[str]]
|
||||
refused: List[Refusal] = field(default_factory=list)
|
||||
not_offered: List[Refusal] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def changed_classes(self) -> List[str]:
|
||||
return [k for k in SSH_ALGORITHM_CLASSES if self.remove.get(k)]
|
||||
|
||||
|
||||
def plan_ssh_hardening(
|
||||
effective: Mapping[str, Sequence[str]],
|
||||
remove: Mapping[str, Iterable[str]],
|
||||
*,
|
||||
client: Optional[Mapping[str, Iterable[str]]] = None,
|
||||
) -> SshHardeningPlan:
|
||||
"""What the lists become when *remove* is taken out of *effective*.
|
||||
|
||||
*client* is what the client managing this host speaks, per class. A class it
|
||||
has no list for is not guarded beyond "never empty".
|
||||
|
||||
When a removal would leave a class empty, or without an algorithm *client*
|
||||
speaks, the first of the asked-for algorithms in the server's order that
|
||||
fixes it stays, and is reported in ``refused``.
|
||||
|
||||
:raises ValueError: for a class that is not one of :data:`SSH_ALGORITHM_CLASSES`.
|
||||
"""
|
||||
unknown = [k for k in remove if k not in SSH_ALGORITHM_CLASSES]
|
||||
if unknown:
|
||||
raise ValueError(f"unknown SSH algorithm class: {', '.join(unknown)}")
|
||||
keep: Dict[str, List[str]] = {}
|
||||
removed: Dict[str, List[str]] = {}
|
||||
refused: List[Refusal] = []
|
||||
not_offered: List[Refusal] = []
|
||||
for kind in SSH_ALGORITHM_CLASSES:
|
||||
offered = list(effective.get(kind, []))
|
||||
asked = set(remove.get(kind, []))
|
||||
not_offered += [Refusal(kind, a, "not_offered") for a in sorted(asked - set(offered))]
|
||||
going = [a for a in offered if a in asked]
|
||||
staying = [a for a in offered if a not in asked]
|
||||
speaks = set(client[kind]) if client and kind in client else None
|
||||
if going and speaks is not None and not speaks & set(staying):
|
||||
spared = next((a for a in going if a in speaks), None)
|
||||
if spared:
|
||||
going.remove(spared)
|
||||
refused.append(Refusal(kind, spared, "client"))
|
||||
if going and not [a for a in offered if a not in going]:
|
||||
spared = going.pop(0)
|
||||
refused.append(Refusal(kind, spared, "last"))
|
||||
keep[kind] = [a for a in offered if a not in going]
|
||||
if going:
|
||||
removed[kind] = going
|
||||
return SshHardeningPlan(keep=keep, remove=removed, refused=refused, not_offered=not_offered)
|
||||
|
||||
|
||||
def render_dropin(keep: Mapping[str, Sequence[str]], kinds: Iterable[str]) -> str:
|
||||
"""The drop-in file stating the full list of each class in *kinds*."""
|
||||
lines = [
|
||||
f"{SSH_ALGORITHM_CLASSES[k][1]} {','.join(keep[k])}\n"
|
||||
for k in SSH_ALGORITHM_CLASSES
|
||||
if k in set(kinds)
|
||||
]
|
||||
return _HEADER + "".join(lines)
|
||||
|
||||
|
||||
def dropin_classes(text: str) -> List[str]:
|
||||
"""The classes an existing drop-in sets."""
|
||||
return [k for k in (_directive_key(line.strip()) for line in text.splitlines()) if k]
|
||||
|
||||
|
||||
def _is_match(line: str) -> bool:
|
||||
parts = line.split(None, 1)
|
||||
return bool(parts) and parts[0].lower() == "match"
|
||||
|
||||
|
||||
def has_dropin_include(text: str) -> bool:
|
||||
"""Whether sshd_config's global section includes :data:`DROPIN_DIR`."""
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if _is_match(line):
|
||||
return False
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0].lower() == "include":
|
||||
if any(p.startswith(DROPIN_DIR + "/") for p in parts[1:]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _split_our_block(text: str) -> Tuple[List[str], str]:
|
||||
"""(the classes an earlier rewrite put above the file, the file without them)."""
|
||||
if not text.startswith(_HEADER):
|
||||
return [], text
|
||||
lines = text[len(_HEADER):].splitlines(keepends=True)
|
||||
ours = 0
|
||||
while ours < len(lines) and _directive_key(lines[ours].strip()):
|
||||
ours += 1
|
||||
return dropin_classes("".join(lines[:ours])), "".join(lines[ours:])
|
||||
|
||||
|
||||
def render_main_config(text: str, keep: Mapping[str, Sequence[str]], kinds: Iterable[str]) -> str:
|
||||
"""sshd_config with the lists of *kinds* first and their old global lines disabled.
|
||||
|
||||
First because sshd keeps the first value it reads. A ``Match`` block is left
|
||||
alone -- it is not the global setting. A class an earlier rewrite set stays
|
||||
set, and rewriting a file this already rewrote gives the same file.
|
||||
"""
|
||||
earlier, body = _split_our_block(text)
|
||||
wanted = set(kinds) | set(earlier)
|
||||
out: List[str] = []
|
||||
in_match = False
|
||||
for raw in body.splitlines():
|
||||
if _is_match(raw.strip()):
|
||||
in_match = True
|
||||
if not in_match and _directive_key(raw.strip()) in wanted:
|
||||
out.append(DISABLED_PREFIX + raw)
|
||||
else:
|
||||
out.append(raw)
|
||||
return render_dropin(keep, wanted) + "\n".join(out) + ("\n" if body.endswith("\n") else "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SshHardeningResult:
|
||||
"""What :meth:`OpenSshServerMixin.harden_ssh_server` did.
|
||||
|
||||
*applied* is True only when the new lists are live and a new session got
|
||||
in. *rolled_back* says the host was changed and then put back. *backup* is
|
||||
where sshd_config was copied to, when it was the file changed.
|
||||
"""
|
||||
|
||||
plan: SshHardeningPlan
|
||||
applied: bool = False
|
||||
rolled_back: bool = False
|
||||
path: Optional[str] = None
|
||||
backup: Optional[str] = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
class OpenSshServerMixin:
|
||||
"""Adds :meth:`harden_ssh_server` to a driver that can run commands as root on an OpenSSH host.
|
||||
|
||||
Mixed in by the drivers whose host runs a stock OpenSSH, not by a base class,
|
||||
so ``hasattr(driver, "harden_ssh_server")`` stays a truthful answer. A driver
|
||||
whose platform writes sshd_config itself (an appliance that regenerates it
|
||||
from its own database) must not inherit this, or must opt out with
|
||||
``harden_ssh_server = None``: its file would be overwritten on the next
|
||||
apply.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def run_command(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
privileged: bool = False,
|
||||
timeout: float = 60,
|
||||
stdin: Optional[bytes] = None,
|
||||
) -> CommandResult: ...
|
||||
|
||||
# ── the host ────────────────────────────────────────────────────────
|
||||
|
||||
def _ssh_run(self, command: str, stdin: Optional[bytes] = None) -> "CommandResult":
|
||||
return self.run_command(command, privileged=True, timeout=30, stdin=stdin)
|
||||
|
||||
def _ssh_read(self, path: str) -> Optional[str]:
|
||||
result = self._ssh_run(f"cat -- {quote(path)}")
|
||||
return result.stdout if result.exit_code == 0 else None
|
||||
|
||||
def _ssh_write(self, path: str, content: str) -> None:
|
||||
script = (
|
||||
f'tmp={quote(path + ".netork-tmp")} && cat > "$tmp" && chmod 644 "$tmp"'
|
||||
f' && mv -f -- "$tmp" {quote(path)} || {{ s=$?; rm -f -- "$tmp"; exit $s; }}'
|
||||
)
|
||||
result = self._ssh_run(script, stdin=content.encode())
|
||||
if result.exit_code != 0:
|
||||
raise RuntimeError(f"could not write {path}: {(result.stderr or result.stdout).strip()}")
|
||||
|
||||
def _ssh_remove(self, path: str) -> None:
|
||||
self._ssh_run(f"rm -f -- {quote(path)}")
|
||||
|
||||
def _reload_ssh_server(self) -> None:
|
||||
"""Make the running sshd read its configuration again; sessions stay up."""
|
||||
result = self._ssh_run(_RELOAD)
|
||||
if result.exit_code != 0:
|
||||
raise RuntimeError(f"could not reload sshd: {(result.stderr or result.stdout).strip()}")
|
||||
|
||||
def get_ssh_server_algorithms(self) -> Dict[str, List[str]]:
|
||||
"""The algorithm lists sshd uses now (``sshd -T``), per class.
|
||||
|
||||
:raises RuntimeError: if sshd -T fails -- no OpenSSH, or no root.
|
||||
"""
|
||||
result = self._ssh_run("PATH=$PATH:/usr/sbin:/sbin sshd -T")
|
||||
if result.exit_code != 0:
|
||||
raise RuntimeError(f"sshd -T failed: {(result.stderr or result.stdout).strip()}")
|
||||
return parse_sshd_effective(result.stdout)
|
||||
|
||||
def _sshd_accepts(self) -> Optional[str]:
|
||||
"""None when ``sshd -t`` accepts the configuration, else what it said."""
|
||||
result = self._ssh_run("PATH=$PATH:/usr/sbin:/sbin sshd -t")
|
||||
return None if result.exit_code == 0 else (result.stderr or result.stdout).strip()
|
||||
|
||||
# ── the change ──────────────────────────────────────────────────────
|
||||
|
||||
def harden_ssh_server(
|
||||
self,
|
||||
remove: Mapping[str, Iterable[str]],
|
||||
*,
|
||||
client: Optional[Mapping[str, Iterable[str]]] = None,
|
||||
verify: Optional[Callable[[], bool]] = None,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
) -> SshHardeningResult:
|
||||
"""Stop the server offering the algorithms in *remove* (class -> names).
|
||||
|
||||
*client* guards the managing client's algorithms (see
|
||||
:func:`plan_ssh_hardening`); *verify* opens a new session after the
|
||||
reload and returns whether it got in; *log* receives a line per step.
|
||||
|
||||
Never raises after the host was touched: a failure from there on puts
|
||||
the previous state back and says so in the result.
|
||||
|
||||
:raises RuntimeError: if the current state cannot be read.
|
||||
"""
|
||||
say = log or (lambda _line: None)
|
||||
say("Reading the algorithms sshd offers (sshd -T)")
|
||||
plan = plan_ssh_hardening(self.get_ssh_server_algorithms(), remove, client=client)
|
||||
for r in plan.refused:
|
||||
why = "it is the last one" if r.reason == "last" else "netOrk could no longer connect"
|
||||
say(f"Keeping {r.algorithm}: {why}")
|
||||
kinds = plan.changed_classes
|
||||
result = SshHardeningResult(plan=plan)
|
||||
if not kinds:
|
||||
result.message = "Nothing to change: the server offers none of the selected algorithms."
|
||||
say(result.message)
|
||||
return result
|
||||
for kind in kinds:
|
||||
say(f"Removing {', '.join(plan.remove[kind])} from {SSH_ALGORITHM_CLASSES[kind][1]}")
|
||||
|
||||
main = self._ssh_read(MAIN_CONFIG)
|
||||
if main is None:
|
||||
raise RuntimeError(f"cannot read {MAIN_CONFIG}")
|
||||
restore = self._write_change(plan, main, result, say)
|
||||
return self._apply_or_restore(plan, kinds, result, restore, verify, say)
|
||||
|
||||
def _write_change(
|
||||
self, plan: SshHardeningPlan, main: str, result: SshHardeningResult, say: Callable[[str], None]
|
||||
) -> Callable[[], None]:
|
||||
"""Write the new lists; return what puts the previous state back."""
|
||||
if has_dropin_include(main):
|
||||
previous = self._ssh_read(DROPIN_PATH)
|
||||
kinds = set(plan.changed_classes) | set(dropin_classes(previous or ""))
|
||||
self._ssh_run(f"mkdir -p -- {quote(DROPIN_DIR)}")
|
||||
self._ssh_write(DROPIN_PATH, render_dropin(plan.keep, kinds))
|
||||
result.path = DROPIN_PATH
|
||||
say(f"Wrote {DROPIN_PATH}")
|
||||
|
||||
def restore() -> None:
|
||||
if previous is None:
|
||||
self._ssh_remove(DROPIN_PATH)
|
||||
else:
|
||||
self._ssh_write(DROPIN_PATH, previous)
|
||||
|
||||
return restore
|
||||
backup = f"{MAIN_CONFIG}.netork-{time.strftime('%Y%m%d-%H%M%S')}.bak"
|
||||
self._ssh_write(backup, main)
|
||||
self._ssh_write(MAIN_CONFIG, render_main_config(main, plan.keep, plan.changed_classes))
|
||||
result.path, result.backup = MAIN_CONFIG, backup
|
||||
say(f"{MAIN_CONFIG} has no Include of {DROPIN_DIR}; rewrote it (copy at {backup})")
|
||||
|
||||
def restore_main() -> None:
|
||||
self._ssh_write(MAIN_CONFIG, main)
|
||||
|
||||
return restore_main
|
||||
|
||||
def _apply_or_restore(
|
||||
self,
|
||||
plan: SshHardeningPlan,
|
||||
kinds: List[str],
|
||||
result: SshHardeningResult,
|
||||
restore: Callable[[], None],
|
||||
verify: Optional[Callable[[], bool]],
|
||||
say: Callable[[str], None],
|
||||
) -> SshHardeningResult:
|
||||
def back(message: str, reload: bool) -> SshHardeningResult:
|
||||
say(message)
|
||||
say("Putting the previous configuration back")
|
||||
restore()
|
||||
if reload:
|
||||
self._reload_ssh_server()
|
||||
result.rolled_back = True
|
||||
result.message = message
|
||||
return result
|
||||
|
||||
try:
|
||||
say("Checking the configuration (sshd -t)")
|
||||
complaint = self._sshd_accepts()
|
||||
if complaint:
|
||||
return back(f"sshd rejected the new configuration: {complaint}", reload=False)
|
||||
now = self.get_ssh_server_algorithms()
|
||||
ignored = [SSH_ALGORITHM_CLASSES[k][1] for k in kinds if now[k] != plan.keep[k]]
|
||||
if ignored:
|
||||
return back(
|
||||
f"sshd does not use the new {', '.join(ignored)}: a setting read before"
|
||||
f" {result.path} decides them",
|
||||
reload=False,
|
||||
)
|
||||
say("Reloading sshd")
|
||||
self._reload_ssh_server()
|
||||
if verify is not None:
|
||||
say("Opening a new SSH session to check netOrk still gets in")
|
||||
if not verify():
|
||||
return back("A new SSH session could not log in after the reload", reload=True)
|
||||
except Exception as exc: # noqa: BLE001 - whatever went wrong, the host goes back
|
||||
return back(f"The change failed: {exc}", reload=True)
|
||||
result.applied = True
|
||||
result.message = "The server no longer offers the selected algorithms."
|
||||
say(result.message)
|
||||
return result
|
||||
@@ -0,0 +1,406 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Virtual IPs: the addresses a host's HA configuration lets float between machines.
|
||||
|
||||
vip-manager and keepalived put a service address on whichever member is master
|
||||
*right now*. The address list of a standby shows no trace of it; what every
|
||||
member has is the configuration that declares it. That is read the same way on
|
||||
every Linux host, so the command and its parse live here once, and a driver
|
||||
only carries the command across.
|
||||
|
||||
**Declared, not held.** The reading says which addresses the configuration
|
||||
lets onto this host. Whether the host holds one at the moment is in its
|
||||
interface addresses, which the caller already has.
|
||||
|
||||
**Filtered on the host.** The same files hold secrets -- vip-manager's etcd
|
||||
password, keepalived's ``auth_pass``, notify scripts with their arguments. The
|
||||
command therefore never prints a configuration: an awk program reads the unit's
|
||||
properties and files and prints allowlisted fields only. A secret cannot reach
|
||||
the caller's memory, its log or an exception text, because it never leaves the
|
||||
host.
|
||||
|
||||
**What is read.**
|
||||
|
||||
- *vip-manager*: every ``vip-manager.service`` and ``vip-manager@*.service`` unit
|
||||
systemd knows. The address comes from the first of: a command-line flag
|
||||
(``--ip``; 1.x's ``-ip=${VIP_IP}`` resolved through the unit's environment), the
|
||||
environment (``VIP_IP``, an ``EnvironmentFile`` over ``Environment``), the
|
||||
``--config`` YAML (5.x's ``ip``). Mask, interface and trigger key alike.
|
||||
- *keepalived*: ``keepalived.service``, its ``-f``/``--use-file`` or
|
||||
``/etc/keepalived/keepalived.conf``. The ``virtual_ipaddress`` and
|
||||
``virtual_ipaddress_excluded`` entries of every ``vrrp_instance``, with the
|
||||
instance's ``interface`` and ``virtual_router_id``. ``include`` is followed
|
||||
four levels deep, a relative pattern from the including file's directory. A
|
||||
pattern that is not a plain path glob is not followed: it would otherwise be
|
||||
handed to a root shell.
|
||||
|
||||
**None is "did not look".** A file that exists but cannot be read, an include
|
||||
that cannot be followed, a host without systemd: that mechanism is None, and the
|
||||
caller keeps what it knew. ``[]`` is a host that has none.
|
||||
|
||||
**Root, and without it.** The configuration may be root's alone. The whole
|
||||
script goes to the host as one ``sh -c`` argument; when the privileged call
|
||||
brings no report back, it runs again without privilege and reads what it can.
|
||||
|
||||
**An exec channel, not a terminal.** With its awk program the command is about
|
||||
4 kB, past the line an interactive shell reliably takes. A driver sends it the
|
||||
way it runs any command that needs no PTY -- napalm-linux's ``run_command``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from shlex import quote
|
||||
from typing import Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import VirtualIpDict, VirtualIpsDict
|
||||
from napalm_device_types.terminal import strip_terminal_codes
|
||||
|
||||
_BEGIN = "VIPS_BEGIN"
|
||||
_END = "VIPS_END"
|
||||
_NO_SYSTEMD = "no-systemd"
|
||||
#: A record that says a mechanism was not fully read.
|
||||
_BLIND = frozenset({"unreadable", "too-deep"})
|
||||
_KEEPALIVED_CONF = "/etc/keepalived/keepalived.conf"
|
||||
_ENV_KEYS = "VIP_(IP|MASK|NETMASK|IFACE|INTERFACE|KEY|TRIGGER_KEY)"
|
||||
_CFG_KEYS = "(ip|netmask|mask|interface|iface|trigger-key|key)"
|
||||
|
||||
#: One line of awk, fed a unit's ``systemctl show`` output with ``-v m=<mechanism>``.
|
||||
#: It prints records, never a line of configuration: ``active <state>``,
|
||||
#: ``arg <flag> <value>``, ``env <VIP_*> <value>``, ``cfg <key> <value>``,
|
||||
#: ``instance <name>`` / ``interface`` / ``virtual_router_id`` / ``vip <addr> [dev <if>]``
|
||||
#: / ``end``, and ``unreadable``/``too-deep``/``missing <path>``. No ``>`` but in
|
||||
#: ``2>/dev/null``: comparisons are written the other way round. ``\047`` is a
|
||||
#: single quote, so the program itself needs none.
|
||||
_AWK = " ".join(
|
||||
(
|
||||
r'function q(p) { return "\047" p "\047" }',
|
||||
r'function ok(f) { if (f == "" || f ~ /\047/) { print "unreadable " f; return 0 }'
|
||||
r' if (system("test -r " q(f)) == 0) return 1;'
|
||||
r' if (system("test -e " q(f)) == 0) print "unreadable " f; else print "missing " f;'
|
||||
r" return 0 }",
|
||||
r"function rf(f, c, l, r, k) { if (!ok(f)) return;"
|
||||
r" while (0 < (r = (getline l < f))) {"
|
||||
rf' if (c == "cfg" && l ~ /^[ \t]*{_CFG_KEYS}[ \t]*:/) {{ sub(/^[ \t]*/, "", l); k = l;'
|
||||
r' sub(/[ \t]*:.*/, "", k); sub(/^[^:]*:[ \t]*/, "", l); print "cfg " k " " l }'
|
||||
rf' else if (c == "env" && l ~ /^[ \t]*(export[ \t]+)?{_ENV_KEYS}[ \t]*=/) {{'
|
||||
r' sub(/^[ \t]*(export[ \t]+)?/, "", l); k = l; sub(/[ \t]*=.*/, "", k);'
|
||||
r' sub(/^[^=]*=[ \t]*/, "", l); print "env " k " " l } }'
|
||||
r' if (r < 0) print "unreadable " f; close(f) }',
|
||||
r'function push(n, a) { st[++sd] = n; if (n == "vrrp_instance") print "instance " a }',
|
||||
r'function pop() { if (0 < sd) { if (st[sd] == "vrrp_instance") print "end"; sd-- } }',
|
||||
r"function stmt(w, nw, f, d, x, i) { if (!nw) return;"
|
||||
r' if (w[1] == "include") { if (1 < nw) inc(w[2], f, d); return }'
|
||||
r' if (sd == 1 && st[1] == "vrrp_instance") {'
|
||||
r' if (1 < nw && (w[1] == "interface" || w[1] == "virtual_router_id")) print w[1] " " w[2];'
|
||||
r" return }"
|
||||
r' if (sd == 2 && st[1] == "vrrp_instance" && st[2] ~ /^virtual_ipaddress(_excluded)?$/) {'
|
||||
r' x = "vip " w[1]; for (i = 2; i < nw; i++) if (w[i] == "dev") x = x " dev " w[i + 1];'
|
||||
r" print x } }",
|
||||
r"function rk(f, d, l, r, n, t, k, w, nw) {"
|
||||
r' if (4 < d || (f in seen)) { print "too-deep " f; return }'
|
||||
r" if (!ok(f)) return; seen[f] = 1;"
|
||||
r' while (0 < (r = (getline l < f))) { sub(/[#!].*/, "", l); gsub(/[{}]/, " & ", l);'
|
||||
r' n = split(l, t, " "); nw = 0; for (k = 1; k <= n; k++) {'
|
||||
r' if (t[k] == "{") { push(nw ? w[1] : "", 1 < nw ? w[2] : ""); nw = 0 }'
|
||||
r' else if (t[k] == "}") { stmt(w, nw, f, d); nw = 0; pop() } else w[++nw] = t[k] }'
|
||||
r" stmt(w, nw, f, d) }"
|
||||
r' if (r < 0) print "unreadable " f; close(f); delete seen[f] }',
|
||||
r"function inc(p, f, d, c, g, dir, fs, n, i) {"
|
||||
r' if (p !~ /^\//) { dir = f; sub(/[^\/]*$/, "", dir); p = dir p }'
|
||||
r' if (p !~ /^[A-Za-z0-9_.\/*?-]+$/) { print "unreadable " p; return }'
|
||||
r' c = "for g in " p "; do [ -e \"$g\" ] && echo \"$g\"; done"; n = 0;'
|
||||
r" while (0 < (c | getline g)) fs[++n] = g; close(c);"
|
||||
r" for (i = 1; i <= n; i++) rk(fs[i], d + 1) }",
|
||||
r'/^ActiveState=/ { print "active " substr($0, 13); next }',
|
||||
r'/^ExecStart=/ { s = $0; i = index(s, "argv[]="); if (!i) next; s = substr(s, i + 7);'
|
||||
r' j = index(s, " ;"); if (j) s = substr(s, 1, j - 1); n = split(s, t, " ");'
|
||||
r' for (k = 2; k <= n; k++) { f = t[k]; if (f !~ /^-/) continue; v = ""; x = index(f, "=");'
|
||||
r" if (x) { v = substr(f, x + 1); f = substr(f, 1, x - 1) }"
|
||||
r' else if (k < n && t[k + 1] !~ /^-/) v = t[++k]; sub(/^--?/, "", f);'
|
||||
r" if (f ~ /^(config|ip|netmask|mask|interface|iface|trigger-key|key|f|use-file)$/) {"
|
||||
r' print "arg " f " " v; a[f] = v } } next }',
|
||||
r'/^EnvironmentFiles=/ { s = substr($0, 18); sub(/ \(ignore_errors=[a-z]*\)$/, "", s);'
|
||||
r' if (s != "") ef[++ne] = s; next }',
|
||||
r'/^Environment=/ { n = split(substr($0, 13), t, " "); for (k = 1; k <= n; k++) {'
|
||||
rf' x = index(t[k], "="); if (x && substr(t[k], 1, x - 1) ~ /^{_ENV_KEYS}$/)'
|
||||
r' print "env " substr(t[k], 1, x - 1) " " substr(t[k], x + 1) } next }',
|
||||
rf'END {{ if (m == "keepalived") {{ p = "{_KEEPALIVED_CONF}";'
|
||||
r' if ("use-file" in a) p = a["use-file"]; if ("f" in a) p = a["f"]; rk(p, 0) }'
|
||||
r' else { for (k = 1; k <= ne; k++) rf(ef[k], "env");'
|
||||
r' if ("config" in a) rf(a["config"], "cfg") } }',
|
||||
)
|
||||
)
|
||||
|
||||
#: 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.
|
||||
VIRTUAL_IPS_COMMAND = (
|
||||
"PATH=$PATH:/usr/sbin:/sbin; "
|
||||
"printf '%s%s\\n' VIPS_ BEGIN; "
|
||||
"if command -v systemctl >/dev/null 2>&1; then echo '[systemd]'; "
|
||||
"for u in $(systemctl list-units --all --plain --no-legend --type=service "
|
||||
"vip-manager.service 'vip-manager@*' keepalived.service 2>/dev/null | awk '{print $1}'); do "
|
||||
"case $u in keepalived*) m=keepalived;; *) m=vip-manager;; esac; "
|
||||
'echo "[$m $u]"; '
|
||||
'systemctl show -p ActiveState -p ExecStart -p EnvironmentFiles -p Environment "$u" '
|
||||
f"2>/dev/null | awk -v m=$m {quote(_AWK)}; done; "
|
||||
"else echo '[no-systemd]'; fi; "
|
||||
"printf '%s%s\\n' VIPS_ END"
|
||||
)
|
||||
|
||||
_COMMENT_RE = re.compile(r"\s+#.*$")
|
||||
_VARIABLE_RE = re.compile(r"^\$\{?(\w+)\}?$")
|
||||
_Record = Tuple[str, str]
|
||||
|
||||
|
||||
def _frame(output: str) -> List[str]:
|
||||
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
|
||||
try:
|
||||
start = lines.index(_BEGIN)
|
||||
end = lines.index(_END, start)
|
||||
except ValueError:
|
||||
raise ValueError("no intact virtual IP report in the output") from None
|
||||
return lines[start + 1 : end]
|
||||
|
||||
|
||||
def _sections(lines: List[str]) -> List[Tuple[str, str, List[_Record]]]:
|
||||
"""``[<mechanism> <unit>]`` headers, each with its records as ``(kind, rest)``."""
|
||||
sections: List[Tuple[str, str, List[_Record]]] = []
|
||||
for line in lines:
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
mechanism, _, unit = line[1:-1].partition(" ")
|
||||
sections.append((mechanism, unit, []))
|
||||
elif line and sections:
|
||||
kind, _, rest = line.partition(" ")
|
||||
sections[-1][2].append((kind, rest.strip()))
|
||||
return sections
|
||||
|
||||
|
||||
def _clean(value: str) -> str:
|
||||
"""Without a trailing ``# comment`` and the quotes around it."""
|
||||
value = _COMMENT_RE.sub("", value.strip())
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _address(text: str) -> Optional[Tuple[str, Optional[int]]]:
|
||||
"""``"10.0.0.10/24"`` -> ``("10.0.0.10", 24)``; None for what is no address."""
|
||||
host, slash, prefix = text.partition("/")
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return None
|
||||
if not slash:
|
||||
return str(address), None
|
||||
if not prefix.isdigit() or int(prefix) > address.max_prefixlen:
|
||||
return None
|
||||
return str(address), int(prefix)
|
||||
|
||||
|
||||
def _prefix(mask: Optional[str]) -> Optional[int]:
|
||||
"""``"24"`` or ``"255.255.255.0"`` -> 24."""
|
||||
if not mask:
|
||||
return None
|
||||
if mask.isdigit():
|
||||
return int(mask) if int(mask) <= 128 else None
|
||||
try:
|
||||
return ipaddress.IPv4Network(f"0.0.0.0/{mask}").prefixlen
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _running(records: List[_Record]) -> Optional[bool]:
|
||||
states = [rest for kind, rest in records if kind == "active"]
|
||||
return states[-1] == "active" if states else None
|
||||
|
||||
|
||||
def _vip_manager(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
|
||||
values: Dict[str, Dict[str, str]] = {"arg": {}, "env": {}, "cfg": {}}
|
||||
for kind, rest in records:
|
||||
if kind in values:
|
||||
key, _, value = rest.partition(" ")
|
||||
values[kind][key] = value
|
||||
env = values["env"]
|
||||
|
||||
def resolve(value: str) -> Optional[str]:
|
||||
value = _clean(value)
|
||||
variable = _VARIABLE_RE.match(value)
|
||||
if variable:
|
||||
value = _clean(env.get(variable.group(1), ""))
|
||||
return value if value and "$" not in value else None
|
||||
|
||||
def pick(args: Tuple[str, ...], envs: Tuple[str, ...], cfgs: Tuple[str, ...]) -> Optional[str]:
|
||||
for kind, names in (("arg", args), ("env", envs), ("cfg", cfgs)):
|
||||
for name in names:
|
||||
if name in values[kind]:
|
||||
found = resolve(values[kind][name])
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
ip = pick(("ip",), ("VIP_IP",), ("ip",))
|
||||
parsed = _address(ip) if ip else None
|
||||
if parsed is None:
|
||||
return []
|
||||
address, prefix = parsed
|
||||
mask = pick(("netmask", "mask"), ("VIP_NETMASK", "VIP_MASK"), ("netmask", "mask"))
|
||||
return [
|
||||
{
|
||||
"mechanism": "vip-manager",
|
||||
"instance": unit[: -len(".service")] if unit.endswith(".service") else unit,
|
||||
"address": address,
|
||||
"prefix_length": _prefix(mask) if mask else prefix,
|
||||
"interface": pick(
|
||||
("interface", "iface"), ("VIP_INTERFACE", "VIP_IFACE"), ("interface", "iface")
|
||||
),
|
||||
"group_key": pick(
|
||||
("trigger-key", "key"), ("VIP_TRIGGER_KEY", "VIP_KEY"), ("trigger-key", "key")
|
||||
),
|
||||
"running": _running(records),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Instance:
|
||||
name: str
|
||||
interface: Optional[str] = None
|
||||
vrid: str = ""
|
||||
vips: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _keepalived(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
|
||||
running = _running(records)
|
||||
instances: List[_Instance] = []
|
||||
current: Optional[_Instance] = None
|
||||
for kind, rest in records:
|
||||
if kind == "instance":
|
||||
current = _Instance(rest or unit)
|
||||
instances.append(current)
|
||||
elif current is None:
|
||||
continue
|
||||
elif kind == "interface":
|
||||
current.interface = rest or None
|
||||
elif kind == "virtual_router_id":
|
||||
current.vrid = rest
|
||||
elif kind == "vip":
|
||||
current.vips.append(rest)
|
||||
elif kind == "end":
|
||||
current = None
|
||||
return [entry for instance in instances for entry in _instance_entries(instance, running)]
|
||||
|
||||
|
||||
def _instance_entries(instance: _Instance, running: Optional[bool]) -> List[VirtualIpDict]:
|
||||
entries: List[VirtualIpDict] = []
|
||||
for vip in instance.vips:
|
||||
tokens = vip.split()
|
||||
parsed = _address(tokens[0]) if tokens else None
|
||||
if parsed is None:
|
||||
continue
|
||||
address, prefix = parsed
|
||||
dev = tokens[tokens.index("dev") + 1] if "dev" in tokens[1:-1] else None
|
||||
entries.append(
|
||||
{
|
||||
"mechanism": "keepalived",
|
||||
"instance": instance.name,
|
||||
"address": address,
|
||||
"prefix_length": prefix if prefix is not None else _host_prefix(address),
|
||||
"interface": dev or instance.interface,
|
||||
"group_key": f"vrid:{instance.vrid}" if instance.vrid.isdigit() else None,
|
||||
"running": running,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _host_prefix(address: str) -> int:
|
||||
"""keepalived's default for an entry without one: the single address."""
|
||||
return ipaddress.ip_address(address).max_prefixlen
|
||||
|
||||
|
||||
_READERS: Dict[str, Tuple[str, Callable[[str, List[_Record]], List[VirtualIpDict]]]] = {
|
||||
"vip-manager": ("vip_manager", _vip_manager),
|
||||
"keepalived": ("keepalived", _keepalived),
|
||||
}
|
||||
|
||||
|
||||
def _order(entry: VirtualIpDict) -> Tuple[int, int, str]:
|
||||
address = ipaddress.ip_address(entry["address"])
|
||||
return address.version, int(address), entry["instance"]
|
||||
|
||||
|
||||
def parse_virtual_ips(output: str) -> VirtualIpsDict:
|
||||
"""Parse what :data:`VIRTUAL_IPS_COMMAND` printed, each mechanism's entries
|
||||
sorted by address.
|
||||
|
||||
:raises ValueError: when the output carries no intact report.
|
||||
"""
|
||||
sections = _sections(_frame(output))
|
||||
if any(mechanism == _NO_SYSTEMD for mechanism, _, _ in sections):
|
||||
return {"vip_manager": None, "keepalived": None}
|
||||
found: Dict[str, List[VirtualIpDict]] = {"vip_manager": [], "keepalived": []}
|
||||
blind: Set[str] = set()
|
||||
for mechanism, unit, records in sections:
|
||||
if mechanism not in _READERS:
|
||||
continue
|
||||
key, read = _READERS[mechanism]
|
||||
if any(kind in _BLIND for kind, _ in records):
|
||||
blind.add(key)
|
||||
else:
|
||||
found[key].extend(read(unit, records))
|
||||
def result(key: str) -> Optional[List[VirtualIpDict]]:
|
||||
return None if key in blind else sorted(found[key], key=_order)
|
||||
|
||||
return {"vip_manager": result("vip_manager"), "keepalived": result("keepalived")}
|
||||
|
||||
|
||||
class VirtualIpsMixin:
|
||||
"""Adds :meth:`get_virtual_ips` to a driver that can run a command on a Linux host.
|
||||
|
||||
The template form (README, "Function classes"): the command and its parse are
|
||||
the same everywhere, so they are concrete here, and a driver supplies only
|
||||
:meth:`_run_virtual_ips_command` -- how a command reaches its host, and how it
|
||||
gains root there. Mixed in by the drivers that can, not by
|
||||
:class:`~napalm_device_types.os.OSDriver`, so ``hasattr(driver,
|
||||
"get_virtual_ips")`` stays a truthful answer.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def _run_virtual_ips_command(self, command: str, *, privileged: bool) -> str:
|
||||
"""Run *command* on the host and return what it printed; as root
|
||||
when *privileged*. The command is a single ``sh -c`` invocation of
|
||||
about 4 kB: send it on an exec channel, not typed into a shell."""
|
||||
...
|
||||
|
||||
def get_virtual_ips(self) -> VirtualIpsDict:
|
||||
"""
|
||||
Returns the virtual IPs the host's vip-manager and keepalived configuration
|
||||
declares -- whether or not the host holds them at the moment.
|
||||
|
||||
* vip_manager (list or None) - see :class:`~napalm_device_types.models.VirtualIpDict`
|
||||
* keepalived (list or None)
|
||||
|
||||
None for a mechanism means it could not be read; ``[]`` that the host has none.
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"vip_manager": [
|
||||
{"mechanism": "vip-manager", "instance": "vip-manager",
|
||||
"address": "10.7.224.10", "prefix_length": 24, "interface": "ens7",
|
||||
"group_key": "/service/netork-db/leader", "running": True},
|
||||
],
|
||||
"keepalived": [],
|
||||
}
|
||||
|
||||
:raises ValueError: if neither reading carried an intact report.
|
||||
"""
|
||||
command = f"sh -c {quote(VIRTUAL_IPS_COMMAND)}"
|
||||
try:
|
||||
return parse_virtual_ips(self._run_virtual_ips_command(command, privileged=True))
|
||||
except ValueError:
|
||||
pass
|
||||
return parse_virtual_ips(self._run_virtual_ips_command(command, privileged=False))
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "napalm-device-types"
|
||||
version = "3.0.0"
|
||||
version = "4.5.0"
|
||||
description = "Abstract device-type base classes for NAPALM drivers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -157,3 +157,22 @@ class TestPrivilegedCli:
|
||||
driver.open_container_engine("docker").stream_cli(["pull", "nginx"], privileged=True)
|
||||
|
||||
assert driver.streams == [("docker pull nginx", True)]
|
||||
|
||||
|
||||
class TestNoDockerModel:
|
||||
"""There is no container model here (NetOrk/netork#765): netOrk reads and
|
||||
handles containers over the Engine API, so the CLI-shaped types and the
|
||||
``get_docker_info()`` declaration that served napalm-linux' old reading are gone."""
|
||||
|
||||
def test_the_docker_types_are_gone(self):
|
||||
import napalm_device_types
|
||||
from napalm_device_types import models
|
||||
|
||||
for name in ("DockerInfoDict", "DockerContainerDict", "DockerImageDict"):
|
||||
assert not hasattr(models, name), name
|
||||
assert not hasattr(napalm_device_types, name), name
|
||||
|
||||
def test_an_os_driver_declares_no_docker_reading(self):
|
||||
from napalm_device_types import os as os_role
|
||||
|
||||
assert "get_docker_info" not in os_role.__dict__.get("OSDriver", object).__dict__
|
||||
+309
-1
@@ -11,10 +11,14 @@ The fixtures are the real states of six hosts on netOrk's test server.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import OSDriver
|
||||
from napalm_device_types.host_status import (
|
||||
BUSY_CHECK,
|
||||
HOST_STATUS_COMMAND,
|
||||
HostStatusMixin,
|
||||
kernel_reboot_pending,
|
||||
@@ -29,8 +33,12 @@ def _wire(
|
||||
modules=("6.8.0-139-generic", "6.8.0-142-generic"),
|
||||
needs_restarting=None,
|
||||
periodic=None,
|
||||
stamp=None,
|
||||
timer="enabled",
|
||||
dnf_timers=("not-found", "not-found"),
|
||||
dpkg=None,
|
||||
journal=(),
|
||||
busy=False,
|
||||
):
|
||||
lines = ["HSTAT_BEGIN"]
|
||||
if reboot_file:
|
||||
@@ -39,7 +47,11 @@ def _wire(
|
||||
lines += ["[needs-restarting]", str(needs_restarting)]
|
||||
lines += ["[kernel]", running, "[modules]", *modules]
|
||||
if periodic is not None:
|
||||
lines += ["[apt-config]", *periodic]
|
||||
lines += ["[apt-config]", *periodic, "[upgrade-stamp]", *([str(stamp)] if stamp else [])]
|
||||
if dpkg is not None:
|
||||
if busy:
|
||||
lines.append("[dpkg-busy]")
|
||||
lines += ["[dpkg-journal]", *journal, "[dpkg-audit]", *dpkg]
|
||||
lines += [
|
||||
"[timers]",
|
||||
f"apt-daily-upgrade.timer {timer}",
|
||||
@@ -198,3 +210,299 @@ class TestHostStatusMixin:
|
||||
|
||||
def test_not_every_os_driver_has_it(self):
|
||||
assert not hasattr(OSDriver, "get_host_status")
|
||||
|
||||
|
||||
class TestFreeBSD:
|
||||
"""freebsd-version -k names the installed kernel, -r the running one; a
|
||||
patch that replaced the kernel shows as the two differing until the host
|
||||
boots it. Recorded on a FreeBSD 15.1 host (NetOrk/netork#799)."""
|
||||
|
||||
#: What the command printed there before this section existed.
|
||||
BEFORE = (
|
||||
"HSTAT_BEGIN\n[kernel]\n15.1-RELEASE\n[modules]\n[timers]\n"
|
||||
"apt-daily-upgrade.timer \ndnf-automatic.timer \ndnf-automatic-install.timer \n"
|
||||
"HSTAT_END\n"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _report(installed: str, running: str) -> str:
|
||||
return (
|
||||
f"HSTAT_BEGIN\n[kernel]\n{running}\n[modules]\n"
|
||||
f"[freebsd-kernel]\n{installed}\n[freebsd-running]\n{running}\n"
|
||||
"[timers]\napt-daily-upgrade.timer \nHSTAT_END\n"
|
||||
)
|
||||
|
||||
def test_without_the_section_it_is_unknown(self):
|
||||
assert parse_host_status(self.BEFORE)["reboot_required"] is None
|
||||
|
||||
def test_the_running_kernel_is_the_installed_one(self):
|
||||
status = parse_host_status(self._report("15.1-RELEASE-p4", "15.1-RELEASE-p4"))
|
||||
assert status["reboot_required"] is False
|
||||
assert status["reboot_reason"] is None
|
||||
|
||||
def test_a_patched_kernel_waits_for_a_reboot(self):
|
||||
status = parse_host_status(self._report("15.1-RELEASE-p5", "15.1-RELEASE-p4"))
|
||||
assert status["reboot_required"] is True
|
||||
assert status["reboot_reason"] == (
|
||||
"kernel 15.1-RELEASE-p5 installed, 15.1-RELEASE-p4 running"
|
||||
)
|
||||
|
||||
def test_freebsd_does_not_patch_itself_as_far_as_netork_can_tell(self):
|
||||
status = parse_host_status(self._report("15.1-RELEASE", "15.1-RELEASE"))
|
||||
assert status["auto_updates"] is None
|
||||
|
||||
def test_the_command_asks_only_where_freebsd_version_exists(self):
|
||||
assert "command -v freebsd-version" in HOST_STATUS_COMMAND
|
||||
assert "freebsd-version -k" in HOST_STATUS_COMMAND
|
||||
assert "freebsd-version -r" in HOST_STATUS_COMMAND
|
||||
|
||||
|
||||
class TestInterruptedDpkg:
|
||||
"""dpkg left halfway: apt refuses to install anything until
|
||||
``dpkg --configure -a`` has run, and the daily apt timers report success
|
||||
all the same (NetOrk/netork#738).
|
||||
|
||||
The fixture is aris (Ubuntu 24.04): unattended-upgrades was cut off on
|
||||
2026-07-03 while update-initramfs built the initrd for 6.8.0-134, and the
|
||||
kernel package stayed half-configured for three months.
|
||||
"""
|
||||
|
||||
ARIS = ("iF linux-image-6.8.0-134-generic",)
|
||||
|
||||
def test_a_half_configured_kernel_is_an_interrupted_dpkg(self):
|
||||
status = parse_host_status(_wire(dpkg=self.ARIS))
|
||||
|
||||
assert status["package_manager_interrupted"] is True
|
||||
assert status["interrupted_packages"] == [
|
||||
{"name": "linux-image-6.8.0-134-generic", "state": "half-configured"}
|
||||
]
|
||||
|
||||
def test_a_clean_dpkg_is_not_interrupted(self):
|
||||
status = parse_host_status(_wire(dpkg=()))
|
||||
|
||||
assert status["package_manager_interrupted"] is False
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "state"),
|
||||
[
|
||||
("iH base-files", "half-installed"),
|
||||
("iU base-files", "unpacked"),
|
||||
("iF base-files", "half-configured"),
|
||||
("iW base-files", "triggers-awaited"),
|
||||
("it base-files", "triggers-pending"),
|
||||
("iUR base-files", "reinstall-required"),
|
||||
("hF base-files", "half-configured"),
|
||||
],
|
||||
)
|
||||
def test_every_state_an_interrupted_run_leaves(self, line, state):
|
||||
status = parse_host_status(_wire(dpkg=(line,)))
|
||||
|
||||
assert status["interrupted_packages"] == [{"name": "base-files", "state": state}]
|
||||
|
||||
def test_a_journal_left_behind_is_interrupted_on_its_own(self):
|
||||
"""What apt itself checks: numbered files in /var/lib/dpkg/updates."""
|
||||
status = parse_host_status(_wire(dpkg=(), journal=("0000", "0001")))
|
||||
|
||||
assert status["package_manager_interrupted"] is True
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
def test_only_numbered_journal_files_count(self):
|
||||
"""apt ignores anything else there, such as dpkg's tmp.i."""
|
||||
status = parse_host_status(_wire(dpkg=(), journal=("tmp.i",)))
|
||||
|
||||
assert status["package_manager_interrupted"] is False
|
||||
|
||||
def test_a_running_package_manager_makes_it_unknown(self):
|
||||
"""Mid-upgrade, packages are unpacked and not yet configured, also
|
||||
between two of apt's dpkg calls. That is work in progress, not an
|
||||
interruption."""
|
||||
status = parse_host_status(_wire(dpkg=("iU base-files",), busy=True))
|
||||
|
||||
assert status["package_manager_interrupted"] is None
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
def test_without_dpkg_it_is_unknown(self):
|
||||
status = parse_host_status(_wire())
|
||||
|
||||
assert status["package_manager_interrupted"] is None
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
def test_the_command_asks_dpkg_only_where_it_exists(self):
|
||||
assert "command -v dpkg-query" in HOST_STATUS_COMMAND
|
||||
assert "/var/lib/dpkg/updates" in HOST_STATUS_COMMAND
|
||||
|
||||
def test_it_never_repairs(self):
|
||||
assert "--configure" not in HOST_STATUS_COMMAND
|
||||
|
||||
|
||||
class TestAutoUpdatesLastSuccess:
|
||||
"""apt.systemd.daily touches upgrade-stamp only after unattended-upgrade
|
||||
succeeded. update-success-stamp says nothing: netOrk's own daily
|
||||
``apt-get update`` touches it as well (NetOrk/netork#738)."""
|
||||
|
||||
def test_the_upgrade_stamp_is_the_last_successful_run(self):
|
||||
status = parse_host_status(_wire(periodic=UNATTENDED, stamp=1751525820))
|
||||
|
||||
assert status["auto_updates_last_success"] == 1751525820
|
||||
assert status["auto_updates_interval"] == 86400
|
||||
|
||||
def test_no_stamp_is_unknown(self):
|
||||
status = parse_host_status(_wire(periodic=UNATTENDED))
|
||||
|
||||
assert status["auto_updates_last_success"] is None
|
||||
|
||||
def test_without_apt_both_are_unknown(self):
|
||||
status = parse_host_status(_wire())
|
||||
|
||||
assert status["auto_updates_last_success"] is None
|
||||
assert status["auto_updates_interval"] is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "seconds"),
|
||||
[
|
||||
('"1"', 86400),
|
||||
('"7d"', 7 * 86400),
|
||||
('"12h"', 43200),
|
||||
('"30m"', 1800),
|
||||
('"90s"', 90),
|
||||
('"always"', 0),
|
||||
('"0"', None),
|
||||
('"weekly"', None),
|
||||
],
|
||||
)
|
||||
def test_the_interval_in_apts_units(self, value, seconds):
|
||||
periodic = [f"APT::Periodic::Unattended-Upgrade {value};"]
|
||||
|
||||
assert parse_host_status(_wire(periodic=periodic))["auto_updates_interval"] == seconds
|
||||
|
||||
def test_the_command_reads_the_upgrade_stamp_only(self):
|
||||
assert "/var/lib/apt/periodic/upgrade-stamp" in HOST_STATUS_COMMAND
|
||||
assert "update-success-stamp" not in HOST_STATUS_COMMAND
|
||||
|
||||
|
||||
class TestBusyCheck:
|
||||
"""Whether a package manager is working right now, run through ``sh`` with a
|
||||
stand-in ``ps``. On Ubuntu, unattended-upgrades keeps
|
||||
``unattended-upgrade-shutdown --wait-for-signal`` running all the time; its
|
||||
process name is cut to 15 characters, ``unattended-upgr``, the same as a real
|
||||
run's. Taken for a run, it made every such host's dpkg state unknown -- aris
|
||||
among them (NetOrk/netork#738)."""
|
||||
|
||||
UBUNTU_IDLE = [
|
||||
("systemd", "/sbin/init"),
|
||||
("unattended-upgr", "/usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal"),
|
||||
("sshd", "sshd: netork [priv]"),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _busy(tmp_path, processes) -> bool:
|
||||
comm = "\n".join(c for c, _ in processes)
|
||||
args = "\n".join(a for _, a in processes)
|
||||
ps = tmp_path / "ps"
|
||||
ps.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f"case \"$*\" in *comm=*) printf '%s\\n' '{comm}';; *) printf '%s\\n' '{args}';; esac\n"
|
||||
)
|
||||
ps.chmod(0o755)
|
||||
env = {"PATH": f"{tmp_path}:/usr/bin:/bin"}
|
||||
out = subprocess.run(["sh", "-c", BUSY_CHECK], capture_output=True, text=True, env=env)
|
||||
return "[dpkg-busy]" in out.stdout
|
||||
|
||||
def test_the_shutdown_helper_of_an_idle_ubuntu_host_is_no_run(self, tmp_path):
|
||||
assert self._busy(tmp_path, self.UBUNTU_IDLE) is False
|
||||
|
||||
def test_unattended_upgrade_running(self, tmp_path):
|
||||
run = ("unattended-upgr", "/usr/bin/python3 /usr/bin/unattended-upgrade")
|
||||
|
||||
assert self._busy(tmp_path, [*self.UBUNTU_IDLE, run]) is True
|
||||
|
||||
@pytest.mark.parametrize("name", ["dpkg", "apt", "apt-get", "aptitude"])
|
||||
def test_dpkg_and_apt(self, tmp_path, name):
|
||||
assert self._busy(tmp_path, [*self.UBUNTU_IDLE, (name, f"/usr/bin/{name} upgrade")]) is True
|
||||
|
||||
def test_it_is_part_of_the_command(self):
|
||||
assert BUSY_CHECK in HOST_STATUS_COMMAND
|
||||
|
||||
def test_the_command_does_not_match_itself(self):
|
||||
"""Run over an exec channel, the command is the command line of the shell
|
||||
that runs it, and ``ps -o args`` lists it."""
|
||||
assert not re.search(r"(^|[ /])unattended-upgrade( |$)", HOST_STATUS_COMMAND)
|
||||
|
||||
|
||||
class TestFilesystems:
|
||||
"""How full the filesystems an upgrade writes to are: ``/``, ``/boot`` and
|
||||
``/var``, read with ``df -Pk`` and no root.
|
||||
|
||||
pve-dual's root filesystem stood at 94 % with about 400 MB free for a week;
|
||||
a kernel upgrade failed on it with "No space left on device" and left dpkg
|
||||
halfway, and nothing had said the disk was full (NetOrk/netork#862). The
|
||||
figures below are shaped after it (6.5 GiB, 400 MiB available): ext4 keeps
|
||||
5 % for root, so what is available is less than total minus used.
|
||||
"""
|
||||
|
||||
HEADER = "Filesystem 1024-blocks Used Available Capacity Mounted on"
|
||||
PVE_ROOT = "/dev/mapper/pve-root 6815744 6065357 409600 94% /"
|
||||
BOOT = "/dev/sda2 1046512 104860 941652 11% /boot"
|
||||
|
||||
@staticmethod
|
||||
def _report(*df_lines: str) -> str:
|
||||
return _wire().replace("[timers]", "\n".join(["[filesystems]", *df_lines, "[timers]"]))
|
||||
|
||||
def test_the_root_filesystem_in_bytes(self):
|
||||
status = parse_host_status(self._report(self.HEADER, self.PVE_ROOT))
|
||||
|
||||
assert status["filesystems"] == [
|
||||
{
|
||||
"mountpoint": "/",
|
||||
"total": 6815744 * 1024,
|
||||
"used": 6065357 * 1024,
|
||||
"available": 409600 * 1024,
|
||||
}
|
||||
]
|
||||
|
||||
def test_a_separate_boot_partition(self):
|
||||
status = parse_host_status(self._report(self.HEADER, self.PVE_ROOT, self.BOOT))
|
||||
|
||||
assert [fs["mountpoint"] for fs in status["filesystems"]] == ["/", "/boot"]
|
||||
|
||||
def test_a_directory_on_root_is_root_once(self):
|
||||
"""``df /boot /var`` names the filesystem they live on, here ``/`` three times."""
|
||||
status = parse_host_status(
|
||||
self._report(self.HEADER, self.PVE_ROOT, self.PVE_ROOT, self.PVE_ROOT)
|
||||
)
|
||||
|
||||
assert [fs["mountpoint"] for fs in status["filesystems"]] == ["/"]
|
||||
|
||||
def test_a_long_device_name_and_a_blank_in_the_mountpoint(self):
|
||||
line = "/dev/disk/by-id/a very long name 1000 600 400 60% /mnt/my disk"
|
||||
|
||||
(fs,) = parse_host_status(self._report(self.HEADER, line))["filesystems"]
|
||||
|
||||
assert (fs["mountpoint"], fs["available"]) == ("/mnt/my disk", 400 * 1024)
|
||||
|
||||
def test_a_filesystem_without_size_is_left_out(self):
|
||||
"""What a pseudo filesystem reports; it cannot run full."""
|
||||
status = parse_host_status(self._report(self.HEADER, "none 0 0 0 - /"))
|
||||
|
||||
assert status["filesystems"] is None
|
||||
|
||||
def test_without_the_section_it_is_unknown(self):
|
||||
"""An older command, or no df: None, not "nothing is full"."""
|
||||
assert parse_host_status(_wire())["filesystems"] is None
|
||||
|
||||
def test_df_that_printed_nothing_is_unknown(self):
|
||||
assert parse_host_status(self._report())["filesystems"] is None
|
||||
|
||||
def test_the_command_asks_for_the_three_without_root(self):
|
||||
assert "df -Pk / /boot /var" in HOST_STATUS_COMMAND
|
||||
assert "sudo" not in HOST_STATUS_COMMAND
|
||||
|
||||
def test_the_command_reads_them_on_a_real_shell(self):
|
||||
"""POSIX df the way a host's sh runs it: ``/`` is always there, whatever it is."""
|
||||
out = subprocess.run(
|
||||
["sh", "-c", HOST_STATUS_COMMAND], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
|
||||
mountpoints = [fs["mountpoint"] for fs in parse_host_status(out.stdout)["filesystems"]]
|
||||
assert "/" in mountpoints
|
||||
@@ -0,0 +1,303 @@
|
||||
"""OpenSSH server hardening: removing algorithms a scanner flagged, without locking anyone out.
|
||||
|
||||
The plan is pure and tested on its own. The mixin is tested against a fake host
|
||||
that keeps files and answers ``sshd -T`` from them, so every path -- the
|
||||
drop-in, the sshd_config fallback, a refused config, a reload the new session
|
||||
cannot get through -- runs without a machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types.channel import CommandResult
|
||||
from napalm_device_types.ssh_server import (
|
||||
DROPIN_PATH,
|
||||
MAIN_CONFIG,
|
||||
OpenSshServerMixin,
|
||||
has_dropin_include,
|
||||
parse_sshd_effective,
|
||||
plan_ssh_hardening,
|
||||
render_dropin,
|
||||
render_main_config,
|
||||
)
|
||||
|
||||
SSHD_T = """\
|
||||
port 22
|
||||
kexalgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,diffie-hellman-group14-sha256
|
||||
hostkeyalgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256
|
||||
ciphers chacha20-poly1305@openssh.com,aes128-ctr,aes256-gcm@openssh.com
|
||||
macs umac-64-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha1,hmac-sha2-256
|
||||
passwordauthentication no
|
||||
"""
|
||||
|
||||
PARAMIKO = {
|
||||
"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "diffie-hellman-group14-sha256"],
|
||||
"hostkey": ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512"],
|
||||
"cipher": ["aes128-ctr", "aes256-gcm@openssh.com"],
|
||||
"mac": ["hmac-sha2-256", "hmac-sha2-256-etm@openssh.com", "hmac-sha1"],
|
||||
}
|
||||
|
||||
|
||||
# ── parse ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_reads_the_four_lists_in_server_order():
|
||||
eff = parse_sshd_effective(SSHD_T)
|
||||
assert eff["kex"][:2] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"]
|
||||
assert eff["hostkey"] == ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256"]
|
||||
assert eff["cipher"] == ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes256-gcm@openssh.com"]
|
||||
assert eff["mac"][-1] == "hmac-sha2-256"
|
||||
|
||||
|
||||
def test_parse_refuses_output_without_the_lists():
|
||||
with pytest.raises(ValueError):
|
||||
parse_sshd_effective("port 22\n")
|
||||
|
||||
|
||||
# ── plan ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_plan_removes_what_was_asked_and_keeps_the_order():
|
||||
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"mac": ["hmac-sha1", "umac-64-etm@openssh.com"]})
|
||||
assert plan.remove == {"mac": ["umac-64-etm@openssh.com", "hmac-sha1"]}
|
||||
assert plan.keep["mac"] == ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-256"]
|
||||
assert plan.changed_classes == ["mac"]
|
||||
assert plan.refused == []
|
||||
|
||||
|
||||
def test_plan_ignores_an_algorithm_the_server_no_longer_offers():
|
||||
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"cipher": ["3des-cbc"]})
|
||||
assert plan.changed_classes == []
|
||||
assert [r.algorithm for r in plan.not_offered] == ["3des-cbc"]
|
||||
|
||||
|
||||
def test_plan_never_removes_the_last_algorithm_of_a_class():
|
||||
plan = plan_ssh_hardening(
|
||||
parse_sshd_effective(SSHD_T),
|
||||
{"cipher": ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes256-gcm@openssh.com"]},
|
||||
)
|
||||
# The first in the server's order stays, the rest go.
|
||||
assert plan.keep["cipher"] == ["chacha20-poly1305@openssh.com"]
|
||||
assert plan.remove["cipher"] == ["aes128-ctr", "aes256-gcm@openssh.com"]
|
||||
assert [(r.kind, r.algorithm, r.reason) for r in plan.refused] == [
|
||||
("cipher", "chacha20-poly1305@openssh.com", "last")
|
||||
]
|
||||
|
||||
|
||||
def test_plan_keeps_one_algorithm_the_managing_client_still_speaks():
|
||||
"""paramiko knows no sntrup or plain curve25519: removing everything it does
|
||||
know would leave the server reachable, but not by netOrk."""
|
||||
plan = plan_ssh_hardening(
|
||||
parse_sshd_effective(SSHD_T),
|
||||
{"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384",
|
||||
"diffie-hellman-group14-sha256"]},
|
||||
client=PARAMIKO,
|
||||
)
|
||||
assert "curve25519-sha256@libssh.org" in plan.keep["kex"]
|
||||
assert [(r.algorithm, r.reason) for r in plan.refused] == [("curve25519-sha256@libssh.org", "client")]
|
||||
assert plan.remove["kex"] == ["ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "diffie-hellman-group14-sha256"]
|
||||
|
||||
|
||||
def test_plan_without_a_client_list_only_guards_the_last_algorithm():
|
||||
plan = plan_ssh_hardening(
|
||||
parse_sshd_effective(SSHD_T),
|
||||
{"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384",
|
||||
"diffie-hellman-group14-sha256"]},
|
||||
)
|
||||
assert plan.refused == []
|
||||
assert plan.keep["kex"] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"]
|
||||
|
||||
|
||||
def test_plan_rejects_an_unknown_class():
|
||||
with pytest.raises(ValueError):
|
||||
plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"compression": ["zlib"]})
|
||||
|
||||
|
||||
# ── render ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dropin_states_full_lists_for_the_changed_classes_only():
|
||||
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"mac": ["hmac-sha1"]})
|
||||
text = render_dropin(plan.keep, ["mac"])
|
||||
assert "MACs umac-64-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-256\n" in text
|
||||
assert "KexAlgorithms" not in text
|
||||
assert text.startswith("#") # says who wrote it
|
||||
|
||||
|
||||
def test_include_is_found_only_in_the_global_section():
|
||||
assert has_dropin_include("Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n")
|
||||
assert has_dropin_include(" include /etc/ssh/sshd_config.d/*.conf\n")
|
||||
assert not has_dropin_include("Port 22\n")
|
||||
assert not has_dropin_include("Port 22\nMatch User x\n Include /etc/ssh/sshd_config.d/*.conf\n")
|
||||
|
||||
|
||||
def test_main_config_gets_the_lists_first_and_the_old_lines_disabled():
|
||||
main = "# defaults\nPort 22\nMACs hmac-sha1,hmac-sha2-256\nMatch User backup\n MACs hmac-sha1\n"
|
||||
text = render_main_config(main, {"mac": ["hmac-sha2-256"]}, ["mac"])
|
||||
lines = text.splitlines()
|
||||
first_directive = next(line for line in lines if line and not line.startswith("#"))
|
||||
assert first_directive == "MACs hmac-sha2-256"
|
||||
assert "# netork: MACs hmac-sha1,hmac-sha2-256" in lines
|
||||
# A Match block is left alone: it is not the global setting.
|
||||
assert " MACs hmac-sha1" in lines
|
||||
|
||||
|
||||
def test_main_config_rewrite_is_idempotent():
|
||||
main = "Port 22\nMACs hmac-sha1,hmac-sha2-256\n"
|
||||
once = render_main_config(main, {"mac": ["hmac-sha2-256"]}, ["mac"])
|
||||
twice = render_main_config(once, {"mac": ["hmac-sha2-256"]}, ["mac"])
|
||||
assert once == twice
|
||||
|
||||
|
||||
# ── the mixin, against a fake host ───────────────────────────────────────
|
||||
|
||||
_DIRECTIVE = {"kexalgorithms": "kex", "hostkeyalgorithms": "hostkey", "ciphers": "cipher", "macs": "mac"}
|
||||
|
||||
|
||||
class FakeHost(OpenSshServerMixin):
|
||||
"""A host with files, an sshd that reads them first-match-wins, and a log of what ran."""
|
||||
|
||||
def __init__(self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n") -> None:
|
||||
self.files: Dict[str, str] = {MAIN_CONFIG: main}
|
||||
self.defaults = parse_sshd_effective(SSHD_T)
|
||||
self.ran: List[str] = []
|
||||
self.syntax_ok = True
|
||||
self.reloads = 0
|
||||
|
||||
def _effective(self) -> Dict[str, List[str]]:
|
||||
eff = {k: list(v) for k, v in self.defaults.items()}
|
||||
seen = set()
|
||||
lines: List[str] = []
|
||||
for line in self.files[MAIN_CONFIG].splitlines():
|
||||
if re.match(r"\s*include\s", line, re.I):
|
||||
lines += self.files.get(DROPIN_PATH, "").splitlines()
|
||||
elif re.match(r"\s*match\s", line, re.I):
|
||||
break
|
||||
else:
|
||||
lines.append(line)
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[0].lower() in _DIRECTIVE:
|
||||
kind = _DIRECTIVE[parts[0].lower()]
|
||||
if kind not in seen:
|
||||
seen.add(kind)
|
||||
eff[kind] = parts[1].split(",")
|
||||
return eff
|
||||
|
||||
def run_command(self, command: str, *, privileged: bool = False, timeout: float = 60,
|
||||
stdin: Optional[bytes] = None) -> CommandResult:
|
||||
self.ran.append(command)
|
||||
assert privileged, command
|
||||
if command.startswith("sshd -T") or "sshd -T" in command:
|
||||
eff = self._effective()
|
||||
out = "".join(f"{d} {','.join(eff[k])}\n" for d, k in _DIRECTIVE.items())
|
||||
return CommandResult(out, "", 0)
|
||||
if "sshd -t" in command:
|
||||
return CommandResult("", "" if self.syntax_ok else "bad line 3", 0 if self.syntax_ok else 255)
|
||||
m = re.match(r"cat -- '?([^' ]+)'?$", command)
|
||||
if m:
|
||||
path = m.group(1)
|
||||
if path in self.files:
|
||||
return CommandResult(self.files[path], "", 0)
|
||||
return CommandResult("", "No such file", 1)
|
||||
m = re.search(r"mv -f -- \"\$tmp\" '?([^' ]+)'?", command)
|
||||
if m:
|
||||
self.files[m.group(1)] = (stdin or b"").decode()
|
||||
return CommandResult("", "", 0)
|
||||
m = re.match(r"rm -f -- '?([^' ]+)'?$", command)
|
||||
if m:
|
||||
self.files.pop(m.group(1), None)
|
||||
return CommandResult("", "", 0)
|
||||
if "reload" in command or "HUP" in command:
|
||||
self.reloads += 1
|
||||
return CommandResult("", "", 0)
|
||||
if command.startswith("mkdir"):
|
||||
return CommandResult("", "", 0)
|
||||
raise AssertionError(f"unexpected command: {command}")
|
||||
|
||||
|
||||
def test_mixin_writes_the_dropin_checks_it_and_reloads():
|
||||
host = FakeHost()
|
||||
lines: List[str] = []
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]}, client=PARAMIKO, log=lines.append)
|
||||
assert result.applied and not result.rolled_back
|
||||
assert result.path == DROPIN_PATH
|
||||
assert "hmac-sha1" not in host._effective()["mac"]
|
||||
assert host.reloads == 1
|
||||
assert any("sshd -t" in c for c in host.ran)
|
||||
assert lines # it says what it does
|
||||
|
||||
|
||||
def test_mixin_keeps_what_an_earlier_fix_wrote():
|
||||
host = FakeHost()
|
||||
host.harden_ssh_server({"hostkey": ["ecdsa-sha2-nistp256"]})
|
||||
host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
eff = host._effective()
|
||||
assert "ecdsa-sha2-nistp256" not in eff["hostkey"]
|
||||
assert "hmac-sha1" not in eff["mac"]
|
||||
|
||||
|
||||
def test_mixin_falls_back_to_sshd_config_without_an_include():
|
||||
host = FakeHost(main="Port 22\nMACs hmac-sha1,hmac-sha2-256\n")
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert result.path == MAIN_CONFIG
|
||||
assert result.backup and host.files[result.backup] == "Port 22\nMACs hmac-sha1,hmac-sha2-256\n"
|
||||
assert host._effective()["mac"] == ["hmac-sha2-256"]
|
||||
|
||||
|
||||
def test_mixin_restores_when_sshd_rejects_the_config():
|
||||
host = FakeHost()
|
||||
host.syntax_ok = False
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert not result.applied and result.rolled_back
|
||||
assert DROPIN_PATH not in host.files
|
||||
assert host.reloads == 0
|
||||
assert "bad line 3" in result.message
|
||||
|
||||
|
||||
def test_mixin_restores_when_the_effective_lists_do_not_change():
|
||||
"""Another file read before ours sets the list: ours is valid and ignored."""
|
||||
host = FakeHost(main="MACs hmac-sha1,hmac-sha2-256\nInclude /etc/ssh/sshd_config.d/*.conf\n")
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert not result.applied and result.rolled_back
|
||||
assert DROPIN_PATH not in host.files
|
||||
assert "MACs" in result.message
|
||||
|
||||
|
||||
def test_mixin_rolls_back_when_a_new_session_cannot_get_in():
|
||||
host = FakeHost()
|
||||
host.files[DROPIN_PATH] = "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"
|
||||
before = host.files[DROPIN_PATH]
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]}, verify=lambda: False)
|
||||
assert not result.applied and result.rolled_back
|
||||
assert host.files[DROPIN_PATH] == before
|
||||
assert host.reloads == 2 # the change, and the way back
|
||||
|
||||
|
||||
def test_mixin_does_nothing_when_nothing_would_change():
|
||||
host = FakeHost()
|
||||
result = host.harden_ssh_server({"cipher": ["3des-cbc"]})
|
||||
assert not result.applied and not result.rolled_back
|
||||
assert host.reloads == 0
|
||||
assert DROPIN_PATH not in host.files
|
||||
|
||||
|
||||
def test_declared_on_no_base_class():
|
||||
"""hasattr is the capability probe: only a driver that mixes it in answers True."""
|
||||
from napalm_device_types import DeviceTypeDriver, OSDriver
|
||||
|
||||
assert not hasattr(DeviceTypeDriver, "harden_ssh_server")
|
||||
assert not hasattr(OSDriver, "harden_ssh_server")
|
||||
|
||||
|
||||
def test_main_config_second_fix_keeps_what_the_first_set():
|
||||
host = FakeHost(main="Port 22\n")
|
||||
host.harden_ssh_server({"hostkey": ["ecdsa-sha2-nistp256"]})
|
||||
host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
eff = host._effective()
|
||||
assert "ecdsa-sha2-nistp256" not in eff["hostkey"]
|
||||
assert "hmac-sha1" not in eff["mac"]
|
||||
@@ -0,0 +1,617 @@
|
||||
"""get_virtual_ips: the addresses a host's HA configuration lets float between machines.
|
||||
|
||||
A virtual IP is not visible from the address list alone: vip-manager and
|
||||
keepalived put it on whichever node is master *right now*, and a standby has no
|
||||
trace of it in ``ip addr``. What every member has is the configuration that
|
||||
declares it. Reading that is the same on every Linux host, so the command and
|
||||
its parse live here once, and a driver only carries the command across
|
||||
(netOrk #829).
|
||||
|
||||
The configuration also holds secrets -- vip-manager's etcd password, keepalived's
|
||||
``auth_pass`` -- so the command filters on the host: only allowlisted fields
|
||||
ever leave it. The tests below run the command for real to hold it to that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import OSDriver
|
||||
from napalm_device_types.virtual_ips import (
|
||||
VIRTUAL_IPS_COMMAND,
|
||||
VirtualIpsMixin,
|
||||
parse_virtual_ips,
|
||||
)
|
||||
|
||||
SECRET = "s3cr3t-do-not-leak"
|
||||
|
||||
|
||||
def _wire(*sections: str, noise: str = "") -> str:
|
||||
"""The report as the command prints it, framed."""
|
||||
return f"{noise}VIPS_BEGIN\n{''.join(sections)}VIPS_END\n"
|
||||
|
||||
|
||||
def _section(mechanism: str, unit: str, *records: str) -> str:
|
||||
return f"[{mechanism} {unit}]\n" + "".join(f"{r}\n" for r in records)
|
||||
|
||||
|
||||
def _vip_manager(*records: str, unit: str = "vip-manager.service") -> str:
|
||||
return _section("vip-manager", unit, *records)
|
||||
|
||||
|
||||
def _keepalived(*records: str, unit: str = "keepalived.service") -> str:
|
||||
return _section("keepalived", unit, *records)
|
||||
|
||||
|
||||
DB01 = _vip_manager(
|
||||
"active active",
|
||||
"arg config /etc/default/vip-manager.yml",
|
||||
"cfg ip 10.7.224.10",
|
||||
"cfg netmask 24",
|
||||
"cfg interface ens7",
|
||||
'cfg trigger-key "/service/netork-db/leader"',
|
||||
)
|
||||
|
||||
|
||||
class TestVipManager:
|
||||
def test_the_5x_yaml_declares_the_address(self):
|
||||
reading = parse_virtual_ips(_wire(DB01))
|
||||
|
||||
assert reading == {
|
||||
"vip_manager": [
|
||||
{
|
||||
"mechanism": "vip-manager",
|
||||
"instance": "vip-manager",
|
||||
"address": "10.7.224.10",
|
||||
"prefix_length": 24,
|
||||
"interface": "ens7",
|
||||
"group_key": "/service/netork-db/leader",
|
||||
"running": True,
|
||||
}
|
||||
],
|
||||
"keepalived": [],
|
||||
}
|
||||
|
||||
def test_the_1x_unit_takes_its_values_from_the_environment_file(self):
|
||||
"""Ubuntu's 1.0.2 package: ``-ip=${VIP_IP}`` on the command line,
|
||||
the values in /etc/default/vip-manager."""
|
||||
section = _vip_manager(
|
||||
"active active",
|
||||
"arg ip ${VIP_IP}",
|
||||
"arg mask $VIP_MASK",
|
||||
"arg iface ${VIP_IFACE}",
|
||||
"arg key ${VIP_KEY}",
|
||||
"env VIP_IP 10.0.0.10",
|
||||
"env VIP_MASK 255.255.255.0",
|
||||
"env VIP_IFACE eth0",
|
||||
'env VIP_KEY "/service/pg/leader"',
|
||||
)
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert (entry["address"], entry["prefix_length"], entry["interface"]) == (
|
||||
"10.0.0.10",
|
||||
24,
|
||||
"eth0",
|
||||
)
|
||||
assert entry["group_key"] == "/service/pg/leader"
|
||||
|
||||
def test_a_flag_beats_the_environment_which_beats_the_config_file(self):
|
||||
section = _vip_manager(
|
||||
"active active",
|
||||
"arg ip 10.0.0.1",
|
||||
"env VIP_IP 10.0.0.2",
|
||||
"env VIP_NETMASK 16",
|
||||
"cfg ip 10.0.0.3",
|
||||
"cfg netmask 24",
|
||||
"cfg interface eth1",
|
||||
)
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert (entry["address"], entry["prefix_length"], entry["interface"]) == (
|
||||
"10.0.0.1",
|
||||
16,
|
||||
"eth1",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[
|
||||
("10.0.0.10 # the database", "10.0.0.10"),
|
||||
("'10.0.0.10'", "10.0.0.10"),
|
||||
('"fd00::10"', "fd00::10"),
|
||||
("FD00:0:0::10", "fd00::10"),
|
||||
],
|
||||
)
|
||||
def test_values_lose_quotes_and_comments(self, value, expected):
|
||||
section = _vip_manager("active active", f"cfg ip {value}")
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert entry["address"] == expected
|
||||
|
||||
def test_a_stopped_unit_still_declares_its_address(self):
|
||||
"""A standby whose vip-manager is down is still a member -- one that
|
||||
cannot take the address over, which is worth knowing."""
|
||||
section = _vip_manager("active inactive", "cfg ip 10.0.0.10")
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert entry["running"] is False
|
||||
assert entry["prefix_length"] is None
|
||||
|
||||
def test_a_unit_without_an_address_declares_nothing(self):
|
||||
section = _vip_manager("active active", "arg ip ${VIP_IP}", "cfg netmask 24")
|
||||
|
||||
assert parse_virtual_ips(_wire(section))["vip_manager"] == []
|
||||
|
||||
def test_a_template_unit_is_named_after_its_instance(self):
|
||||
section = _vip_manager(
|
||||
"active active", "cfg ip 10.0.0.10", unit="vip-manager@pg16.service"
|
||||
)
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert entry["instance"] == "vip-manager@pg16"
|
||||
|
||||
def test_an_unreadable_config_is_not_looked_at(self):
|
||||
"""None, not []: the reading must not say the address is gone."""
|
||||
section = _vip_manager("active active", "unreadable /etc/default/vip-manager.yml")
|
||||
|
||||
reading = parse_virtual_ips(_wire(section, _keepalived("active inactive")))
|
||||
|
||||
assert reading["vip_manager"] is None
|
||||
assert reading["keepalived"] == []
|
||||
|
||||
|
||||
class TestKeepalived:
|
||||
TWO_INSTANCES = _keepalived(
|
||||
"active active",
|
||||
"instance VI_DB",
|
||||
"interface eth0",
|
||||
"virtual_router_id 51",
|
||||
"vip 10.0.0.10/24 dev eth0",
|
||||
"vip 10.0.0.11",
|
||||
"end",
|
||||
"instance web",
|
||||
"vip fd00::80",
|
||||
"interface eth1",
|
||||
"end",
|
||||
)
|
||||
|
||||
def test_each_address_of_each_instance(self):
|
||||
reading = parse_virtual_ips(_wire(self.TWO_INSTANCES))
|
||||
|
||||
assert reading["vip_manager"] == []
|
||||
assert reading["keepalived"] == [
|
||||
{
|
||||
"mechanism": "keepalived",
|
||||
"instance": "VI_DB",
|
||||
"address": "10.0.0.10",
|
||||
"prefix_length": 24,
|
||||
"interface": "eth0",
|
||||
"group_key": "vrid:51",
|
||||
"running": True,
|
||||
},
|
||||
{
|
||||
"mechanism": "keepalived",
|
||||
"instance": "VI_DB",
|
||||
"address": "10.0.0.11",
|
||||
"prefix_length": 32,
|
||||
"interface": "eth0",
|
||||
"group_key": "vrid:51",
|
||||
"running": True,
|
||||
},
|
||||
{
|
||||
"mechanism": "keepalived",
|
||||
"instance": "web",
|
||||
"address": "fd00::80",
|
||||
"prefix_length": 128,
|
||||
"interface": "eth1",
|
||||
"group_key": None,
|
||||
"running": True,
|
||||
},
|
||||
]
|
||||
|
||||
def test_dev_beats_the_instance_interface(self):
|
||||
section = _keepalived(
|
||||
"active active", "instance a", "interface eth0", "vip 10.0.0.10/24 dev eth9", "end"
|
||||
)
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["keepalived"]
|
||||
|
||||
assert entry["interface"] == "eth9"
|
||||
|
||||
@pytest.mark.parametrize("line", ["vip $VIP", "vip @node1", "vip not-an-address"])
|
||||
def test_what_is_no_address_is_skipped(self, line):
|
||||
"""keepalived's ``$VAR`` substitution and ``@host`` conditionals are
|
||||
not resolved here; an entry that is no address is no entry."""
|
||||
section = _keepalived("active active", "instance a", line, "vip 10.0.0.12", "end")
|
||||
|
||||
assert [e["address"] for e in parse_virtual_ips(_wire(section))["keepalived"]] == [
|
||||
"10.0.0.12"
|
||||
]
|
||||
|
||||
def test_an_instance_cut_off_by_the_end_of_the_file_still_counts(self):
|
||||
section = _keepalived("active active", "instance a", "vip 10.0.0.10")
|
||||
|
||||
assert len(parse_virtual_ips(_wire(section))["keepalived"]) == 1
|
||||
|
||||
def test_without_a_config_file_it_declares_nothing(self):
|
||||
section = _keepalived("active inactive", "missing /etc/keepalived/keepalived.conf")
|
||||
|
||||
assert parse_virtual_ips(_wire(section))["keepalived"] == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"record", ["unreadable /etc/keepalived/conf.d/x.conf", "too-deep /etc/keepalived/a.conf"]
|
||||
)
|
||||
def test_an_include_it_could_not_follow_is_not_looked_at(self, record):
|
||||
section = _keepalived("active active", "instance a", "vip 10.0.0.10", "end", record)
|
||||
|
||||
assert parse_virtual_ips(_wire(section))["keepalived"] is None
|
||||
|
||||
|
||||
class TestTheReport:
|
||||
def test_no_units_is_looked_and_found_nothing(self):
|
||||
assert parse_virtual_ips(_wire("[systemd]\n")) == {"vip_manager": [], "keepalived": []}
|
||||
|
||||
def test_a_host_without_systemd_is_not_looked_at(self):
|
||||
assert parse_virtual_ips(_wire("[no-systemd]\n")) == {
|
||||
"vip_manager": None,
|
||||
"keepalived": None,
|
||||
}
|
||||
|
||||
def test_noise_before_the_report_is_ignored(self):
|
||||
reading = parse_virtual_ips(_wire(DB01, noise="$ sh -c '...'\nWelcome to Ubuntu\n"))
|
||||
|
||||
assert reading["vip_manager"][0]["address"] == "10.7.224.10"
|
||||
|
||||
def test_a_report_cut_short_raises_without_quoting_it(self):
|
||||
"""The output may carry configuration; an error message must not."""
|
||||
with pytest.raises(ValueError) as caught:
|
||||
parse_virtual_ips(f"VIPS_BEGIN\n{DB01}")
|
||||
|
||||
assert "10.7.224.10" not in str(caught.value)
|
||||
|
||||
def test_entries_are_sorted(self):
|
||||
reading = parse_virtual_ips(
|
||||
_wire(
|
||||
_vip_manager("active active", "cfg ip 10.0.0.20", unit="vip-manager@b.service"),
|
||||
_vip_manager("active active", "cfg ip 10.0.0.3", unit="vip-manager@a.service"),
|
||||
)
|
||||
)
|
||||
|
||||
assert [e["address"] for e in reading["vip_manager"]] == ["10.0.0.3", "10.0.0.20"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The command itself, run against a stub systemctl and real files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DB01_YAML = f"""# managed by hand, see infrastructure/docs/DATABASE_CLUSTER.md
|
||||
ip: 10.7.224.10
|
||||
netmask: 24
|
||||
interface: ens7
|
||||
trigger-key: "/service/netork-db/leader"
|
||||
trigger-value: "db-01"
|
||||
dcs-type: etcd
|
||||
dcs-endpoints:
|
||||
- http://10.7.234.12:2379
|
||||
etcd-user: patroni
|
||||
etcd-password: {SECRET}
|
||||
"""
|
||||
|
||||
KEEPALIVED_CONF = f"""! Configuration File for keepalived
|
||||
global_defs {{
|
||||
notification_email {{ ops@example.org }}
|
||||
router_id LVS_{SECRET}
|
||||
}}
|
||||
|
||||
vrrp_script chk_haproxy {{
|
||||
script "/usr/local/bin/check {SECRET}"
|
||||
}}
|
||||
|
||||
vrrp_instance VI_WEB {{
|
||||
state MASTER
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 101
|
||||
authentication {{
|
||||
auth_type PASS
|
||||
auth_pass {SECRET}
|
||||
}}
|
||||
virtual_ipaddress {{
|
||||
192.0.2.10/24 dev eth0 label eth0:1
|
||||
}}
|
||||
notify_master "/etc/keepalived/master.sh {SECRET}"
|
||||
}}
|
||||
|
||||
include conf.d/*.conf
|
||||
"""
|
||||
|
||||
INCLUDED_CONF = """vrrp_instance VI_DB { interface eth1
|
||||
virtual_router_id 52
|
||||
virtual_ipaddress { 192.0.2.20 }
|
||||
virtual_ipaddress_excluded {
|
||||
2001:db8::20/64
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _awks() -> list:
|
||||
found = []
|
||||
if shutil.which("mawk"):
|
||||
found.append(pytest.param(["mawk"], id="mawk"))
|
||||
if shutil.which("busybox"):
|
||||
found.append(pytest.param(["busybox", "awk"], id="busybox"))
|
||||
if shutil.which("gawk"):
|
||||
found.append(pytest.param(["gawk"], id="gawk"))
|
||||
return found or [pytest.param(None, marks=pytest.mark.skip(reason="no awk to run"))]
|
||||
|
||||
|
||||
class _Host:
|
||||
"""A host as far as the command can tell: units, their properties, files."""
|
||||
|
||||
def __init__(self, root: Path, awk: list) -> None:
|
||||
self.root = root
|
||||
self.bin = root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.state = root / "systemd"
|
||||
self.state.mkdir()
|
||||
(self.state / "units").write_text("")
|
||||
stub = self.bin / "systemctl"
|
||||
stub.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f"d={self.state}\n"
|
||||
'case "$1" in\n'
|
||||
f' list-units) {shutil.which("cat")} "$d/units" ;;\n'
|
||||
f' show) for last; do :; done; {shutil.which("cat")} "$d/show-$last" ;;\n'
|
||||
"esac\n"
|
||||
)
|
||||
stub.chmod(0o755)
|
||||
wrapper = self.bin / "awk"
|
||||
program = " ".join([shutil.which(awk[0]) or awk[0], *awk[1:]])
|
||||
wrapper.write_text(f'#!/bin/sh\nexec {program} "$@"\n')
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
def unit(self, name: str, *properties: str) -> None:
|
||||
with (self.state / "units").open("a") as units:
|
||||
units.write(f"{name} loaded active running {name}\n")
|
||||
(self.state / f"show-{name}").write_text("".join(f"{p}\n" for p in properties))
|
||||
|
||||
def file(self, relative: str, content: str) -> Path:
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
def run(self, *, systemctl: bool = True) -> str:
|
||||
if not systemctl:
|
||||
(self.bin / "systemctl").unlink()
|
||||
return subprocess.run(
|
||||
["/bin/sh", "-c", VIRTUAL_IPS_COMMAND],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={"PATH": str(self.bin)},
|
||||
timeout=30,
|
||||
).stdout
|
||||
|
||||
|
||||
def _exec_start(*argv: str) -> str:
|
||||
return (
|
||||
f"ExecStart={{ path={argv[0]} ; argv[]={' '.join(argv)} ; ignore_errors=no ; "
|
||||
"start_time=[Mon 2026-10-05 06:50:00 UTC] ; stop_time=[n/a] ; pid=758 ; "
|
||||
"code=(null) ; status=0/0 }"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(params=_awks())
|
||||
def host(request, tmp_path) -> _Host:
|
||||
return _Host(tmp_path, request.param)
|
||||
|
||||
|
||||
class TestTheCommandOnAHost:
|
||||
def test_vip_manager_5x_as_on_db_01(self, host):
|
||||
config = host.file("etc/default/vip-manager.yml", DB01_YAML)
|
||||
host.unit(
|
||||
"vip-manager.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/bin/vip-manager", f"--config={config}"),
|
||||
f"Environment=VIP_ETCD_PASSWORD={SECRET} VIP_INTERFACE=ens7",
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert SECRET not in out
|
||||
assert "10.7.234.12" not in out # the etcd endpoints stay on the host
|
||||
assert parse_virtual_ips(out)["vip_manager"] == [
|
||||
{
|
||||
"mechanism": "vip-manager",
|
||||
"instance": "vip-manager",
|
||||
"address": "10.7.224.10",
|
||||
"prefix_length": 24,
|
||||
"interface": "ens7",
|
||||
"group_key": "/service/netork-db/leader",
|
||||
"running": True,
|
||||
}
|
||||
]
|
||||
|
||||
def test_vip_manager_1x_with_its_environment_file(self, host):
|
||||
envfile = host.file(
|
||||
"etc/default/vip-manager",
|
||||
f'VIP_IP="10.0.0.10"\nexport VIP_MASK=24\nVIP_IFACE=eth0\nVIP_KEY=/service/pg/leader\n'
|
||||
f"VIP_ETCD_PASSWORD={SECRET}\n",
|
||||
)
|
||||
host.unit(
|
||||
"vip-manager.service",
|
||||
"ActiveState=active",
|
||||
_exec_start(
|
||||
"/usr/bin/vip-manager",
|
||||
"-ip=${VIP_IP}",
|
||||
"-mask=${VIP_MASK}",
|
||||
"-iface=${VIP_IFACE}",
|
||||
"-key=${VIP_KEY}",
|
||||
"-etcd_password",
|
||||
SECRET,
|
||||
),
|
||||
f"EnvironmentFiles={envfile} (ignore_errors=yes)",
|
||||
f"EnvironmentFiles={host.root}/etc/default/missing (ignore_errors=yes)",
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert SECRET not in out
|
||||
[entry] = parse_virtual_ips(out)["vip_manager"]
|
||||
assert (entry["address"], entry["prefix_length"], entry["interface"]) == (
|
||||
"10.0.0.10",
|
||||
24,
|
||||
"eth0",
|
||||
)
|
||||
|
||||
def test_keepalived_with_includes(self, host):
|
||||
config = host.file("etc/keepalived/keepalived.conf", KEEPALIVED_CONF)
|
||||
host.file("etc/keepalived/conf.d/db.conf", INCLUDED_CONF)
|
||||
host.unit(
|
||||
"keepalived.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/sbin/keepalived", "--dont-fork", "-f", str(config)),
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert SECRET not in out
|
||||
assert "MASTER" not in out and "priority" not in out
|
||||
reading = parse_virtual_ips(out)
|
||||
assert [(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
|
||||
for e in reading["keepalived"]] == [
|
||||
("VI_WEB", "192.0.2.10", 24, "eth0", "vrid:51"),
|
||||
("VI_DB", "192.0.2.20", 32, "eth1", "vrid:52"),
|
||||
("VI_DB", "2001:db8::20", 64, "eth1", "vrid:52"),
|
||||
]
|
||||
assert reading["vip_manager"] == []
|
||||
|
||||
def test_an_include_that_includes_itself_is_not_followed_forever(self, host):
|
||||
config = host.file(
|
||||
"etc/keepalived/keepalived.conf",
|
||||
"vrrp_instance a {\n virtual_ipaddress {\n 192.0.2.30\n }\n}\n"
|
||||
"include keepalived.conf\n",
|
||||
)
|
||||
host.unit(
|
||||
"keepalived.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/sbin/keepalived", f"--use-file={config}"),
|
||||
)
|
||||
|
||||
assert parse_virtual_ips(host.run())["keepalived"] is None
|
||||
|
||||
def test_an_include_pattern_is_never_run_as_shell(self, host):
|
||||
"""The include line comes from a file and the command runs as root."""
|
||||
marker = host.root / "PWNED"
|
||||
config = host.file(
|
||||
"etc/keepalived/keepalived.conf",
|
||||
f"include /etc/x.conf;touch${{IFS}}{marker}\ninclude $(touch {marker})\n",
|
||||
)
|
||||
host.unit(
|
||||
"keepalived.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/sbin/keepalived", "-f", str(config)),
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert not marker.exists()
|
||||
assert parse_virtual_ips(out)["keepalived"] is None
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() == 0, reason="root reads every file")
|
||||
def test_a_config_it_may_not_read_is_reported_as_such(self, host):
|
||||
config = host.file("etc/default/vip-manager.yml", DB01_YAML)
|
||||
config.chmod(0o000)
|
||||
host.unit(
|
||||
"vip-manager.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/bin/vip-manager", "--config", str(config)),
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert parse_virtual_ips(out)["vip_manager"] is None
|
||||
|
||||
def test_no_units_at_all(self, host):
|
||||
assert parse_virtual_ips(host.run()) == {"vip_manager": [], "keepalived": []}
|
||||
|
||||
def test_a_host_without_systemd(self, host):
|
||||
assert parse_virtual_ips(host.run(systemctl=False)) == {
|
||||
"vip_manager": None,
|
||||
"keepalived": None,
|
||||
}
|
||||
|
||||
|
||||
class TestTheCommand:
|
||||
def test_the_frame_is_not_in_the_command_itself(self):
|
||||
assert "VIPS_BEGIN" not in VIRTUAL_IPS_COMMAND
|
||||
assert "VIPS_END" not in VIRTUAL_IPS_COMMAND
|
||||
|
||||
def test_it_writes_and_changes_nothing(self):
|
||||
for verb in ("sudo", " > ", ">>", "kill", "rm ", "start", "stop", "restart", "reload"):
|
||||
assert verb not in VIRTUAL_IPS_COMMAND
|
||||
|
||||
def test_it_is_posix_sh(self):
|
||||
result = subprocess.run(
|
||||
["sh", "-n", "-c", VIRTUAL_IPS_COMMAND], capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_it_is_one_line(self):
|
||||
"""About 4 kB with its awk program -- past the line a terminal takes, so a
|
||||
driver sends it on an exec channel. One argument there, one line."""
|
||||
driver = _Driver(_wire())
|
||||
driver.get_virtual_ips()
|
||||
|
||||
[(command, _privileged)] = driver.calls
|
||||
assert "\n" not in command
|
||||
|
||||
|
||||
class _Driver(VirtualIpsMixin):
|
||||
def __init__(self, privileged: str, unprivileged: str = "") -> None:
|
||||
self.answers = {True: privileged, False: unprivileged}
|
||||
self.calls: list = []
|
||||
|
||||
def _run_virtual_ips_command(self, command: str, *, privileged: bool) -> str:
|
||||
self.calls.append((command, privileged))
|
||||
return self.answers[privileged]
|
||||
|
||||
|
||||
class TestTheTemplate:
|
||||
def test_a_driver_supplies_only_the_transport(self):
|
||||
driver = _Driver(_wire(DB01))
|
||||
|
||||
reading = driver.get_virtual_ips()
|
||||
|
||||
assert reading["vip_manager"][0]["address"] == "10.7.224.10"
|
||||
[(command, privileged)] = driver.calls
|
||||
assert privileged is True
|
||||
assert command.startswith("sh -c '")
|
||||
assert subprocess.run(["sh", "-n", "-c", command]).returncode == 0
|
||||
|
||||
def test_without_root_it_reads_what_it_can(self):
|
||||
"""The config may be root's alone; what is readable is still worth having."""
|
||||
driver = _Driver(
|
||||
"sudo: a password is required\n",
|
||||
_wire(_vip_manager("active active", "unreadable /etc/default/vip-manager.yml")),
|
||||
)
|
||||
|
||||
reading = driver.get_virtual_ips()
|
||||
|
||||
assert reading["vip_manager"] is None
|
||||
assert [p for _, p in driver.calls] == [True, False]
|
||||
|
||||
def test_not_every_os_driver_has_it(self):
|
||||
assert not issubclass(OSDriver, VirtualIpsMixin)
|
||||
assert not hasattr(OSDriver, "get_virtual_ips")
|
||||
Reference in new issue
Block a user