merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

@@ -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

View File

@@ -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):

View 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

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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):

View 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 ")

View File

@@ -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()

View 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 == []

View 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

View File

@@ -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)

View 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 == []

View File

@@ -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

View File

@@ -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)