feat!: settle the open review questions; release 4.7.0
## feat!: settle the open review questions on roles, diffs, SSH and SNMP - roles: a driver that sets ROLE itself replaces the role it inherits, so a Windows driver on OSDriver is "windows", not also "linux". - os: OSDriver's account listing is get_os_users; NAPALM's get_users has another shape and a runtime stub, so hasattr could not tell. - README: NAPALM's own getters must come from the driver or a mixin listed before the first role base; its stubs win the MRO otherwise. - firewall/DHCP diffs: an empty key, a key given twice, or one more than one live entry holds raises ValueError instead of overwriting by guess; live entries without a key are never matched. - ssh_server: only host key algorithms the host has a key for count as staying; when the keys cannot be read, the host key list is not touched. sshd_config and its copy keep their file mode (GNU and BSD stat), 644 only for a new file. - SNMP: 64-bit ifXTable octets and ifHighSpeed, with the 32-bit counters as fallback for agents without ifXTable. - pyproject: declare typing-extensions, which models.py imports. BREAKING CHANGE: OSDriver.get_users is now get_os_users; diffs raise ValueError on keys that cannot name one entry; plan_ssh_hardening removes no host key algorithm without host_keys. ## fix: tell "could not read" and "does not install" apart in update and VIP readers - package_updates: the dnf security reader records dnf's exit status and raises when it is missing or non-zero, as the apt reader does; a failed dnf no longer reads as "no security advisories". - host_status: an enabled dnf-automatic.timer counts as auto updates only with apply_updates = yes (dnf5's automatic.conf wins over dnf4's); dnf-automatic-install.timer always does. Unattended-upgrades counts only while the program is installed, not from a conffile left behind. - virtual_ips: a host with systemctl but without systemd as PID 1 reads as "did not look" (None), not as "has none". keepalived blocks whose brace is on the next line are read. BREAKING CHANGE: parse_dnf_security expects DNF_SECURITY_COMMAND's __DNF_RC marker and raises ValueError without it.
This commit is contained in:
1 parent
e509445572
commit
155b5bbbe4
21 files changed
+701
-74
No files matched your search
@@ -206,3 +206,25 @@ class TestApplyReservationSet:
|
||||
assert driver.commits == 0
|
||||
assert driver.applied == []
|
||||
assert lines == ["[commit] no changes"]
|
||||
|
||||
|
||||
class TestKeysThatCannotNameOneReservation:
|
||||
def test_a_live_reservation_without_mac_is_never_matched(self):
|
||||
driver = _FakeDhcpServer([_reservation(uuid="b", mac="")])
|
||||
|
||||
diff = driver.diff_dhcp_reservations([_reservation()])
|
||||
|
||||
assert diff == {"add": [_reservation()], "update": []}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"live, desired",
|
||||
[
|
||||
([], [_reservation(mac="")]),
|
||||
([], [_reservation(mac="AA-BB-CC-DD-EE-02"), _reservation(mac="aabb.ccdd.ee02")]),
|
||||
([_reservation(uuid="a"), _reservation(uuid="b")], [_reservation()]),
|
||||
],
|
||||
ids=["empty desired", "desired twice", "two live share it"],
|
||||
)
|
||||
def test_raises(self, live, desired):
|
||||
with pytest.raises(ValueError):
|
||||
_FakeDhcpServer(live).diff_dhcp_reservations(desired)
|
||||
@@ -226,3 +226,13 @@ def test_device_specific_methods_are_absent_until_implemented(method: str) -> No
|
||||
def test_calling_a_missing_device_method_fails_loudly() -> None:
|
||||
with pytest.raises(AttributeError):
|
||||
_BareDriver().get_dhcp_subnets()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"desired",
|
||||
[[_subnet(subnet="")], [_subnet(), _subnet(subnet=" 10.10.20.0/24 ")]],
|
||||
ids=["empty", "twice"],
|
||||
)
|
||||
def test_a_subnet_that_cannot_name_one_raises(desired):
|
||||
with pytest.raises(ValueError):
|
||||
_FakeDhcpServer([]).diff_dhcp_subnets(desired)
|
||||
@@ -169,3 +169,30 @@ class TestApplyFirewallRuleset:
|
||||
|
||||
assert driver.applied == []
|
||||
assert driver.committed is True
|
||||
|
||||
|
||||
class TestKeysThatCannotNameOneRule:
|
||||
"""An empty or shared description must never overwrite a rule by guess."""
|
||||
|
||||
def test_a_live_rule_without_description_is_never_matched(self):
|
||||
driver = _FakeFirewall([_rule(uuid="auto1", description="", action="block")])
|
||||
|
||||
diff = driver.diff_firewall_rules([_rule()])
|
||||
|
||||
assert diff == {"add": [_rule()], "update": []}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"live, desired",
|
||||
[
|
||||
([], [_rule(description="")]),
|
||||
([], [_rule(), _rule(action="block")]),
|
||||
([_rule(uuid="u1"), _rule(uuid="u2")], [_rule()]),
|
||||
],
|
||||
ids=["empty desired", "desired twice", "two live rules share it"],
|
||||
)
|
||||
def test_raises(self, live, desired):
|
||||
driver = _FakeFirewall(live)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
driver.diff_firewall_rules(desired)
|
||||
assert driver.applied == []
|
||||
@@ -11,6 +11,7 @@ The fixtures are the real states of six hosts on netOrk's test server.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
@@ -35,7 +36,9 @@ def _wire(
|
||||
periodic=None,
|
||||
stamp=None,
|
||||
timer="enabled",
|
||||
unattended=True,
|
||||
dnf_timers=("not-found", "not-found"),
|
||||
dnf_conf=(),
|
||||
dpkg=None,
|
||||
journal=(),
|
||||
busy=False,
|
||||
@@ -47,7 +50,10 @@ def _wire(
|
||||
lines += ["[needs-restarting]", str(needs_restarting)]
|
||||
lines += ["[kernel]", running, "[modules]", *modules]
|
||||
if periodic is not None:
|
||||
lines += ["[apt-config]", *periodic, "[upgrade-stamp]", *([str(stamp)] if stamp else [])]
|
||||
lines += ["[apt-config]", *periodic]
|
||||
if unattended:
|
||||
lines.append("[unattended-upgrade]")
|
||||
lines += ["[upgrade-stamp]", *([str(stamp)] if stamp else [])]
|
||||
if dpkg is not None:
|
||||
if busy:
|
||||
lines.append("[dpkg-busy]")
|
||||
@@ -57,6 +63,8 @@ def _wire(
|
||||
f"apt-daily-upgrade.timer {timer}",
|
||||
f"dnf-automatic.timer {dnf_timers[0]}",
|
||||
f"dnf-automatic-install.timer {dnf_timers[1]}",
|
||||
"[dnf-automatic]",
|
||||
*dnf_conf,
|
||||
"HSTAT_END",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -162,11 +170,57 @@ class TestAutoUpdates:
|
||||
|
||||
assert status["auto_updates"] is False
|
||||
|
||||
def test_dnf_automatic(self):
|
||||
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found")))
|
||||
def test_removed_but_not_purged_does_not_patch_itself(self):
|
||||
"""``apt remove unattended-upgrades`` leaves its conffile, and its "1", behind."""
|
||||
status = parse_host_status(_wire(periodic=UNATTENDED, unattended=False))
|
||||
|
||||
assert status["auto_updates"] is False
|
||||
|
||||
def test_dnf_automatic_install_timer_always_installs(self):
|
||||
status = parse_host_status(_wire(dnf_timers=("not-found", "enabled")))
|
||||
|
||||
assert status["auto_updates"] is True
|
||||
|
||||
def test_dnf_automatic_with_apply_updates(self):
|
||||
conf = ["apply_updates = yes"]
|
||||
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found"), dnf_conf=conf))
|
||||
|
||||
assert status["auto_updates"] is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"conf", [[], ["apply_updates = no"], ["apply_updates=yes", "apply_updates = False"]]
|
||||
)
|
||||
def test_dnf_automatic_by_default_only_downloads(self, conf):
|
||||
"""dnf-automatic.timer installs only with apply_updates; the default is no.
|
||||
dnf5's file comes last and wins."""
|
||||
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found"), dnf_conf=conf))
|
||||
|
||||
assert status["auto_updates"] is False
|
||||
|
||||
def test_the_command_reads_apply_updates_of_dnf4_then_dnf5(self):
|
||||
conf = "/etc/dnf/automatic.conf /etc/dnf/dnf5-plugins/automatic.conf"
|
||||
assert conf in HOST_STATUS_COMMAND
|
||||
|
||||
@pytest.mark.parametrize("installed", [True, False])
|
||||
def test_the_command_asks_whether_unattended_upgrade_is_installed(self, tmp_path, installed):
|
||||
"""Run for real, with a stand-in apt-config that still says "1"."""
|
||||
if os.path.exists("/usr/bin/unattended-upgrade"):
|
||||
pytest.skip("this host has unattended-upgrade installed")
|
||||
apt_config = tmp_path / "apt-config"
|
||||
apt_config.write_text("#!/bin/sh\necho 'APT::Periodic::Unattended-Upgrade \"1\";'\n")
|
||||
stubs = [apt_config]
|
||||
if installed:
|
||||
stubs.append(tmp_path / "unattended-upgrade")
|
||||
stubs[-1].write_text("#!/bin/sh\nexit 0\n")
|
||||
for stub in stubs:
|
||||
stub.chmod(0o755)
|
||||
env = {**os.environ, "PATH": f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}"}
|
||||
out = subprocess.run(
|
||||
["sh", "-c", HOST_STATUS_COMMAND], capture_output=True, text=True, env=env
|
||||
).stdout
|
||||
|
||||
assert ("[unattended-upgrade]" in out) is installed
|
||||
|
||||
def test_neither_apt_nor_dnf_is_unknown(self):
|
||||
assert parse_host_status(_wire())["auto_updates"] is None
|
||||
|
||||
|
||||
@@ -11,10 +11,14 @@ Fixture lines are from real hosts (Ubuntu 24.04, Debian 13 / OMV, Proxmox VE 9).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types.package_updates import (
|
||||
APT_UPGRADABLE_COMMAND,
|
||||
DNF_SECURITY_COMMAND,
|
||||
nevra_name,
|
||||
parse_apt_upgradable,
|
||||
parse_dnf_security,
|
||||
@@ -139,13 +143,38 @@ class TestDnfSecurity:
|
||||
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-libs-1:3.1.4-2.fc40.x86_64
|
||||
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-1:3.1.4-2.fc40.x86_64
|
||||
RLSA-2024:1234 Moderate/Sec. kernel-core-5.14.0-427.13.1.el9_4.x86_64
|
||||
__DNF_RC=0
|
||||
"""
|
||||
|
||||
def test_the_names_of_packages_with_a_security_advisory(self):
|
||||
assert parse_dnf_security(self.ADVISORIES) == {"openssl-libs", "openssl", "kernel-core"}
|
||||
|
||||
def test_nothing_is_an_empty_set(self):
|
||||
assert parse_dnf_security("") == set()
|
||||
assert parse_dnf_security("__DNF_RC=0\n") == set()
|
||||
|
||||
def test_a_failed_dnf_raises_instead_of_reading_as_no_advisories(self):
|
||||
"""dnf that could not reach its repositories prints nothing to stdout;
|
||||
every pending update would have become not security."""
|
||||
with pytest.raises(ValueError, match="1"):
|
||||
parse_dnf_security("__DNF_RC=1\n")
|
||||
|
||||
def test_output_cut_short_raises(self):
|
||||
with pytest.raises(ValueError, match="no exit status"):
|
||||
parse_dnf_security(self.ADVISORIES.replace("__DNF_RC=0\n", ""))
|
||||
|
||||
def test_the_command_reports_dnfs_exit_status_not_cats(self, tmp_path):
|
||||
"""A stand-in dnf that fails quietly, as one without its repositories does."""
|
||||
dnf = tmp_path / "dnf"
|
||||
dnf.write_text("#!/bin/sh\nexit 1\n")
|
||||
dnf.chmod(0o755)
|
||||
env = {"PATH": f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}"}
|
||||
out = subprocess.run(
|
||||
["sh", "-c", DNF_SECURITY_COMMAND], capture_output=True, text=True, env=env
|
||||
).stdout
|
||||
|
||||
assert out == "__DNF_RC=1\n"
|
||||
with pytest.raises(ValueError, match="exit status 1"):
|
||||
parse_dnf_security(out)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("nevra", "name"),
|
||||
|
||||
@@ -141,6 +141,19 @@ class TestRoleIntrospection:
|
||||
assert primary_role_of(Nas) == "storage"
|
||||
assert primary_role_of(Host) == "linux"
|
||||
|
||||
def test_a_driver_that_sets_its_own_role_replaces_the_inherited_one(self):
|
||||
"""A Windows host is an OS driver too, but not a "linux" one."""
|
||||
|
||||
class Windows(OSDriver):
|
||||
ROLE = "windows"
|
||||
|
||||
class Nas(StorageDriver, Windows):
|
||||
pass
|
||||
|
||||
assert primary_role_of(Windows) == "windows"
|
||||
assert [r.__name__ for r in roles_of(Windows)] == ["Windows"]
|
||||
assert [r.__name__ for r in roles_of(Nas)] == ["StorageDriver", "Windows"]
|
||||
|
||||
def test_driver_without_a_role_has_none(self):
|
||||
class Bare(DeviceTypeDriver):
|
||||
pass
|
||||
|
||||
+132
-3
@@ -19,7 +19,9 @@ from napalm_device_types.ssh_server import (
|
||||
MAIN_CONFIG,
|
||||
OpenSshServerMixin,
|
||||
has_dropin_include,
|
||||
host_key_algorithms,
|
||||
parse_sshd_effective,
|
||||
parse_sshd_hostkeys,
|
||||
plan_ssh_hardening,
|
||||
render_dropin,
|
||||
render_main_config,
|
||||
@@ -32,8 +34,26 @@ 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
|
||||
hostkey /etc/ssh/ssh_host_ecdsa_key
|
||||
hostkey /etc/ssh/ssh_host_ed25519_key
|
||||
hostkey /etc/ssh/ssh_host_rsa_key
|
||||
"""
|
||||
|
||||
#: What ``ssh-keygen -l`` prints per host key type.
|
||||
KEYGEN = {
|
||||
"/etc/ssh/ssh_host_ecdsa_key": "256 SHA256:aaaa root@host (ECDSA)",
|
||||
"/etc/ssh/ssh_host_ed25519_key": "256 SHA256:bbbb root@host (ED25519)",
|
||||
"/etc/ssh/ssh_host_rsa_key": "3072 SHA256:cccc root@host (RSA)",
|
||||
}
|
||||
|
||||
#: The lock-out case: sshd offers ECDSA, sk- and certificate algorithms, but the
|
||||
#: host has only an ed25519 and an RSA key.
|
||||
SSHD_T_FEW_KEYS = SSHD_T.replace(
|
||||
"hostkeyalgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256",
|
||||
"hostkeyalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,"
|
||||
"ssh-ed25519,ecdsa-sha2-nistp256,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256",
|
||||
).replace("hostkey /etc/ssh/ssh_host_ecdsa_key\n", "")
|
||||
|
||||
PARAMIKO = {
|
||||
"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "diffie-hellman-group14-sha256"],
|
||||
"hostkey": ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512"],
|
||||
@@ -112,6 +132,58 @@ def test_plan_without_a_client_list_only_guards_the_last_algorithm():
|
||||
assert plan.keep["kex"] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"]
|
||||
|
||||
|
||||
def test_plan_counts_only_host_key_algorithms_the_host_has_a_key_for():
|
||||
"""Without ed25519 and RSA, only ECDSA, sk- and cert algorithms stay: offered,
|
||||
but the host has no key for them, so nobody would get in."""
|
||||
eff = parse_sshd_effective(SSHD_T_FEW_KEYS)
|
||||
plan = plan_ssh_hardening(
|
||||
eff,
|
||||
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]},
|
||||
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
|
||||
)
|
||||
assert "ssh-ed25519" in plan.keep["hostkey"]
|
||||
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ssh-ed25519", "last")]
|
||||
assert plan.remove["hostkey"] == ["rsa-sha2-512", "rsa-sha2-256"]
|
||||
|
||||
|
||||
def test_plan_client_guard_counts_only_usable_host_keys():
|
||||
eff = parse_sshd_effective(SSHD_T_FEW_KEYS)
|
||||
plan = plan_ssh_hardening(
|
||||
eff,
|
||||
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]},
|
||||
client=PARAMIKO,
|
||||
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
|
||||
)
|
||||
# paramiko speaks ecdsa-sha2-nistp256, but the host has no such key.
|
||||
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ssh-ed25519", "client")]
|
||||
|
||||
|
||||
def test_plan_removes_a_host_key_algorithm_when_a_usable_one_stays():
|
||||
plan = plan_ssh_hardening(
|
||||
parse_sshd_effective(SSHD_T_FEW_KEYS),
|
||||
{"hostkey": ["rsa-sha2-256"]},
|
||||
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
|
||||
)
|
||||
assert plan.remove == {"hostkey": ["rsa-sha2-256"]}
|
||||
assert plan.refused == []
|
||||
|
||||
|
||||
def test_plan_leaves_host_keys_alone_when_the_keys_are_unknown():
|
||||
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"hostkey": ["ecdsa-sha2-nistp256"]})
|
||||
assert plan.changed_classes == []
|
||||
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ecdsa-sha2-nistp256", "host_keys_unknown")]
|
||||
|
||||
|
||||
def test_host_key_algorithms_from_ssh_keygen():
|
||||
assert host_key_algorithms("\n".join(KEYGEN.values())) == {
|
||||
"ecdsa-sha2-nistp256", "ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa",
|
||||
}
|
||||
assert host_key_algorithms("521 SHA256:x no comment (ECDSA)") == {"ecdsa-sha2-nistp521"}
|
||||
assert host_key_algorithms("") is None
|
||||
assert host_key_algorithms("garbage") is None
|
||||
assert parse_sshd_hostkeys(SSHD_T)[0] == "/etc/ssh/ssh_host_ecdsa_key"
|
||||
|
||||
|
||||
def test_plan_rejects_an_unknown_class():
|
||||
with pytest.raises(ValueError):
|
||||
plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"compression": ["zlib"]})
|
||||
@@ -161,9 +233,14 @@ _DIRECTIVE = {"kexalgorithms": "kex", "hostkeyalgorithms": "hostkey", "ciphers":
|
||||
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:
|
||||
def __init__(self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n",
|
||||
sshd_t: str = SSHD_T) -> None:
|
||||
self.files: Dict[str, str] = {MAIN_CONFIG: main}
|
||||
self.defaults = parse_sshd_effective(SSHD_T)
|
||||
self.modes: Dict[str, str] = {MAIN_CONFIG: "644"}
|
||||
self.sshd_t = sshd_t
|
||||
self.defaults = parse_sshd_effective(sshd_t)
|
||||
self.keys = {p: KEYGEN[p] for p in parse_sshd_hostkeys(sshd_t)}
|
||||
self.keygen_ok = True
|
||||
self.ran: List[str] = []
|
||||
self.syntax_ok = True
|
||||
self.reloads = 0
|
||||
@@ -204,16 +281,24 @@ class FakeHost(OpenSshServerMixin):
|
||||
self.files[path] = (stdin or b"").decode()
|
||||
return CommandResult(path, "", 0)
|
||||
assert privileged, command
|
||||
m = re.search(r"install -m 644 -- '?([^' ]+)'? '?([^' ]+)'?", command)
|
||||
m = re.search(r'install -m "\$m" -- \'?([^\' ]+)\'? \'?([^\' ]+)\'?', command)
|
||||
if m:
|
||||
src, tmp = m.group(1), m.group(2)
|
||||
target = re.search(r"mv -f -- '?[^' ]+'? '?([^' ;]+)'?", command).group(1)
|
||||
ref = re.search(r"r='?([^' ;]+)'?; m=\$\(stat -c %a", command).group(1)
|
||||
self.modes[target] = self.modes.get(ref, "644") if ref in self.files else "644"
|
||||
self.files[target] = self.files.pop(src)
|
||||
assert tmp.endswith(".netork-tmp")
|
||||
return CommandResult("", "", 0)
|
||||
if "ssh-keygen -l" in command:
|
||||
if not self.keygen_ok:
|
||||
return CommandResult("", "Permission denied", 1)
|
||||
paths = re.search(r"for k in (.*?); do", command).group(1).replace("'", "").split()
|
||||
return CommandResult("".join(self.keys[p] + "\n" for p in paths if p in self.keys), "", 0)
|
||||
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())
|
||||
out += "".join(line + "\n" for line in self.sshd_t.splitlines() if line.startswith("hostkey "))
|
||||
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)
|
||||
@@ -226,6 +311,7 @@ class FakeHost(OpenSshServerMixin):
|
||||
m = re.match(r"rm -f -- '?([^' ]+)'?$", command)
|
||||
if m:
|
||||
self.files.pop(m.group(1), None)
|
||||
self.modes.pop(m.group(1), None)
|
||||
return CommandResult("", "", 0)
|
||||
if "reload" in command or "HUP" in command:
|
||||
self.reloads += 1
|
||||
@@ -349,3 +435,46 @@ def test_mixin_reports_a_failed_way_back_instead_of_raising():
|
||||
|
||||
assert not result.applied and not result.rolled_back
|
||||
assert "putting the previous configuration back failed" in result.message
|
||||
|
||||
|
||||
def test_mixin_refuses_to_lock_out_a_host_with_only_ed25519_and_rsa_keys():
|
||||
host = FakeHost(sshd_t=SSHD_T_FEW_KEYS)
|
||||
lines: List[str] = []
|
||||
result = host.harden_ssh_server(
|
||||
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]}, log=lines.append
|
||||
)
|
||||
assert result.applied
|
||||
assert "ssh-ed25519" in host._effective()["hostkey"]
|
||||
assert any("Keeping ssh-ed25519" in line for line in lines)
|
||||
|
||||
|
||||
def test_mixin_removes_a_host_key_algorithm_when_a_usable_one_stays():
|
||||
host = FakeHost(sshd_t=SSHD_T_FEW_KEYS)
|
||||
result = host.harden_ssh_server({"hostkey": ["rsa-sha2-256", "rsa-sha2-512"]})
|
||||
assert result.applied and result.plan.refused == []
|
||||
eff = host._effective()["hostkey"]
|
||||
assert "rsa-sha2-256" not in eff and "ssh-ed25519" in eff
|
||||
|
||||
|
||||
def test_mixin_leaves_host_keys_alone_when_it_cannot_read_them():
|
||||
host = FakeHost()
|
||||
host.keygen_ok = False
|
||||
result = host.harden_ssh_server({"hostkey": ["ecdsa-sha2-nistp256"]})
|
||||
assert not result.applied and host.reloads == 0
|
||||
assert DROPIN_PATH not in host.files
|
||||
assert [r.reason for r in result.plan.refused] == ["host_keys_unknown"]
|
||||
|
||||
|
||||
def test_a_0600_sshd_config_stays_0600_and_so_does_its_copy():
|
||||
host = FakeHost(main="Port 22\nMACs hmac-sha1,hmac-sha2-256\n")
|
||||
host.modes[MAIN_CONFIG] = "600"
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert result.applied
|
||||
assert host.modes[MAIN_CONFIG] == "600"
|
||||
assert host.modes[result.backup] == "600"
|
||||
|
||||
|
||||
def test_a_new_dropin_gets_644():
|
||||
host = FakeHost()
|
||||
host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert host.modes[DROPIN_PATH] == "644"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""SNMP interface counters: 64-bit where the agent has ifXTable, 32-bit where not."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
from napalm_device_types._ucd_metrics import (
|
||||
OID_IF_DESCR,
|
||||
OID_IF_HC_IN_OCT,
|
||||
OID_IF_HC_OUT_OCT,
|
||||
OID_IF_HIGH_SPEED,
|
||||
OID_IF_IN_OCT,
|
||||
OID_IF_OUT_OCT,
|
||||
OID_IF_SPEED,
|
||||
collect_ucd_metrics,
|
||||
)
|
||||
|
||||
LEGACY = {
|
||||
OID_IF_DESCR: {"1": "eth0"},
|
||||
OID_IF_SPEED: {"1": "4294967295"},
|
||||
OID_IF_IN_OCT: {"1": "100"},
|
||||
OID_IF_OUT_OCT: {"1": "200"},
|
||||
}
|
||||
|
||||
|
||||
def _collect(tables: Dict[str, Dict[str, str]]) -> dict:
|
||||
async def get(oid: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
async def walk(oid: str) -> Dict[str, str]:
|
||||
return tables.get(oid, {})
|
||||
|
||||
return asyncio.run(collect_ucd_metrics(get, walk))
|
||||
|
||||
|
||||
def test_the_64_bit_counters_and_high_speed_win():
|
||||
tables = {
|
||||
**LEGACY,
|
||||
OID_IF_HC_IN_OCT: {"1": "8589934592"},
|
||||
OID_IF_HC_OUT_OCT: {"1": "17179869184"},
|
||||
OID_IF_HIGH_SPEED: {"1": "10000"},
|
||||
}
|
||||
|
||||
eth0 = _collect(tables)["interfaces"]["eth0"]
|
||||
|
||||
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (8589934592, 17179869184, 10000)
|
||||
|
||||
|
||||
def test_an_agent_without_ifxtable_falls_back_to_the_32_bit_counters():
|
||||
eth0 = _collect(LEGACY)["interfaces"]["eth0"]
|
||||
|
||||
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (100, 200, 4294)
|
||||
@@ -365,6 +365,9 @@ class _Host:
|
||||
self.bin.mkdir()
|
||||
self.state = root / "systemd"
|
||||
self.state.mkdir()
|
||||
#: Stands in for /run/systemd/system, there while systemd is PID 1.
|
||||
self.booted = root / "run-systemd-system"
|
||||
self.booted.mkdir()
|
||||
(self.state / "units").write_text("")
|
||||
stub = self.bin / "systemctl"
|
||||
stub.write_text(
|
||||
@@ -392,11 +395,15 @@ class _Host:
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
def run(self, *, systemctl: bool = True) -> str:
|
||||
def run(self, *, systemctl: bool = True, booted: bool = True) -> str:
|
||||
if not systemctl:
|
||||
(self.bin / "systemctl").unlink()
|
||||
if not booted:
|
||||
self.booted.rmdir()
|
||||
assert VIRTUAL_IPS_COMMAND.count("/run/systemd/system") == 1
|
||||
command = VIRTUAL_IPS_COMMAND.replace("/run/systemd/system", str(self.booted))
|
||||
return subprocess.run(
|
||||
["/bin/sh", "-c", VIRTUAL_IPS_COMMAND],
|
||||
["/bin/sh", "-c", command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={"PATH": str(self.bin)},
|
||||
@@ -552,6 +559,46 @@ class TestTheCommandOnAHost:
|
||||
"keepalived": None,
|
||||
}
|
||||
|
||||
def test_systemctl_without_systemd_running_is_not_looked_at(self, host):
|
||||
"""A container that carries systemctl but runs something else as PID 1:
|
||||
systemctl lists no units there, which is not "none declared"."""
|
||||
config = host.file(
|
||||
"etc/keepalived/keepalived.conf", "vrrp_instance a { virtual_ipaddress { 192.0.2.4 } }"
|
||||
)
|
||||
host.unit("keepalived.service", "ActiveState=active", _exec_start("/k", "-f", str(config)))
|
||||
|
||||
assert parse_virtual_ips(host.run(booted=False)) == {
|
||||
"vip_manager": None,
|
||||
"keepalived": None,
|
||||
}
|
||||
|
||||
def test_braces_on_their_own_line(self, host):
|
||||
"""keepalived takes ``vrrp_instance VI_2`` and its ``{`` on separate lines,
|
||||
the same for every block; the secrets inside stay on the host."""
|
||||
config = host.file(
|
||||
"etc/keepalived/keepalived.conf",
|
||||
"vrrp_instance VI_1 {\n interface eth0\n"
|
||||
" virtual_ipaddress {\n 192.0.2.50\n }\n}\n"
|
||||
"vrrp_instance VI_2\n{\n interface eth1\n virtual_router_id 53\n"
|
||||
f" authentication\n {{\n auth_type PASS\n auth_pass {SECRET}\n }}\n"
|
||||
" virtual_ipaddress\n\n {\n 192.0.2.60/24\n }\n"
|
||||
f" notify_master /etc/keepalived/master.sh {SECRET}\n}}\n",
|
||||
)
|
||||
host.unit(
|
||||
"keepalived.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/sbin/keepalived", "-f", str(config)),
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert SECRET not in out
|
||||
assert [(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
|
||||
for e in parse_virtual_ips(out)["keepalived"]] == [
|
||||
("VI_1", "192.0.2.50", 32, "eth0", None),
|
||||
("VI_2", "192.0.2.60", 24, "eth1", "vrid:53"),
|
||||
]
|
||||
|
||||
|
||||
class TestTheCommand:
|
||||
def test_the_frame_is_not_in_the_command_itself(self):
|
||||
|
||||
Reference in new issue
Block a user