merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
@@ -18,13 +18,35 @@ _FUZZ_SETTINGS = dict(
|
||||
)
|
||||
|
||||
|
||||
def make_fake_decnet_logging() -> ModuleType:
|
||||
mod = ModuleType("decnet_logging")
|
||||
def make_fake_syslog_bridge() -> ModuleType:
|
||||
mod = ModuleType("syslog_bridge")
|
||||
mod.syslog_line = MagicMock(return_value="")
|
||||
mod.write_syslog_file = MagicMock()
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
# encode_secret returns the universal cred SD shape; tests don't
|
||||
# care about the exact bytes, just that the key set is correct.
|
||||
mod.encode_secret = MagicMock(
|
||||
return_value={"secret_printable": "", "secret_b64": ""}
|
||||
)
|
||||
# classify_authorization returns None for unknown / absent auth so
|
||||
# services that call **(cred or {}) get a no-op spread.
|
||||
mod.classify_authorization = MagicMock(return_value=None)
|
||||
return mod
|
||||
|
||||
|
||||
def load_real_instance_seed() -> ModuleType:
|
||||
"""Load the real instance_seed helper so templates under test see the
|
||||
actual per-instance seeding behavior, not a stub. Tests that need
|
||||
determinism should pin NODE_NAME via monkeypatch before loading a
|
||||
template."""
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"instance_seed", "decnet/templates/instance_seed.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/imap/server.py
|
||||
Tests for decnet/templates/imap/server.py
|
||||
|
||||
Exercises the full IMAP4rev1 state machine:
|
||||
NOT_AUTHENTICATED → AUTHENTICATED → SELECTED
|
||||
@@ -17,31 +17,33 @@ import pytest
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_fake_decnet_logging() -> ModuleType:
|
||||
mod = ModuleType("decnet_logging")
|
||||
def _make_fake_syslog_bridge() -> ModuleType:
|
||||
mod = ModuleType("syslog_bridge")
|
||||
mod.syslog_line = MagicMock(return_value="")
|
||||
mod.write_syslog_file = MagicMock()
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
mod.encode_secret = MagicMock(return_value={"secret_printable": "", "secret_b64": ""})
|
||||
mod.classify_authorization = MagicMock(return_value=None)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_imap():
|
||||
"""Import imap server module, injecting a stub decnet_logging."""
|
||||
"""Import imap server module, injecting a stub syslog_bridge."""
|
||||
env = {
|
||||
"NODE_NAME": "testhost",
|
||||
"IMAP_USERS": "admin:admin123,root:toor",
|
||||
"IMAP_BANNER": "* OK [testhost] Dovecot ready.",
|
||||
}
|
||||
for key in list(sys.modules):
|
||||
if key in ("imap_server", "decnet_logging"):
|
||||
if key in ("imap_server", "syslog_bridge"):
|
||||
del sys.modules[key]
|
||||
|
||||
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
|
||||
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"imap_server", "templates/imap/server.py"
|
||||
"imap_server", "decnet/templates/imap/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
|
||||
148
tests/service_testing/test_imap_spool.py
Normal file
148
tests/service_testing/test_imap_spool.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Spool-backed email loading for the IMAP template.
|
||||
|
||||
Verifies that when ``IMAP_EMAIL_SEED`` points at a directory of .eml
|
||||
files, the IMAP server serves those (replacing the hardcoded
|
||||
``_BAIT_EMAILS`` fallback). Empty / missing dir falls back gracefully.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_EML_TEMPLATE = (
|
||||
"From: {from_name} <{from_addr}>\r\n"
|
||||
"To: Sarah <sarah@corp.com>\r\n"
|
||||
"Subject: {subject}\r\n"
|
||||
"Message-ID: <{mid}@corp.com>\r\n"
|
||||
"Date: Mon, 26 Apr 2026 10:00:00 +0000\r\n"
|
||||
"\r\n"
|
||||
"{body}\r\n"
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_syslog_bridge() -> ModuleType:
|
||||
mod = ModuleType("syslog_bridge")
|
||||
mod.syslog_line = MagicMock(return_value="")
|
||||
mod.write_syslog_file = MagicMock()
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
mod.encode_secret = MagicMock(return_value={"secret_printable": "", "secret_b64": ""})
|
||||
mod.classify_authorization = MagicMock(return_value=None)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_imap(env_overrides: dict[str, str]):
|
||||
env = {
|
||||
"NODE_NAME": "testhost",
|
||||
"IMAP_USERS": "admin:admin123",
|
||||
"IMAP_BANNER": "* OK Dovecot ready.",
|
||||
**env_overrides,
|
||||
}
|
||||
for key in list(sys.modules):
|
||||
if key in ("imap_server", "syslog_bridge"):
|
||||
del sys.modules[key]
|
||||
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"imap_server", "decnet/templates/imap/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _seed(tmp_path: Path, n: int = 3) -> Path:
|
||||
spool = tmp_path / "spool"
|
||||
spool.mkdir()
|
||||
thread = spool / "thr1"
|
||||
thread.mkdir()
|
||||
for i in range(n):
|
||||
eml = thread / f"msg{i}.eml"
|
||||
eml.write_text(_EML_TEMPLATE.format(
|
||||
from_name=f"Sender {i}",
|
||||
from_addr=f"sender{i}@corp.com",
|
||||
subject=f"Topic {i}",
|
||||
mid=f"m{i}",
|
||||
body=f"Body of message {i}.",
|
||||
))
|
||||
return spool
|
||||
|
||||
|
||||
def test_falls_back_to_hardcoded_when_seed_unset(tmp_path):
|
||||
mod = _load_imap({})
|
||||
emails = mod._get_emails()
|
||||
# The shipped fallback ships exactly 10 entries.
|
||||
assert len(emails) == 10
|
||||
assert emails[0]["from_addr"] == "devops@company.internal"
|
||||
|
||||
|
||||
def test_falls_back_when_seed_dir_missing(tmp_path):
|
||||
mod = _load_imap({"IMAP_EMAIL_SEED": str(tmp_path / "does-not-exist")})
|
||||
emails = mod._get_emails()
|
||||
assert len(emails) == 10 # fallback
|
||||
|
||||
|
||||
def test_falls_back_when_seed_dir_empty(tmp_path):
|
||||
(tmp_path / "spool").mkdir()
|
||||
mod = _load_imap({"IMAP_EMAIL_SEED": str(tmp_path / "spool")})
|
||||
assert len(mod._get_emails()) == 10 # fallback (no .eml files)
|
||||
|
||||
|
||||
def test_loads_eml_files_from_spool(tmp_path):
|
||||
spool = _seed(tmp_path, n=3)
|
||||
mod = _load_imap({"IMAP_EMAIL_SEED": str(spool)})
|
||||
emails = mod._get_emails()
|
||||
assert len(emails) == 3
|
||||
senders = {e["from_addr"] for e in emails}
|
||||
assert senders == {"sender0@corp.com", "sender1@corp.com", "sender2@corp.com"}
|
||||
# UIDs are 1-based and unique.
|
||||
assert {e["uid"] for e in emails} == {1, 2, 3}
|
||||
|
||||
|
||||
def test_loaded_eml_carries_full_rfc822_body(tmp_path):
|
||||
spool = _seed(tmp_path, n=1)
|
||||
mod = _load_imap({"IMAP_EMAIL_SEED": str(spool)})
|
||||
emails = mod._get_emails()
|
||||
assert "From:" in emails[0]["body"]
|
||||
assert "Subject: Topic 0" in emails[0]["body"]
|
||||
assert "Body of message 0." in emails[0]["body"]
|
||||
|
||||
|
||||
def test_corrupt_eml_skipped_not_fatal(tmp_path):
|
||||
spool = tmp_path / "spool"
|
||||
spool.mkdir()
|
||||
(spool / "good.eml").write_text(_EML_TEMPLATE.format(
|
||||
from_name="Good", from_addr="good@corp.com",
|
||||
subject="ok", mid="g", body="ok",
|
||||
))
|
||||
# Make a directory with a .eml extension to provoke an OSError on
|
||||
# read_bytes — the loader should skip it without crashing.
|
||||
(spool / "broken.eml").mkdir()
|
||||
mod = _load_imap({"IMAP_EMAIL_SEED": str(spool)})
|
||||
emails = mod._get_emails()
|
||||
assert len(emails) == 1
|
||||
assert emails[0]["from_addr"] == "good@corp.com"
|
||||
|
||||
|
||||
def test_select_inbox_reflects_spool_count(tmp_path):
|
||||
spool = _seed(tmp_path, n=4)
|
||||
mod = _load_imap({"IMAP_EMAIL_SEED": str(spool)})
|
||||
proto = mod.IMAPProtocol()
|
||||
transport = MagicMock()
|
||||
written: list[bytes] = []
|
||||
transport.write.side_effect = written.append
|
||||
proto.connection_made(transport)
|
||||
written.clear()
|
||||
proto.data_received(b"A0 LOGIN admin admin123\r\n")
|
||||
written.clear()
|
||||
proto.data_received(b"B0 SELECT INBOX\r\n")
|
||||
out = b"".join(written)
|
||||
assert b"* 4 EXISTS" in out
|
||||
assert b"[UIDNEXT 5]" in out
|
||||
91
tests/service_testing/test_instance_seed.py
Normal file
91
tests/service_testing/test_instance_seed.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tests for decnet/templates/instance_seed.py — the per-instance stealth
|
||||
seeding helper. These tests pin NODE_NAME to assert determinism of the
|
||||
seeded functions, and sweep NODE_NAMEs to assert cross-fleet divergence.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _load_seed(node_name: str):
|
||||
sys.modules.pop("instance_seed", None)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"instance_seed", "decnet/templates/instance_seed.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", {"NODE_NAME": node_name}, clear=False):
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_same_nodename_yields_stable_uuid():
|
||||
a = _load_seed("deckie-42").instance_uuid("x")
|
||||
b = _load_seed("deckie-42").instance_uuid("x")
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_different_nodename_yields_different_uuid():
|
||||
a = _load_seed("deckie-alpha").instance_uuid("x")
|
||||
b = _load_seed("deckie-beta").instance_uuid("x")
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_pick_is_deterministic_per_instance():
|
||||
choices = ["a", "b", "c", "d", "e"]
|
||||
m1 = _load_seed("hostX")
|
||||
m2 = _load_seed("hostX")
|
||||
assert m1.pick(choices) == m2.pick(choices)
|
||||
|
||||
|
||||
def test_pick_varies_across_fleet():
|
||||
"""For a reasonable fleet size, pick should land on at least 2 distinct
|
||||
values. Anything less means the seed isn't actually diversifying output."""
|
||||
choices = list("abcdefghij")
|
||||
picks = {_load_seed(f"host{i}").pick(choices) for i in range(20)}
|
||||
assert len(picks) >= 3
|
||||
|
||||
|
||||
def test_uptime_monotonic_across_calls():
|
||||
mod = _load_seed("uptime-host")
|
||||
u1 = mod.uptime_seconds()
|
||||
time.sleep(0.02)
|
||||
u2 = mod.uptime_seconds()
|
||||
assert u2 >= u1
|
||||
|
||||
|
||||
def test_uptime_includes_boot_offset():
|
||||
"""uptime should be > a few minutes even at process start — deckies
|
||||
should not look like they just booted."""
|
||||
mod = _load_seed("fresh-host")
|
||||
assert mod.uptime_seconds() > 600
|
||||
|
||||
|
||||
def test_fresh_bytes_is_not_deterministic():
|
||||
"""fresh_bytes is per-connection randomness, not seeded — otherwise
|
||||
two MySQL handshakes to the same decky would present identical salts."""
|
||||
mod = _load_seed("host")
|
||||
assert mod.fresh_bytes(16) != mod.fresh_bytes(16)
|
||||
|
||||
|
||||
def test_random_bytes_is_deterministic():
|
||||
"""random_bytes is the *seeded* variant — used for stable per-instance
|
||||
identifiers like cluster UUIDs."""
|
||||
a = _load_seed("h").random_bytes(16, "ns")
|
||||
b = _load_seed("h").random_bytes(16, "ns")
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_jitter_sleeps_in_range():
|
||||
mod = _load_seed("jh")
|
||||
|
||||
async def run():
|
||||
start = time.perf_counter()
|
||||
await mod.jitter(10, 30)
|
||||
return time.perf_counter() - start
|
||||
|
||||
elapsed = asyncio.run(run())
|
||||
assert 0.005 <= elapsed <= 0.200 # generous upper bound for CI jitter
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/mongodb/server.py
|
||||
Tests for decnet/templates/mongodb/server.py
|
||||
|
||||
Covers the MongoDB wire-protocol (OP_MSG / OP_QUERY) happy path and regression
|
||||
tests for the zero-length msg_len infinite-loop bug and oversized msg_len.
|
||||
@@ -14,17 +14,22 @@ import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
|
||||
from .conftest import (
|
||||
_FUZZ_SETTINGS,
|
||||
load_real_instance_seed,
|
||||
make_fake_syslog_bridge,
|
||||
run_with_timeout,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_mongodb():
|
||||
for key in list(sys.modules):
|
||||
if key in ("mongodb_server", "decnet_logging"):
|
||||
del sys.modules[key]
|
||||
sys.modules["decnet_logging"] = make_fake_decnet_logging()
|
||||
spec = importlib.util.spec_from_file_location("mongodb_server", "templates/mongodb/server.py")
|
||||
for key in ("mongodb_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec = importlib.util.spec_from_file_location("mongodb_server", "decnet/templates/mongodb/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/mqtt/server.py
|
||||
Tests for decnet/templates/mqtt/server.py
|
||||
|
||||
Exercises behavior with MQTT_ACCEPT_ALL=1 and customizable topics.
|
||||
Uses asyncio transport/protocol directly.
|
||||
@@ -16,13 +16,15 @@ import pytest
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_fake_decnet_logging() -> ModuleType:
|
||||
mod = ModuleType("decnet_logging")
|
||||
def _make_fake_syslog_bridge() -> ModuleType:
|
||||
mod = ModuleType("syslog_bridge")
|
||||
mod.syslog_line = MagicMock(return_value="")
|
||||
mod.write_syslog_file = MagicMock()
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
mod.encode_secret = MagicMock(return_value={"secret_printable": "", "secret_b64": ""})
|
||||
mod.classify_authorization = MagicMock(return_value=None)
|
||||
return mod
|
||||
|
||||
|
||||
@@ -33,15 +35,16 @@ def _load_mqtt(accept_all: bool = True, custom_topics: str = "", persona: str =
|
||||
"MQTT_PERSONA": persona,
|
||||
"MQTT_CUSTOM_TOPICS": custom_topics,
|
||||
}
|
||||
for key in list(sys.modules):
|
||||
if key in ("mqtt_server", "decnet_logging"):
|
||||
del sys.modules[key]
|
||||
for key in ("mqtt_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
|
||||
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
|
||||
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
|
||||
|
||||
spec = importlib.util.spec_from_file_location("mqtt_server", "templates/mqtt/server.py")
|
||||
spec = importlib.util.spec_from_file_location("mqtt_server", "decnet/templates/mqtt/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
from .conftest import load_real_instance_seed
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
@@ -127,14 +130,18 @@ def test_subscribe_wildcard_retained(mqtt_mod):
|
||||
_send(proto, _connect_packet())
|
||||
written.clear()
|
||||
|
||||
_send(proto, _subscribe_packet("plant/#"))
|
||||
# The water_plant persona now picks a per-instance site prefix (north,
|
||||
# south, plant-a, etc.) instead of hardcoding "plant/". Use the top-level
|
||||
# wildcard so the test doesn't depend on which site this decky rolled.
|
||||
_send(proto, _subscribe_packet("#"))
|
||||
|
||||
assert len(written) >= 2 # At least SUBACK + some publishes
|
||||
assert written[0].startswith(b"\x90") # SUBACK
|
||||
assert len(written) >= 2 # At least SUBACK + some publishes
|
||||
assert written[0].startswith(b"\x90") # SUBACK
|
||||
|
||||
combined = b"".join(written[1:])
|
||||
# Should contain some water plant topics
|
||||
assert b"plant/water/tank1/level" in combined
|
||||
# The identifying tail of water-plant topics is stable regardless of
|
||||
# which site prefix was chosen at instance boot.
|
||||
assert b"water/tank1/level" in combined
|
||||
|
||||
def test_publish_qos1_returns_puback(mqtt_mod):
|
||||
proto, _, written = _make_protocol(mqtt_mod)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/mqtt/server.py — protocol boundary and fuzz cases.
|
||||
Tests for decnet/templates/mqtt/server.py — protocol boundary and fuzz cases.
|
||||
|
||||
Focuses on the variable-length remaining-length field (MQTT spec: max 4 bytes).
|
||||
A 5th continuation byte used to cause the server to get stuck waiting for a
|
||||
@@ -15,17 +15,22 @@ import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
|
||||
from .conftest import (
|
||||
_FUZZ_SETTINGS,
|
||||
load_real_instance_seed,
|
||||
make_fake_syslog_bridge,
|
||||
run_with_timeout,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_mqtt():
|
||||
for key in list(sys.modules):
|
||||
if key in ("mqtt_server", "decnet_logging"):
|
||||
del sys.modules[key]
|
||||
sys.modules["decnet_logging"] = make_fake_decnet_logging()
|
||||
spec = importlib.util.spec_from_file_location("mqtt_server", "templates/mqtt/server.py")
|
||||
for key in ("mqtt_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec = importlib.util.spec_from_file_location("mqtt_server", "decnet/templates/mqtt/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", {"MQTT_ACCEPT_ALL": "1", "MQTT_PERSONA": "water_plant"}, clear=False):
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/mssql/server.py
|
||||
Tests for decnet/templates/mssql/server.py
|
||||
|
||||
Covers the TDS pre-login / login7 happy path and regression tests for the
|
||||
zero-length pkt_len infinite-loop bug that was fixed (pkt_len < 8 guard).
|
||||
@@ -14,17 +14,22 @@ import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
|
||||
from .conftest import (
|
||||
_FUZZ_SETTINGS,
|
||||
load_real_instance_seed,
|
||||
make_fake_syslog_bridge,
|
||||
run_with_timeout,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_mssql():
|
||||
for key in list(sys.modules):
|
||||
if key in ("mssql_server", "decnet_logging"):
|
||||
del sys.modules[key]
|
||||
sys.modules["decnet_logging"] = make_fake_decnet_logging()
|
||||
spec = importlib.util.spec_from_file_location("mssql_server", "templates/mssql/server.py")
|
||||
for key in ("mssql_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec = importlib.util.spec_from_file_location("mssql_server", "decnet/templates/mssql/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/mysql/server.py
|
||||
Tests for decnet/templates/mysql/server.py
|
||||
|
||||
Covers the MySQL handshake happy path and regression tests for oversized
|
||||
length fields that could cause huge buffer allocations.
|
||||
@@ -14,17 +14,22 @@ import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
|
||||
from .conftest import (
|
||||
_FUZZ_SETTINGS,
|
||||
load_real_instance_seed,
|
||||
make_fake_syslog_bridge,
|
||||
run_with_timeout,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_mysql():
|
||||
for key in list(sys.modules):
|
||||
if key in ("mysql_server", "decnet_logging"):
|
||||
del sys.modules[key]
|
||||
sys.modules["decnet_logging"] = make_fake_decnet_logging()
|
||||
spec = importlib.util.spec_from_file_location("mysql_server", "templates/mysql/server.py")
|
||||
for key in ("mysql_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec = importlib.util.spec_from_file_location("mysql_server", "decnet/templates/mysql/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
@@ -33,6 +38,7 @@ def _load_mysql():
|
||||
def _make_protocol(mod):
|
||||
proto = mod.MySQLProtocol()
|
||||
transport = MagicMock()
|
||||
transport.is_closing.return_value = False
|
||||
written: list[bytes] = []
|
||||
transport.write.side_effect = written.append
|
||||
proto.connection_made(transport)
|
||||
@@ -66,6 +72,7 @@ def mysql_mod():
|
||||
def test_connection_sends_greeting(mysql_mod):
|
||||
proto = mysql_mod.MySQLProtocol()
|
||||
transport = MagicMock()
|
||||
transport.is_closing.return_value = False
|
||||
written: list[bytes] = []
|
||||
transport.write.side_effect = written.append
|
||||
proto.connection_made(transport)
|
||||
@@ -89,7 +96,7 @@ def test_login_packet_returns_access_denied(mysql_mod):
|
||||
|
||||
def test_login_logs_username():
|
||||
mod = _load_mysql()
|
||||
log_mock = sys.modules["decnet_logging"]
|
||||
log_mock = sys.modules["syslog_bridge"]
|
||||
proto, _, _ = _make_protocol(mod)
|
||||
proto.data_received(_login_packet(username="hacker"))
|
||||
calls_str = str(log_mock.syslog_line.call_args_list)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/pop3/server.py
|
||||
Tests for decnet/templates/pop3/server.py
|
||||
|
||||
Exercises the full POP3 state machine:
|
||||
AUTHORIZATION → TRANSACTION
|
||||
@@ -17,13 +17,15 @@ import pytest
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_fake_decnet_logging() -> ModuleType:
|
||||
mod = ModuleType("decnet_logging")
|
||||
def _make_fake_syslog_bridge() -> ModuleType:
|
||||
mod = ModuleType("syslog_bridge")
|
||||
mod.syslog_line = MagicMock(return_value="")
|
||||
mod.write_syslog_file = MagicMock()
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
mod.encode_secret = MagicMock(return_value={"secret_printable": "", "secret_b64": ""})
|
||||
mod.classify_authorization = MagicMock(return_value=None)
|
||||
return mod
|
||||
|
||||
|
||||
@@ -34,13 +36,13 @@ def _load_pop3():
|
||||
"IMAP_BANNER": "+OK [testhost] Dovecot ready.",
|
||||
}
|
||||
for key in list(sys.modules):
|
||||
if key in ("pop3_server", "decnet_logging"):
|
||||
if key in ("pop3_server", "syslog_bridge"):
|
||||
del sys.modules[key]
|
||||
|
||||
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
|
||||
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"pop3_server", "templates/pop3/server.py"
|
||||
"pop3_server", "decnet/templates/pop3/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
|
||||
96
tests/service_testing/test_pop3_spool.py
Normal file
96
tests/service_testing/test_pop3_spool.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Spool-backed email loading for the POP3 template."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
_EML_TEMPLATE = (
|
||||
"From: Sender <sender@corp.com>\r\n"
|
||||
"To: Sarah <sarah@corp.com>\r\n"
|
||||
"Subject: {subject}\r\n"
|
||||
"Message-ID: <{mid}@corp.com>\r\n"
|
||||
"\r\n"
|
||||
"{body}\r\n"
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_syslog_bridge() -> ModuleType:
|
||||
mod = ModuleType("syslog_bridge")
|
||||
mod.syslog_line = MagicMock(return_value="")
|
||||
mod.write_syslog_file = MagicMock()
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
mod.encode_secret = MagicMock(return_value={"secret_printable": "", "secret_b64": ""})
|
||||
mod.classify_authorization = MagicMock(return_value=None)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_pop3(env_overrides):
|
||||
env = {
|
||||
"NODE_NAME": "testhost",
|
||||
"IMAP_USERS": "admin:admin123",
|
||||
**env_overrides,
|
||||
}
|
||||
for key in list(sys.modules):
|
||||
if key in ("pop3_server", "syslog_bridge"):
|
||||
del sys.modules[key]
|
||||
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"pop3_server", "decnet/templates/pop3/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _seed(tmp_path: Path, n: int) -> Path:
|
||||
spool = tmp_path / "spool"
|
||||
spool.mkdir()
|
||||
for i in range(n):
|
||||
(spool / f"m{i}.eml").write_text(_EML_TEMPLATE.format(
|
||||
subject=f"Topic {i}", mid=f"m{i}", body=f"Body {i}",
|
||||
))
|
||||
return spool
|
||||
|
||||
|
||||
def test_falls_back_when_seed_unset(tmp_path):
|
||||
mod = _load_pop3({})
|
||||
assert len(mod._get_emails()) == 10 # hardcoded fallback
|
||||
|
||||
|
||||
def test_falls_back_when_seed_dir_missing(tmp_path):
|
||||
mod = _load_pop3({"POP3_EMAIL_SEED": str(tmp_path / "nope")})
|
||||
assert len(mod._get_emails()) == 10
|
||||
|
||||
|
||||
def test_loads_emls_from_spool(tmp_path):
|
||||
spool = _seed(tmp_path, n=3)
|
||||
mod = _load_pop3({"POP3_EMAIL_SEED": str(spool)})
|
||||
emails = mod._get_emails()
|
||||
assert len(emails) == 3
|
||||
# POP3 stores raw RFC 822 strings; verify content round-trips.
|
||||
assert any("Topic 0" in e for e in emails)
|
||||
assert all(e.startswith("From:") for e in emails)
|
||||
|
||||
|
||||
def test_stat_reflects_spool_size(tmp_path):
|
||||
spool = _seed(tmp_path, n=2)
|
||||
mod = _load_pop3({"POP3_EMAIL_SEED": str(spool)})
|
||||
proto = mod.POP3Protocol()
|
||||
transport = MagicMock()
|
||||
written: list[bytes] = []
|
||||
transport.write.side_effect = written.append
|
||||
proto.connection_made(transport)
|
||||
written.clear()
|
||||
proto.data_received(b"USER admin\r\n")
|
||||
proto.data_received(b"PASS admin123\r\n")
|
||||
written.clear()
|
||||
proto.data_received(b"STAT\r\n")
|
||||
out = b"".join(written)
|
||||
assert out.startswith(b"+OK 2 ")
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/postgres/server.py
|
||||
Tests for decnet/templates/postgres/server.py
|
||||
|
||||
Covers the PostgreSQL startup / MD5-auth handshake happy path and regression
|
||||
tests for zero/tiny/huge msg_len in both the startup and auth states.
|
||||
@@ -14,17 +14,22 @@ import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
|
||||
from .conftest import (
|
||||
_FUZZ_SETTINGS,
|
||||
load_real_instance_seed,
|
||||
make_fake_syslog_bridge,
|
||||
run_with_timeout,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_postgres():
|
||||
for key in list(sys.modules):
|
||||
if key in ("postgres_server", "decnet_logging"):
|
||||
del sys.modules[key]
|
||||
sys.modules["decnet_logging"] = make_fake_decnet_logging()
|
||||
spec = importlib.util.spec_from_file_location("postgres_server", "templates/postgres/server.py")
|
||||
for key in ("postgres_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec = importlib.util.spec_from_file_location("postgres_server", "decnet/templates/postgres/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
@@ -80,7 +85,7 @@ def test_startup_sends_auth_challenge(postgres_mod):
|
||||
|
||||
def test_startup_logs_username():
|
||||
mod = _load_postgres()
|
||||
log_mock = sys.modules["decnet_logging"]
|
||||
log_mock = sys.modules["syslog_bridge"]
|
||||
proto, _, _ = _make_protocol(mod)
|
||||
proto.data_received(_startup_msg(user="attacker"))
|
||||
log_mock.syslog_line.assert_called()
|
||||
|
||||
174
tests/service_testing/test_rdp_basic.py
Normal file
174
tests/service_testing/test_rdp_basic.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Tests for decnet/templates/rdp/server.py — X.224 CR cookie capture.
|
||||
|
||||
Drives the asyncio handler with an in-memory StreamReader, asserts:
|
||||
* mstshash cookie in CR is captured as principal/username.
|
||||
* rdpNegRequest.requestedProtocols is recorded.
|
||||
* X.224 Connection Confirm is well-formed and selects PROTOCOL_RDP.
|
||||
* Malformed / oversized TPKT does not crash the handler.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import load_real_instance_seed, make_fake_syslog_bridge
|
||||
|
||||
|
||||
# ── Module loader ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_real_ntlmssp():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"ntlmssp", "decnet/templates/_shared/ntlmssp.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_rdp():
|
||||
for key in ("rdp_server", "syslog_bridge", "instance_seed", "ntlmssp"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
sys.modules["ntlmssp"] = _load_real_ntlmssp()
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"rdp_server", "decnet/templates/rdp/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rdp_mod():
|
||||
return _load_rdp()
|
||||
|
||||
|
||||
# ── PDU builders ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _x224_connection_request(cookie: str | None = None, requested_protocols: int | None = None) -> bytes:
|
||||
"""Build TPKT(X.224 CR [+ Cookie] [+ rdpNegRequest])."""
|
||||
var = b""
|
||||
if cookie is not None:
|
||||
var += f"Cookie: mstshash={cookie}\r\n".encode("ascii")
|
||||
if requested_protocols is not None:
|
||||
var += (
|
||||
bytes([0x01, 0x00])
|
||||
+ (8).to_bytes(2, "little")
|
||||
+ requested_protocols.to_bytes(4, "little")
|
||||
)
|
||||
li = 6 + len(var) # length indicator covers bytes after itself
|
||||
x224 = bytes([li, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00]) + var
|
||||
tpkt = bytes([0x03, 0x00]) + (4 + len(x224)).to_bytes(2, "big")
|
||||
return tpkt + x224
|
||||
|
||||
|
||||
def _make_streams():
|
||||
reader = asyncio.StreamReader()
|
||||
writer = MagicMock()
|
||||
written: list[bytes] = []
|
||||
writer.write.side_effect = written.append
|
||||
writer.get_extra_info.return_value = ("203.0.113.42", 49152)
|
||||
|
||||
async def _drained():
|
||||
return None
|
||||
|
||||
async def _wait_closed():
|
||||
return None
|
||||
|
||||
writer.drain = _drained
|
||||
writer.wait_closed = _wait_closed
|
||||
return reader, writer, written
|
||||
|
||||
|
||||
def _drive(rdp_mod, request_bytes: bytes):
|
||||
async def _run():
|
||||
reader, writer, written = _make_streams()
|
||||
reader.feed_data(request_bytes)
|
||||
reader.feed_eof()
|
||||
await asyncio.wait_for(rdp_mod._handle_client(reader, writer), timeout=2.0)
|
||||
return writer, written
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cookie_is_captured_as_principal():
|
||||
mod = _load_rdp()
|
||||
log_mock = sys.modules["syslog_bridge"]
|
||||
_drive(mod, _x224_connection_request(cookie="alice"))
|
||||
cookie_calls = [
|
||||
c for c in log_mock.syslog_line.call_args_list
|
||||
if len(c.args) >= 3 and c.args[2] == "rdp_cookie"
|
||||
]
|
||||
assert cookie_calls, "expected an rdp_cookie event"
|
||||
kwargs = cookie_calls[0].kwargs
|
||||
assert kwargs["principal"] == "alice"
|
||||
assert kwargs["username"] == "alice"
|
||||
|
||||
|
||||
def test_requested_protocols_recorded():
|
||||
mod = _load_rdp()
|
||||
log_mock = sys.modules["syslog_bridge"]
|
||||
_drive(mod, _x224_connection_request(cookie="bob", requested_protocols=0x03)) # SSL|HYBRID
|
||||
cookie_calls = [
|
||||
c for c in log_mock.syslog_line.call_args_list
|
||||
if len(c.args) >= 3 and c.args[2] == "rdp_cookie"
|
||||
]
|
||||
assert cookie_calls
|
||||
assert cookie_calls[0].kwargs["requested_protocols"] == 0x03
|
||||
|
||||
|
||||
def test_connection_confirm_well_formed(rdp_mod):
|
||||
_, written = _drive(rdp_mod, _x224_connection_request(cookie="charlie"))
|
||||
blob = b"".join(written)
|
||||
assert blob[0] == 0x03 # TPKT version
|
||||
total = int.from_bytes(blob[2:4], "big")
|
||||
assert total == len(blob)
|
||||
# X.224 CC type byte at offset 5
|
||||
assert blob[5] == 0xD0
|
||||
# rdpNegRsp begins at offset 11; SelectedProtocol at offset 15 (4 bytes LE)
|
||||
selected = int.from_bytes(blob[15:19], "little")
|
||||
assert selected == 0x00000000 # PROTOCOL_RDP
|
||||
|
||||
|
||||
def test_no_cookie_still_replies(rdp_mod):
|
||||
_, written = _drive(rdp_mod, _x224_connection_request(cookie=None, requested_protocols=0x00))
|
||||
assert written, "server must still reply with X.224 CC even without cookie"
|
||||
blob = b"".join(written)
|
||||
assert blob[5] == 0xD0 # CC
|
||||
|
||||
|
||||
def test_no_cookie_emits_connection_request_event():
|
||||
mod = _load_rdp()
|
||||
log_mock = sys.modules["syslog_bridge"]
|
||||
_drive(mod, _x224_connection_request(cookie=None))
|
||||
types = [
|
||||
c.args[2] for c in log_mock.syslog_line.call_args_list
|
||||
if len(c.args) >= 3
|
||||
]
|
||||
assert "connection_request" in types
|
||||
assert "rdp_cookie" not in types
|
||||
|
||||
|
||||
def test_oversized_tpkt_is_dropped(rdp_mod):
|
||||
# TPKT len = 65535 → above MAX_TPKT_LEN; handler must reject without
|
||||
# waiting for the full body.
|
||||
bad = bytes([0x03, 0x00, 0xFF, 0xFF])
|
||||
_, written = _drive(rdp_mod, bad)
|
||||
assert written == []
|
||||
|
||||
|
||||
def test_non_tpkt_first_byte_is_dropped(rdp_mod):
|
||||
bad = b"\x16\x03\x01\x00\x10" + b"\x00" * 11 # looks like TLS ClientHello
|
||||
_, written = _drive(rdp_mod, bad)
|
||||
assert written == []
|
||||
211
tests/service_testing/test_rdp_nla.py
Normal file
211
tests/service_testing/test_rdp_nla.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Tests for the RDP NLA / CredSSP credential-capture path.
|
||||
|
||||
The TLS layer is exercised end-to-end in deploy verification; here we
|
||||
unit-test the inner pieces: DER length reader, TSRequest builder,
|
||||
TSRequest reader, and the ``_handle_nla`` loop driving canned CredSSP
|
||||
DER bytes carrying NTLMSSP Type 1 / Type 3 messages.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import load_real_instance_seed, make_fake_syslog_bridge
|
||||
|
||||
|
||||
# ── Module loader ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_real_ntlmssp():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"ntlmssp", "decnet/templates/_shared/ntlmssp.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_rdp(*, enable_nla: bool = True, monkeypatch=None):
|
||||
if monkeypatch is not None:
|
||||
if enable_nla:
|
||||
monkeypatch.setenv("RDP_ENABLE_NLA", "true")
|
||||
else:
|
||||
monkeypatch.delenv("RDP_ENABLE_NLA", raising=False)
|
||||
for key in ("rdp_server", "syslog_bridge", "instance_seed", "ntlmssp"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
sys.modules["ntlmssp"] = _load_real_ntlmssp()
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"rdp_server", "decnet/templates/rdp/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ntlmssp_type1() -> bytes:
|
||||
return b"NTLMSSP\x00" + struct.pack("<I", 1) + struct.pack("<I", 0xE2088297) + b"\x00" * 24
|
||||
|
||||
|
||||
def _ntlmssp_type3(username: str, domain: str, nt_response: bytes) -> bytes:
|
||||
user_b = username.encode("utf-16-le")
|
||||
dom_b = domain.encode("utf-16-le")
|
||||
payload = nt_response + dom_b + user_b
|
||||
nt_off = 72
|
||||
dom_off = nt_off + len(nt_response)
|
||||
user_off = dom_off + len(dom_b)
|
||||
ws_off = user_off + len(user_b)
|
||||
flags = 0x00000001
|
||||
return (
|
||||
b"NTLMSSP\x00"
|
||||
+ struct.pack("<I", 3)
|
||||
+ struct.pack("<HHI", 0, 0, ws_off)
|
||||
+ struct.pack("<HHI", len(nt_response), len(nt_response), nt_off)
|
||||
+ struct.pack("<HHI", len(dom_b), len(dom_b), dom_off)
|
||||
+ struct.pack("<HHI", len(user_b), len(user_b), user_off)
|
||||
+ struct.pack("<HHI", 0, 0, ws_off)
|
||||
+ struct.pack("<HHI", 0, 0, ws_off)
|
||||
+ struct.pack("<I", flags)
|
||||
+ b"\x00" * 8
|
||||
+ payload
|
||||
)
|
||||
|
||||
|
||||
def _make_writer():
|
||||
writer = MagicMock()
|
||||
written: list[bytes] = []
|
||||
writer.write.side_effect = written.append
|
||||
|
||||
async def _drained():
|
||||
return None
|
||||
|
||||
writer.drain = _drained
|
||||
return writer, written
|
||||
|
||||
|
||||
# ── Builder / reader unit tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_der_len_short_form(monkeypatch):
|
||||
mod = _load_rdp(monkeypatch=monkeypatch)
|
||||
assert mod._der_len(0) == b"\x00"
|
||||
assert mod._der_len(0x7F) == b"\x7f"
|
||||
|
||||
|
||||
def test_der_len_long_form(monkeypatch):
|
||||
mod = _load_rdp(monkeypatch=monkeypatch)
|
||||
assert mod._der_len(0x80) == b"\x81\x80"
|
||||
assert mod._der_len(0x100) == b"\x82\x01\x00"
|
||||
|
||||
|
||||
def test_tsrequest_with_token_round_trip(monkeypatch):
|
||||
mod = _load_rdp(monkeypatch=monkeypatch)
|
||||
payload = b"NTLMSSP\x00" + b"\x02" + b"\x00" * 31
|
||||
blob = mod._build_tsrequest_with_token(version=6, ntlm_blob=payload)
|
||||
# Outer SEQUENCE
|
||||
assert blob[0] == 0x30
|
||||
# Find the inner OCTET STRING content, confirm payload is intact
|
||||
assert payload in blob
|
||||
|
||||
|
||||
def test_read_one_tsrequest_returns_full_blob(monkeypatch):
|
||||
mod = _load_rdp(monkeypatch=monkeypatch)
|
||||
payload = mod._build_tsrequest_with_token(6, b"NTLMSSP\x00" + b"\x03" + b"\x00" * 200)
|
||||
|
||||
async def _run():
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(payload)
|
||||
reader.feed_eof()
|
||||
return await mod._read_one_tsrequest(reader)
|
||||
|
||||
out = asyncio.run(_run())
|
||||
assert out == payload
|
||||
|
||||
|
||||
def test_read_one_tsrequest_rejects_oversized(monkeypatch):
|
||||
mod = _load_rdp(monkeypatch=monkeypatch)
|
||||
# Hand-craft a SEQUENCE with body length > MAX_TSREQUEST_LEN
|
||||
over = mod.MAX_TSREQUEST_LEN + 1
|
||||
bad = b"\x30\x84" + over.to_bytes(4, "big") # 4-byte length
|
||||
|
||||
async def _run():
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(bad)
|
||||
reader.feed_eof()
|
||||
with pytest.raises(ValueError):
|
||||
await mod._read_one_tsrequest(reader)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ── _handle_nla integration ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_type1_then_type3_captures_credential(monkeypatch):
|
||||
mod = _load_rdp(monkeypatch=monkeypatch)
|
||||
log_mock = sys.modules["syslog_bridge"]
|
||||
nt_response = b"\xcc" * 32
|
||||
ts1 = mod._build_tsrequest_with_token(6, _ntlmssp_type1())
|
||||
ts3 = mod._build_tsrequest_with_token(6, _ntlmssp_type3("alice", "ACME", nt_response))
|
||||
|
||||
async def _run():
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(ts1 + ts3)
|
||||
reader.feed_eof()
|
||||
writer, written = _make_writer()
|
||||
await mod._handle_nla(reader, writer, "192.0.2.5", 51000)
|
||||
return written
|
||||
|
||||
written = asyncio.run(_run())
|
||||
# Server replied to Type 1 with a Type 2 challenge wrapped in TSRequest
|
||||
assert written, "expected a TSRequest response to Type 1"
|
||||
resp = b"".join(written)
|
||||
assert b"NTLMSSP\x00" in resp
|
||||
type_byte = resp[resp.index(b"NTLMSSP\x00") + 8]
|
||||
assert type_byte == 0x02
|
||||
|
||||
auth_calls = [
|
||||
c for c in log_mock.syslog_line.call_args_list
|
||||
if len(c.args) >= 3 and c.args[2] == "auth_attempt"
|
||||
]
|
||||
assert auth_calls
|
||||
kwargs = auth_calls[0].kwargs
|
||||
assert kwargs["principal"] == "ACME\\alice"
|
||||
assert kwargs["secret_kind"] == "ntlmssp_v2"
|
||||
assert kwargs["auth_path"] == "nla"
|
||||
|
||||
|
||||
def test_handle_nla_returns_cleanly_on_garbage(monkeypatch):
|
||||
mod = _load_rdp(monkeypatch=monkeypatch)
|
||||
|
||||
async def _run():
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(b"\x00\x01\x02\x03not a sequence")
|
||||
reader.feed_eof()
|
||||
writer, _ = _make_writer()
|
||||
await mod._handle_nla(reader, writer, "198.51.100.9", 1234)
|
||||
|
||||
asyncio.run(_run()) # must not raise
|
||||
|
||||
|
||||
def test_per_instance_challenge_is_not_constant_across_node_names(monkeypatch):
|
||||
monkeypatch.setenv("NODE_NAME", "decky-alpha")
|
||||
monkeypatch.setenv("RDP_ENABLE_NLA", "true")
|
||||
mod_a = _load_rdp(monkeypatch=monkeypatch)
|
||||
chal_a = mod_a.SERVER_CHALLENGE
|
||||
|
||||
monkeypatch.setenv("NODE_NAME", "decky-bravo")
|
||||
mod_b = _load_rdp(monkeypatch=monkeypatch)
|
||||
chal_b = mod_b.SERVER_CHALLENGE
|
||||
|
||||
assert chal_a != chal_b
|
||||
assert len(chal_a) == 8 and len(chal_b) == 8
|
||||
@@ -5,28 +5,27 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_fake_decnet_logging() -> ModuleType:
|
||||
mod = ModuleType("decnet_logging")
|
||||
mod.syslog_line = MagicMock(return_value="")
|
||||
mod.write_syslog_file = MagicMock()
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
return mod
|
||||
from tests.service_testing.conftest import (
|
||||
load_real_instance_seed,
|
||||
make_fake_syslog_bridge,
|
||||
)
|
||||
|
||||
|
||||
def _load_redis():
|
||||
env = {"NODE_NAME": "testredis"}
|
||||
for key in list(sys.modules):
|
||||
if key in ("redis_server", "decnet_logging"):
|
||||
del sys.modules[key]
|
||||
def _load_redis(node_name: str = "testredis"):
|
||||
env = {"NODE_NAME": node_name}
|
||||
for key in ("redis_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
|
||||
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
|
||||
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
||||
|
||||
spec = importlib.util.spec_from_file_location("redis_server", "templates/redis/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
# Pin NODE_NAME before loading instance_seed — the seed is derived at
|
||||
# import time.
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"redis_server", "decnet/templates/redis/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
@@ -39,6 +38,7 @@ def redis_mod():
|
||||
def _make_protocol(mod):
|
||||
proto = mod.RedisProtocol()
|
||||
transport = MagicMock()
|
||||
transport.is_closing.return_value = False
|
||||
written: list[bytes] = []
|
||||
transport.write.side_effect = written.append
|
||||
proto.connection_made(transport)
|
||||
@@ -51,54 +51,73 @@ def _send(proto, *lines: bytes) -> None:
|
||||
proto.data_received(line)
|
||||
|
||||
|
||||
def test_auth_accepted(redis_mod):
|
||||
def test_auth_with_no_password_configured(redis_mod, monkeypatch):
|
||||
"""Default config has no REDIS_PASSWORD — real redis rejects AUTH with
|
||||
the 'no password is set' ERR message. Accepting any AUTH blindly (the
|
||||
old behavior) is a honeypot tell."""
|
||||
proto, _, written = _make_protocol(redis_mod)
|
||||
_send(proto, b"AUTH password\r\n")
|
||||
assert b"".join(written) == b"+OK\r\n"
|
||||
resp = b"".join(written)
|
||||
assert resp.startswith(b"-ERR")
|
||||
assert b"no password is set" in resp
|
||||
|
||||
|
||||
def test_keys_wildcard(redis_mod):
|
||||
def test_keys_pattern_yields_subset(redis_mod):
|
||||
"""Fake store contents are now per-instance. We can't assert exact keys,
|
||||
but KEYS with a narrow prefix should still return a proper RESP array
|
||||
whose length matches the filtered subset."""
|
||||
proto, _, written = _make_protocol(redis_mod)
|
||||
_send(proto, b"*2\r\n$4\r\nKEYS\r\n$8\r\nsession:\r\n")
|
||||
response = b"".join(written)
|
||||
assert response.startswith(b"*0\r\n") # no key equals literal "session:"
|
||||
|
||||
|
||||
def test_keys_star_returns_all(redis_mod):
|
||||
proto, _, written = _make_protocol(redis_mod)
|
||||
_send(proto, b"*2\r\n$4\r\nKEYS\r\n$1\r\n*\r\n")
|
||||
response = b"".join(written)
|
||||
assert response.startswith(b"*10\r\n")
|
||||
assert b"config:aws_access_key" in response
|
||||
# First bulk line is the array length; at least 1 key must exist.
|
||||
assert response.startswith(b"*")
|
||||
count = int(response.split(b"\r\n", 1)[0][1:])
|
||||
assert count >= 1
|
||||
|
||||
|
||||
def test_keys_prefix(redis_mod):
|
||||
def test_config_get_returns_real_kv_pairs(redis_mod):
|
||||
"""Old server returned *0 for CONFIG — a strong honeypot signature.
|
||||
New behavior returns real config key/value pairs."""
|
||||
proto, _, written = _make_protocol(redis_mod)
|
||||
_send(proto, b"*2\r\n$4\r\nKEYS\r\n$6\r\nuser:*\r\n")
|
||||
# Use a wildcard to make the length prefix unambiguous and match both
|
||||
# "maxmemory" and "maxmemory-policy".
|
||||
_send(proto, b"*3\r\n$6\r\nCONFIG\r\n$3\r\nGET\r\n$10\r\nmaxmemory*\r\n")
|
||||
response = b"".join(written)
|
||||
assert response.startswith(b"*2\r\n")
|
||||
assert b"user:admin" in response
|
||||
assert response.startswith(b"*4\r\n") # 2 keys × (key+value) = 4 elements
|
||||
assert b"maxmemory" in response
|
||||
assert b"maxmemory-policy" in response
|
||||
|
||||
|
||||
def test_get_valid_key(redis_mod):
|
||||
def test_info_has_dynamic_uptime(redis_mod):
|
||||
proto, _, written = _make_protocol(redis_mod)
|
||||
_send(proto, b"*2\r\n$3\r\nGET\r\n$13\r\ncache:api_key\r\n")
|
||||
_send(proto, b"*1\r\n$4\r\nINFO\r\n")
|
||||
response = b"".join(written)
|
||||
assert response == b"$38\r\nsk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ\r\n"
|
||||
assert b"uptime_in_seconds:" in response
|
||||
# Old server hard-coded 864000 — ensure we're not regressing.
|
||||
assert b"uptime_in_seconds:864000\r\n" not in response
|
||||
|
||||
|
||||
def test_get_invalid_key(redis_mod):
|
||||
proto, _, written = _make_protocol(redis_mod)
|
||||
_send(proto, b"*2\r\n$3\r\nGET\r\n$7\r\nunknown\r\n")
|
||||
response = b"".join(written)
|
||||
assert response == b"$-1\r\n"
|
||||
|
||||
|
||||
def test_scan(redis_mod):
|
||||
proto, _, written = _make_protocol(redis_mod)
|
||||
_send(proto, b"*1\r\n$4\r\nSCAN\r\n")
|
||||
response = b"".join(written)
|
||||
assert response.startswith(b"*2\r\n$1\r\n0\r\n*10\r\n")
|
||||
def test_per_instance_version_differs_across_decky_names():
|
||||
"""Two deckies with different NODE_NAMEs should, with high probability,
|
||||
pick different redis versions from the weighted pool."""
|
||||
picks: set[str] = set()
|
||||
for name in ("decky-a", "decky-b", "decky-c", "decky-d", "decky-e", "decky-f"):
|
||||
mod = _load_redis(node_name=name)
|
||||
picks.add(mod._REDIS_VER)
|
||||
assert len(picks) >= 2
|
||||
|
||||
|
||||
def test_type_and_ttl(redis_mod):
|
||||
proto, _, written = _make_protocol(redis_mod)
|
||||
_send(proto, b"TYPE somekey\r\n")
|
||||
assert b"".join(written) == b"+string\r\n"
|
||||
assert b"+string\r\n" in b"".join(written)
|
||||
written.clear()
|
||||
|
||||
_send(proto, b"TTL somekey\r\n")
|
||||
assert b"".join(written) == b":-1\r\n"
|
||||
assert b":-1\r\n" in b"".join(written)
|
||||
|
||||
268
tests/service_testing/test_smb_server.py
Normal file
268
tests/service_testing/test_smb_server.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Tests for decnet/templates/smb/server.py — hand-rolled SMB2 framer.
|
||||
|
||||
Drives the asyncio handler with an in-memory StreamReader and a mocked
|
||||
StreamWriter. Exercises the full Negotiate → SessionSetup(Type1) →
|
||||
SessionSetup(Type3) flow and asserts that an NTLMSSP Type 3 lands in
|
||||
the universal credential SD shape.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import load_real_instance_seed, make_fake_syslog_bridge
|
||||
|
||||
|
||||
# ── Module loader ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_real_ntlmssp():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"ntlmssp", "decnet/templates/_shared/ntlmssp.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_smb():
|
||||
for key in ("smb_server", "syslog_bridge", "instance_seed", "ntlmssp"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
sys.modules["ntlmssp"] = _load_real_ntlmssp()
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"smb_server", "decnet/templates/smb/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smb_mod():
|
||||
return _load_smb()
|
||||
|
||||
|
||||
def _make_streams():
|
||||
"""Return (reader, writer, written) — writer.write() collects bytes.
|
||||
|
||||
Must be called from inside a running event loop because
|
||||
asyncio.StreamReader's __init__ needs one in Python 3.11.
|
||||
"""
|
||||
reader = asyncio.StreamReader()
|
||||
writer = MagicMock()
|
||||
written: list[bytes] = []
|
||||
writer.write.side_effect = written.append
|
||||
writer.get_extra_info.return_value = ("198.51.100.7", 51234)
|
||||
|
||||
async def _wait_closed():
|
||||
return None
|
||||
|
||||
writer.wait_closed = _wait_closed
|
||||
return reader, writer, written
|
||||
|
||||
|
||||
# ── PDU builders ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _nbss(payload: bytes) -> bytes:
|
||||
return bytes([0x00]) + len(payload).to_bytes(3, "big") + payload
|
||||
|
||||
|
||||
def _smb2_header(command: int, message_id: int, session_id: int = 0) -> bytes:
|
||||
return (
|
||||
b"\xfeSMB"
|
||||
+ struct.pack("<H", 64)
|
||||
+ struct.pack("<H", 0)
|
||||
+ struct.pack("<I", 0)
|
||||
+ struct.pack("<H", command)
|
||||
+ struct.pack("<H", 1)
|
||||
+ struct.pack("<I", 0)
|
||||
+ struct.pack("<I", 0)
|
||||
+ struct.pack("<Q", message_id)
|
||||
+ struct.pack("<I", 0)
|
||||
+ struct.pack("<I", 0)
|
||||
+ struct.pack("<Q", session_id)
|
||||
+ b"\x00" * 16
|
||||
)
|
||||
|
||||
|
||||
def _negotiate_request() -> bytes:
|
||||
# SMB2 NEGOTIATE Request (MS-SMB2 §2.2.3) — minimal, 1 dialect
|
||||
body = (
|
||||
struct.pack("<H", 36) # StructureSize
|
||||
+ struct.pack("<H", 1) # DialectCount
|
||||
+ struct.pack("<H", 0) # SecurityMode
|
||||
+ struct.pack("<H", 0) # Reserved
|
||||
+ struct.pack("<I", 0) # Capabilities
|
||||
+ b"\x00" * 16 # ClientGuid
|
||||
+ struct.pack("<Q", 0) # ClientStartTime
|
||||
+ struct.pack("<H", 0x0210) # Dialect = SMB 2.1
|
||||
+ struct.pack("<H", 0) # padding
|
||||
)
|
||||
return _smb2_header(0x0000, 0) + body
|
||||
|
||||
|
||||
def _session_setup_request(message_id: int, sec_blob: bytes) -> bytes:
|
||||
body = (
|
||||
struct.pack("<H", 25) # StructureSize
|
||||
+ struct.pack("<B", 0) # Flags
|
||||
+ struct.pack("<B", 0) # SecurityMode
|
||||
+ struct.pack("<I", 0) # Capabilities
|
||||
+ struct.pack("<I", 0) # Channel
|
||||
+ struct.pack("<H", 64 + 24) # SecurityBufferOffset
|
||||
+ struct.pack("<H", len(sec_blob))
|
||||
+ struct.pack("<Q", 0) # PreviousSessionId
|
||||
)
|
||||
return _smb2_header(0x0001, message_id) + body + sec_blob
|
||||
|
||||
|
||||
def _ntlmssp_type1() -> bytes:
|
||||
return b"NTLMSSP\x00" + struct.pack("<I", 1) + struct.pack("<I", 0xE2088297) + b"\x00" * 24
|
||||
|
||||
|
||||
def _ntlmssp_type3(username: str, domain: str, nt_response: bytes) -> bytes:
|
||||
"""Build a minimal valid NTLMSSP Type 3 with NEGOTIATE_UNICODE."""
|
||||
user_b = username.encode("utf-16-le")
|
||||
dom_b = domain.encode("utf-16-le")
|
||||
workstation = b""
|
||||
payload = nt_response + dom_b + user_b + workstation
|
||||
|
||||
# 64-byte header + 8-byte version
|
||||
nt_off = 72
|
||||
dom_off = nt_off + len(nt_response)
|
||||
user_off = dom_off + len(dom_b)
|
||||
ws_off = user_off + len(user_b)
|
||||
flags = 0x00000001 # NEGOTIATE_UNICODE
|
||||
|
||||
return (
|
||||
b"NTLMSSP\x00"
|
||||
+ struct.pack("<I", 3)
|
||||
+ struct.pack("<HHI", 0, 0, ws_off) # LmChallengeResponseFields (empty)
|
||||
+ struct.pack("<HHI", len(nt_response), len(nt_response), nt_off)
|
||||
+ struct.pack("<HHI", len(dom_b), len(dom_b), dom_off)
|
||||
+ struct.pack("<HHI", len(user_b), len(user_b), user_off)
|
||||
+ struct.pack("<HHI", 0, 0, ws_off) # WorkstationFields (empty)
|
||||
+ struct.pack("<HHI", 0, 0, ws_off) # EncryptedRandomSessionKey (empty)
|
||||
+ struct.pack("<I", flags)
|
||||
+ b"\x00" * 8 # Version
|
||||
+ payload
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _drive(smb_mod, request_bytes: bytes):
|
||||
async def _run():
|
||||
reader, writer, written = _make_streams()
|
||||
reader.feed_data(request_bytes)
|
||||
reader.feed_eof()
|
||||
await asyncio.wait_for(smb_mod._handle_client(reader, writer), timeout=2.0)
|
||||
return writer, written
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_negotiate_response_is_smb2_dialect_0x0210(smb_mod):
|
||||
_, written = _drive(smb_mod, _nbss(_negotiate_request()))
|
||||
blob = b"".join(written)
|
||||
# Skip NBSS header (4 bytes), then SMB2 header (64), body StructureSize, body[2:4]=DialectRevision
|
||||
assert blob[:4] == b"\x00\x00\x00\x83" or blob[0] == 0x00
|
||||
smb = blob[4:]
|
||||
assert smb.startswith(b"\xfeSMB")
|
||||
body = smb[64:]
|
||||
dialect = struct.unpack_from("<H", body, 4)[0]
|
||||
assert dialect == 0x0210
|
||||
|
||||
|
||||
def test_first_session_setup_returns_more_processing_required(smb_mod):
|
||||
pkt1 = _nbss(_negotiate_request())
|
||||
pkt2 = _nbss(_session_setup_request(1, _ntlmssp_type1()))
|
||||
_, written = _drive(smb_mod, pkt1 + pkt2)
|
||||
# second response
|
||||
assert len(written) >= 2
|
||||
smb = written[1][4:]
|
||||
status = struct.unpack_from("<I", smb, 8)[0]
|
||||
assert status == 0xC0000016 # STATUS_MORE_PROCESSING_REQUIRED
|
||||
# SecurityBuffer should carry an NTLMSSP Type 2
|
||||
body = smb[64:]
|
||||
sec_off = struct.unpack_from("<H", body, 4)[0]
|
||||
sec_len = struct.unpack_from("<H", body, 6)[0]
|
||||
sec = smb[sec_off:sec_off + sec_len]
|
||||
assert b"NTLMSSP\x00" in sec
|
||||
type_byte = sec[sec.index(b"NTLMSSP\x00") + 8]
|
||||
assert type_byte == 0x02
|
||||
|
||||
|
||||
def test_type3_credential_lands_in_log():
|
||||
mod = _load_smb()
|
||||
log_mock = sys.modules["syslog_bridge"]
|
||||
nt_response = b"\xaa" * 32 # 32-byte NTLMv2 response
|
||||
type3 = _ntlmssp_type3("alice", "ACME", nt_response)
|
||||
pkts = (
|
||||
_nbss(_negotiate_request())
|
||||
+ _nbss(_session_setup_request(1, _ntlmssp_type1()))
|
||||
+ _nbss(_session_setup_request(2, type3))
|
||||
)
|
||||
_drive(mod, pkts)
|
||||
|
||||
# Find the auth_attempt call
|
||||
auth_calls = [
|
||||
c for c in log_mock.syslog_line.call_args_list
|
||||
if len(c.args) >= 3 and c.args[2] == "auth_attempt"
|
||||
]
|
||||
assert auth_calls, f"no auth_attempt logged; got: {log_mock.syslog_line.call_args_list}"
|
||||
kwargs = auth_calls[0].kwargs
|
||||
assert kwargs["principal"] == "ACME\\alice"
|
||||
assert kwargs["secret_kind"] == "ntlmssp_v2"
|
||||
assert kwargs["username"] == "alice"
|
||||
assert kwargs["domain"] == "ACME"
|
||||
assert "secret_b64" in kwargs and kwargs["secret_b64"]
|
||||
|
||||
|
||||
def test_second_session_setup_returns_logon_failure(smb_mod):
|
||||
nt_response = b"\xbb" * 32
|
||||
type3 = _ntlmssp_type3("bob", "", nt_response)
|
||||
pkts = (
|
||||
_nbss(_negotiate_request())
|
||||
+ _nbss(_session_setup_request(1, _ntlmssp_type1()))
|
||||
+ _nbss(_session_setup_request(2, type3))
|
||||
)
|
||||
_, written = _drive(smb_mod, pkts)
|
||||
smb = written[-1][4:]
|
||||
status = struct.unpack_from("<I", smb, 8)[0]
|
||||
assert status == 0xC000006D # STATUS_LOGON_FAILURE
|
||||
|
||||
|
||||
def test_oversized_nbss_length_drops_connection(smb_mod):
|
||||
# nb_len = 8 MiB > MAX_NBSS_LEN; framer should bail before allocating
|
||||
bad = bytes([0x00]) + (8 * 1024 * 1024).to_bytes(3, "big")
|
||||
_, written = _drive(smb_mod, bad)
|
||||
assert written == []
|
||||
|
||||
|
||||
def test_smb1_negotiate_drops_connection(smb_mod):
|
||||
# 0xff 'SMB' is the SMB1 magic — our framer doesn't speak it
|
||||
pdu = b"\xffSMB" + b"\x00" * 60
|
||||
_, written = _drive(smb_mod, _nbss(pdu))
|
||||
assert written == []
|
||||
|
||||
|
||||
def test_short_pdu_below_64_drops(smb_mod):
|
||||
# NBSS length < 64 should be rejected
|
||||
bad = bytes([0x00]) + (32).to_bytes(3, "big") + b"\x00" * 32
|
||||
_, written = _drive(smb_mod, bad)
|
||||
assert written == []
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/smtp/server.py
|
||||
Tests for decnet/templates/smtp/server.py
|
||||
|
||||
Exercises both modes:
|
||||
- credential-harvester (SMTP_OPEN_RELAY=0, default)
|
||||
@@ -19,33 +19,42 @@ import pytest
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_fake_decnet_logging() -> ModuleType:
|
||||
"""Return a stub decnet_logging module that does nothing."""
|
||||
mod = ModuleType("decnet_logging")
|
||||
def _make_fake_syslog_bridge() -> ModuleType:
|
||||
"""Return a stub syslog_bridge module that does nothing."""
|
||||
mod = ModuleType("syslog_bridge")
|
||||
mod.syslog_line = MagicMock(return_value="")
|
||||
mod.write_syslog_file = MagicMock()
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
mod.encode_secret = MagicMock(return_value={"secret_printable": "", "secret_b64": ""})
|
||||
mod.classify_authorization = MagicMock(return_value=None)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_smtp(open_relay: bool):
|
||||
"""Import smtp server module with desired OPEN_RELAY value.
|
||||
|
||||
Injects a stub decnet_logging into sys.modules so the template can import
|
||||
Injects a stub syslog_bridge into sys.modules so the template can import
|
||||
it without needing the real file on sys.path.
|
||||
"""
|
||||
env = {"SMTP_OPEN_RELAY": "1" if open_relay else "0", "NODE_NAME": "testhost"}
|
||||
for key in list(sys.modules):
|
||||
if key in ("smtp_server", "decnet_logging"):
|
||||
del sys.modules[key]
|
||||
env = {
|
||||
"SMTP_OPEN_RELAY": "1" if open_relay else "0",
|
||||
"NODE_NAME": "testhost",
|
||||
# Force deterministic RCPT acceptance in tests; relay filtering is
|
||||
# covered in its own dedicated test class below.
|
||||
"SMTP_RCPT_DROP_RATE": "0",
|
||||
}
|
||||
for key in ("smtp_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
|
||||
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
|
||||
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
|
||||
|
||||
spec = importlib.util.spec_from_file_location("smtp_server", "templates/smtp/server.py")
|
||||
spec = importlib.util.spec_from_file_location("smtp_server", "decnet/templates/smtp/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
from .conftest import load_real_instance_seed
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
@@ -114,6 +123,20 @@ def test_ehlo_returns_250_multiline(relay_mod):
|
||||
assert "PIPELINING" in combined
|
||||
|
||||
|
||||
def test_ehlo_empty_domain_rejected(relay_mod):
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
_send(proto, "EHLO")
|
||||
replies = _replies(written)
|
||||
assert any(r.startswith("501") for r in replies)
|
||||
|
||||
|
||||
def test_helo_empty_domain_rejected(relay_mod):
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
_send(proto, "HELO")
|
||||
replies = _replies(written)
|
||||
assert any(r.startswith("501") for r in replies)
|
||||
|
||||
|
||||
# ── OPEN RELAY MODE ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestOpenRelay:
|
||||
@@ -223,6 +246,63 @@ class TestOpenRelay:
|
||||
assert any("queued as" in r for r in replies)
|
||||
|
||||
|
||||
# ── OPEN-RELAY FILTERING ─────────────────────────────────────────────────────
|
||||
|
||||
class TestOpenRelayFiltering:
|
||||
"""Real open relays reject malformed/bogus RCPTs even when they accept
|
||||
external mail — a pure tarpit is a honeypot tell."""
|
||||
|
||||
@staticmethod
|
||||
def _session_with_env(env_extra: dict, *lines) -> list[str]:
|
||||
env = {
|
||||
"SMTP_OPEN_RELAY": "1",
|
||||
"NODE_NAME": "testhost",
|
||||
"SMTP_RCPT_DROP_RATE": "0",
|
||||
**env_extra,
|
||||
}
|
||||
for key in ("smtp_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"smtp_server", "decnet/templates/smtp/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
from .conftest import load_real_instance_seed
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec.loader.exec_module(mod)
|
||||
proto, _, written = _make_protocol(mod)
|
||||
_send(proto, *lines)
|
||||
return _replies(written)
|
||||
|
||||
def test_malformed_rcpt_returns_501(self):
|
||||
replies = self._session_with_env(
|
||||
{},
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<notanaddress>",
|
||||
)
|
||||
assert any(r.startswith("501") for r in replies)
|
||||
|
||||
def test_blocked_tld_returns_550(self):
|
||||
replies = self._session_with_env(
|
||||
{},
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<admin@foo.invalid>",
|
||||
)
|
||||
assert any(r.startswith("550") for r in replies)
|
||||
|
||||
def test_always_greylist_returns_451(self):
|
||||
replies = self._session_with_env(
|
||||
{"SMTP_RCPT_DROP_RATE": "1.0"},
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<victim@legit-domain.com>",
|
||||
)
|
||||
assert any(r.startswith("451") for r in replies)
|
||||
|
||||
|
||||
# ── CREDENTIAL HARVESTER MODE ─────────────────────────────────────────────────
|
||||
|
||||
class TestCredentialHarvester:
|
||||
@@ -282,10 +362,14 @@ class TestCredentialHarvester:
|
||||
# ── Queue ID ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_rand_msg_id_format(relay_mod):
|
||||
# Postfix queue IDs use a vowel-free alphabet (no aeiou, no 0/1) and
|
||||
# vary in length with the current microsecond magnitude — typically
|
||||
# 10-12 chars.
|
||||
postfix_alphabet = set("BCDFGHJKLMNPQRSTVWXYZ23456789")
|
||||
for _ in range(50):
|
||||
mid = relay_mod._rand_msg_id()
|
||||
assert len(mid) == 12
|
||||
assert mid.isalnum()
|
||||
assert 10 <= len(mid) <= 12
|
||||
assert set(mid).issubset(postfix_alphabet)
|
||||
|
||||
|
||||
# ── AUTH PLAIN decode ─────────────────────────────────────────────────────────
|
||||
@@ -301,3 +385,276 @@ def test_decode_auth_plain_garbage_no_raise(relay_mod):
|
||||
user, pw = relay_mod._decode_auth_plain("!!!notbase64!!!")
|
||||
assert isinstance(user, str)
|
||||
assert isinstance(pw, str)
|
||||
|
||||
|
||||
# ── Bare LF line endings ────────────────────────────────────────────────────
|
||||
|
||||
def _send_bare_lf(proto, *lines: str) -> None:
|
||||
"""Feed LF-only terminated lines to the protocol (simulates telnet/nc)."""
|
||||
for line in lines:
|
||||
proto.data_received((line + "\n").encode())
|
||||
|
||||
|
||||
def test_ehlo_works_with_bare_lf(relay_mod):
|
||||
"""Clients sending bare LF (telnet, nc) must get EHLO responses."""
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
_send_bare_lf(proto, "EHLO attacker.com")
|
||||
combined = b"".join(written).decode()
|
||||
assert "250" in combined
|
||||
assert "AUTH" in combined
|
||||
|
||||
|
||||
def test_full_session_with_bare_lf(relay_mod):
|
||||
"""A complete relay session using bare LF line endings."""
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
_send_bare_lf(
|
||||
proto,
|
||||
"EHLO attacker.com",
|
||||
"MAIL FROM:<hacker@evil.com>",
|
||||
"RCPT TO:<admin@target.com>",
|
||||
"DATA",
|
||||
"Subject: test",
|
||||
"",
|
||||
"body",
|
||||
".",
|
||||
"QUIT",
|
||||
)
|
||||
replies = _replies(written)
|
||||
assert any("queued as" in r for r in replies)
|
||||
assert any(r.startswith("221") for r in replies)
|
||||
|
||||
|
||||
def test_mixed_line_endings(relay_mod):
|
||||
"""A single data_received call containing a mix of CRLF and bare LF."""
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
proto.data_received(b"EHLO test.com\r\nMAIL FROM:<a@b.com>\nRCPT TO:<c@d.com>\r\n")
|
||||
replies = _replies(written)
|
||||
assert any("250" in r for r in replies)
|
||||
assert any(r.startswith("250 2.1.0") for r in replies)
|
||||
assert any(r.startswith("250 2.1.5") for r in replies)
|
||||
|
||||
|
||||
# ── AUTH PLAIN continuation (no inline credentials) ──────────────────────────
|
||||
|
||||
def test_auth_plain_continuation_relay(relay_mod):
|
||||
"""AUTH PLAIN without inline creds should prompt then accept on next line."""
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
_send(proto, "AUTH PLAIN")
|
||||
replies = _replies(written)
|
||||
assert any(r.startswith("334") for r in replies), "Expected 334 continuation"
|
||||
written.clear()
|
||||
creds = base64.b64encode(b"\x00admin\x00password").decode()
|
||||
_send(proto, creds)
|
||||
replies = _replies(written)
|
||||
assert any(r.startswith("235") for r in replies), "Expected 235 auth success"
|
||||
|
||||
|
||||
def test_auth_plain_continuation_harvester(harvester_mod):
|
||||
"""AUTH PLAIN continuation in harvester mode should reject with 535."""
|
||||
proto, _, written = _make_protocol(harvester_mod)
|
||||
_send(proto, "AUTH PLAIN")
|
||||
replies = _replies(written)
|
||||
assert any(r.startswith("334") for r in replies)
|
||||
written.clear()
|
||||
creds = base64.b64encode(b"\x00admin\x00password").decode()
|
||||
_send(proto, creds)
|
||||
replies = _replies(written)
|
||||
assert any(r.startswith("535") for r in replies)
|
||||
|
||||
|
||||
# ── FULL-MESSAGE CAPTURE (quarantine + message_stored event) ─────────────────
|
||||
|
||||
def _load_smtp_with_quarantine(quarantine_dir: str, max_body_bytes: int | None = None):
|
||||
"""Reload the SMTP template with a quarantine dir + optional body cap.
|
||||
|
||||
Same mechanics as _load_smtp but threads extra env through so the module's
|
||||
capture-path code is exercised end-to-end (file write + parse).
|
||||
"""
|
||||
env = {
|
||||
"SMTP_OPEN_RELAY": "1",
|
||||
"NODE_NAME": "testhost",
|
||||
"SMTP_RCPT_DROP_RATE": "0",
|
||||
"SMTP_QUARANTINE_DIR": quarantine_dir,
|
||||
}
|
||||
if max_body_bytes is not None:
|
||||
env["SMTP_MAX_BODY_BYTES"] = str(max_body_bytes)
|
||||
for key in ("smtp_server", "syslog_bridge", "instance_seed"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"smtp_server", "decnet/templates/smtp/server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
from .conftest import load_real_instance_seed
|
||||
sys.modules["instance_seed"] = load_real_instance_seed()
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _logged_events(mod) -> list[tuple[str, dict]]:
|
||||
"""Return every (event_type, fields) tuple syslog_bridge was called with."""
|
||||
calls = mod.syslog_line.call_args_list
|
||||
events: list[tuple[str, dict]] = []
|
||||
for call in calls:
|
||||
args, kwargs = call
|
||||
# syslog_line(service, hostname, event_type, severity=..., **fields)
|
||||
event_type = args[2] if len(args) > 2 else kwargs.get("event_type", "")
|
||||
# Strip positional service/hostname/event_type/severity when present.
|
||||
fields = dict(kwargs)
|
||||
fields.pop("severity", None)
|
||||
events.append((event_type, fields))
|
||||
return events
|
||||
|
||||
|
||||
class TestMessageCapture:
|
||||
|
||||
def test_message_stored_event_written(self, tmp_path):
|
||||
mod = _load_smtp_with_quarantine(str(tmp_path))
|
||||
proto, _, _ = _make_protocol(mod)
|
||||
_send(
|
||||
proto,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<spam@evil.com>",
|
||||
"RCPT TO:<victim@target.com>",
|
||||
"DATA",
|
||||
"Subject: hello",
|
||||
"From: spam@evil.com",
|
||||
"",
|
||||
"body line",
|
||||
".",
|
||||
)
|
||||
events = _logged_events(mod)
|
||||
stored = [f for t, f in events if t == "message_stored"]
|
||||
assert len(stored) == 1, f"expected 1 message_stored event, got {events}"
|
||||
rec = stored[0]
|
||||
assert rec["subject"] == "hello"
|
||||
assert rec["from_hdr"] == "spam@evil.com"
|
||||
assert rec["mail_from"] == "<spam@evil.com>"
|
||||
assert rec["rcpt_to"] == "<victim@target.com>"
|
||||
assert rec["attachment_count"] == 0
|
||||
# Filename matches artifact endpoint's regex.
|
||||
import re as _re
|
||||
assert _re.fullmatch(
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z_[a-f0-9]{12}_[A-Za-z0-9._-]{1,255}",
|
||||
rec["stored_as"],
|
||||
)
|
||||
|
||||
def test_message_file_written_to_quarantine(self, tmp_path):
|
||||
mod = _load_smtp_with_quarantine(str(tmp_path))
|
||||
proto, _, _ = _make_protocol(mod)
|
||||
_send(
|
||||
proto,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<c@d.com>",
|
||||
"DATA",
|
||||
"Subject: test",
|
||||
"",
|
||||
"payload bytes",
|
||||
".",
|
||||
)
|
||||
files = list(tmp_path.iterdir())
|
||||
assert len(files) == 1
|
||||
contents = files[0].read_bytes()
|
||||
assert b"Subject: test" in contents
|
||||
assert b"payload bytes" in contents
|
||||
assert files[0].name.endswith(".eml")
|
||||
|
||||
def test_attachment_manifest_captured(self, tmp_path):
|
||||
"""A multipart message with an attachment must report filename + sha256."""
|
||||
import hashlib as _hashlib
|
||||
mod = _load_smtp_with_quarantine(str(tmp_path))
|
||||
proto, _, _ = _make_protocol(mod)
|
||||
boundary = "----ABC"
|
||||
payload = b"MZ\x90\x00fake-exe-bytes"
|
||||
import base64 as _b64
|
||||
payload_b64 = _b64.b64encode(payload).decode()
|
||||
_send(
|
||||
proto,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<c@d.com>",
|
||||
"DATA",
|
||||
"Subject: malware",
|
||||
f"Content-Type: multipart/mixed; boundary={boundary}",
|
||||
"MIME-Version: 1.0",
|
||||
"",
|
||||
f"--{boundary}",
|
||||
'Content-Type: text/plain',
|
||||
"",
|
||||
"see attached",
|
||||
f"--{boundary}",
|
||||
'Content-Type: application/octet-stream; name="payload.exe"',
|
||||
'Content-Disposition: attachment; filename="payload.exe"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
payload_b64,
|
||||
f"--{boundary}--",
|
||||
".",
|
||||
)
|
||||
events = _logged_events(mod)
|
||||
stored = [f for t, f in events if t == "message_stored"]
|
||||
assert len(stored) == 1
|
||||
rec = stored[0]
|
||||
assert rec["attachment_count"] == 1
|
||||
import json as _json
|
||||
manifest = _json.loads(rec["attachments_json"])
|
||||
assert len(manifest) == 1
|
||||
assert manifest[0]["filename"] == "payload.exe"
|
||||
assert manifest[0]["sha256"] == _hashlib.sha256(payload).hexdigest()
|
||||
assert manifest[0]["size"] == len(payload)
|
||||
|
||||
def test_capture_disabled_when_dir_unset(self, tmp_path, relay_mod):
|
||||
"""With SMTP_QUARANTINE_DIR unset, message_accepted fires but no
|
||||
message_stored event and no files are written."""
|
||||
proto, _, _ = _make_protocol(relay_mod)
|
||||
_send(
|
||||
proto,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<c@d.com>",
|
||||
"DATA",
|
||||
"Subject: no-capture",
|
||||
"",
|
||||
"body",
|
||||
".",
|
||||
)
|
||||
events = _logged_events(relay_mod)
|
||||
assert any(t == "message_accepted" for t, _ in events)
|
||||
assert not any(t == "message_stored" for t, _ in events)
|
||||
|
||||
def test_body_size_cap_truncates(self, tmp_path):
|
||||
"""Body beyond SMTP_MAX_BODY_BYTES is dropped but the session still
|
||||
terminates and truncated=1 is flagged."""
|
||||
mod = _load_smtp_with_quarantine(str(tmp_path), max_body_bytes=64)
|
||||
proto, _, _ = _make_protocol(mod)
|
||||
big_line = "A" * 500
|
||||
_send(
|
||||
proto,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<c@d.com>",
|
||||
"DATA",
|
||||
"Subject: big",
|
||||
"",
|
||||
big_line,
|
||||
big_line,
|
||||
".",
|
||||
)
|
||||
events = _logged_events(mod)
|
||||
stored = [f for t, f in events if t == "message_stored"]
|
||||
accepted = [f for t, f in events if t == "message_accepted"]
|
||||
assert accepted and accepted[0]["truncated"] == 1
|
||||
# File still written with whatever we managed to buffer.
|
||||
assert len(list(tmp_path.iterdir())) == 1
|
||||
assert stored and stored[0]["truncated"] == 1
|
||||
|
||||
def test_rset_resets_body_state(self, tmp_path):
|
||||
"""RSET must clear data_bytes + truncated flag so a fresh transaction
|
||||
is not accounted against the prior one."""
|
||||
mod = _load_smtp_with_quarantine(str(tmp_path))
|
||||
proto, _, _ = _make_protocol(mod)
|
||||
_send(proto, "EHLO x.com", "MAIL FROM:<a@b.com>", "RCPT TO:<c@d.com>", "RSET")
|
||||
assert proto._data_bytes == 0
|
||||
assert proto._data_truncated is False
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for templates/snmp/server.py
|
||||
Tests for decnet/templates/snmp/server.py
|
||||
|
||||
Exercises behavior with SNMP_ARCHETYPE modifications.
|
||||
Uses asyncio DatagramProtocol directly.
|
||||
@@ -15,8 +15,8 @@ import pytest
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_fake_decnet_logging() -> ModuleType:
|
||||
mod = ModuleType("decnet_logging")
|
||||
def _make_fake_syslog_bridge() -> ModuleType:
|
||||
mod = ModuleType("syslog_bridge")
|
||||
def syslog_line(*args, **kwargs):
|
||||
print("LOG:", args, kwargs)
|
||||
return ""
|
||||
@@ -25,6 +25,8 @@ def _make_fake_decnet_logging() -> ModuleType:
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
mod.encode_secret = MagicMock(return_value={"secret_printable": "", "secret_b64": ""})
|
||||
mod.classify_authorization = MagicMock(return_value=None)
|
||||
return mod
|
||||
|
||||
|
||||
@@ -34,12 +36,12 @@ def _load_snmp(archetype: str = "default"):
|
||||
"SNMP_ARCHETYPE": archetype,
|
||||
}
|
||||
for key in list(sys.modules):
|
||||
if key in ("snmp_server", "decnet_logging"):
|
||||
if key in ("snmp_server", "syslog_bridge"):
|
||||
del sys.modules[key]
|
||||
|
||||
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
|
||||
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
|
||||
|
||||
spec = importlib.util.spec_from_file_location("snmp_server", "templates/snmp/server.py")
|
||||
spec = importlib.util.spec_from_file_location("snmp_server", "decnet/templates/snmp/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
Reference in New Issue
Block a user