diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index d299346..4236e84 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -9,7 +9,15 @@
"Bash(pip show:*)",
"Bash(python:*)",
"Bash(DECNET_JWT_SECRET=\"test-secret-xyz-1234!\" DECNET_ADMIN_PASSWORD=\"test-pass-xyz-1234!\" python:*)",
- "Bash(ls /home/anti/Tools/DECNET/*.db* /home/anti/Tools/DECNET/test_*.db*)"
+ "Bash(ls /home/anti/Tools/DECNET/*.db* /home/anti/Tools/DECNET/test_*.db*)",
+ "mcp__plugin_context-mode_context-mode__ctx_execute_file",
+ "Bash(nc)",
+ "Bash(nmap:*)",
+ "Bash(ping -c1 -W2 192.168.1.200)",
+ "Bash(xxd)",
+ "Bash(curl -s http://192.168.1.200:2375/version)",
+ "Bash(python3 -m json.tool)",
+ "Bash(curl -s http://192.168.1.200:9200/)"
]
}
}
diff --git a/.hypothesis/constants/0723f1d37b1d6520 b/.hypothesis/constants/0723f1d37b1d6520
new file mode 100644
index 0000000..054b007
--- /dev/null
+++ b/.hypothesis/constants/0723f1d37b1d6520
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/repository.py
+# hypothesis_version: 6.151.11
+
+[]
\ No newline at end of file
diff --git a/.hypothesis/constants/07a22c69f66b85d8 b/.hypothesis/constants/07a22c69f66b85d8
new file mode 100644
index 0000000..f5f3347
--- /dev/null
+++ b/.hypothesis/constants/07a22c69f66b85d8
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/database.py
+# hypothesis_version: 6.151.12
+
+['file:', 'sqlite+aiosqlite:///', 'sqlite:///', 'uri']
\ No newline at end of file
diff --git a/.hypothesis/constants/09ef744ca56274d2 b/.hypothesis/constants/09ef744ca56274d2
new file mode 100644
index 0000000..178345e
--- /dev/null
+++ b/.hypothesis/constants/09ef744ca56274d2
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.12
+
+[',', '.env', '.env.local', '0.0.0.0', '8000', '8080', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'PYTEST_CURRENT_TEST', 'PYTEST_VERSION', 'admin', 'changeme', 'password', 'secret', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/0a0f6b9dba9c3bb0 b/.hypothesis/constants/0a0f6b9dba9c3bb0
new file mode 100644
index 0000000..f8c12d0
--- /dev/null
+++ b/.hypothesis/constants/0a0f6b9dba9c3bb0
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/database.py
+# hypothesis_version: 6.151.11
+
+[]
\ No newline at end of file
diff --git a/.hypothesis/constants/0b7e12fbe6188a74 b/.hypothesis/constants/0b7e12fbe6188a74
new file mode 100644
index 0000000..36f85aa
--- /dev/null
+++ b/.hypothesis/constants/0b7e12fbe6188a74
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.12
+
+[8000, 8080, 65535, ',', '.env', '.env.local', '0.0.0.0', '127.0.0.1', '::', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'PYTEST', 'admin', 'changeme', 'localhost', 'password', 'secret', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/136282b746ebc317 b/.hypothesis/constants/136282b746ebc317
new file mode 100644
index 0000000..fed2e87
--- /dev/null
+++ b/.hypothesis/constants/136282b746ebc317
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.12
+
+[',', '.env', '.env.local', '0.0.0.0', '8000', '8080', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'PYTEST', 'admin', 'changeme', 'password', 'secret', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/16c3436398292e7b b/.hypothesis/constants/16c3436398292e7b
new file mode 100644
index 0000000..cd00ccd
--- /dev/null
+++ b/.hypothesis/constants/16c3436398292e7b
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_deploy_deckies.py
+# hypothesis_version: 6.151.12
+
+[400, 500, '/deckies/deploy', 'Fleet Management', 'decnet.web.api', 'message', 'unihost']
\ No newline at end of file
diff --git a/.hypothesis/constants/18383420a6ccbe40 b/.hypothesis/constants/18383420a6ccbe40
new file mode 100644
index 0000000..8ef4c3b
--- /dev/null
+++ b/.hypothesis/constants/18383420a6ccbe40
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/stream/api_stream_events.py
+# hypothesis_version: 6.151.12
+
+[401, 422, '/stream', 'Not authenticated', 'Observability', 'Validation error', 'description', 'id', 'lastEventId', 'text/event-stream']
\ No newline at end of file
diff --git a/.hypothesis/constants/1ba0b973b7599de4 b/.hypothesis/constants/1ba0b973b7599de4
new file mode 100644
index 0000000..82e6a33
--- /dev/null
+++ b/.hypothesis/constants/1ba0b973b7599de4
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_get_deckies.py
+# hypothesis_version: 6.151.12
+
+[401, 422, '/deckies', 'Fleet Management', 'Not authenticated', 'Validation error', 'description']
\ No newline at end of file
diff --git a/.hypothesis/constants/2478ea431ee48b03 b/.hypothesis/constants/2478ea431ee48b03
new file mode 100644
index 0000000..f1a485c
--- /dev/null
+++ b/.hypothesis/constants/2478ea431ee48b03
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/logs/api_get_histogram.py
+# hypothesis_version: 6.151.12
+
+[401, 422, '/logs/histogram', 'Logs', 'Not authenticated', 'Validation error', 'description']
\ No newline at end of file
diff --git a/.hypothesis/constants/2734e2e89a3a6860 b/.hypothesis/constants/2734e2e89a3a6860
new file mode 100644
index 0000000..cd00ccd
--- /dev/null
+++ b/.hypothesis/constants/2734e2e89a3a6860
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_deploy_deckies.py
+# hypothesis_version: 6.151.12
+
+[400, 500, '/deckies/deploy', 'Fleet Management', 'decnet.web.api', 'message', 'unihost']
\ No newline at end of file
diff --git a/.hypothesis/constants/2754c329ba29d0cd b/.hypothesis/constants/2754c329ba29d0cd
new file mode 100644
index 0000000..b236300
--- /dev/null
+++ b/.hypothesis/constants/2754c329ba29d0cd
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.12
+
+[',', '.env', '.env.local', '0.0.0.0', '8000', '8080', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'admin', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/28aabd7532da8ac5 b/.hypothesis/constants/28aabd7532da8ac5
new file mode 100644
index 0000000..6907985
--- /dev/null
+++ b/.hypothesis/constants/28aabd7532da8ac5
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/bounty/api_get_bounties.py
+# hypothesis_version: 6.151.12
+
+[1000, '/bounty', 'Bounty Vault', 'data', 'limit', 'offset', 'total']
\ No newline at end of file
diff --git a/.hypothesis/constants/2d0b4c8c54bbb44a b/.hypothesis/constants/2d0b4c8c54bbb44a
new file mode 100644
index 0000000..faea83d
--- /dev/null
+++ b/.hypothesis/constants/2d0b4c8c54bbb44a
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/dependencies.py
+# hypothesis_version: 6.151.12
+
+['/api/v1/auth/login', 'Authorization', 'Bearer', 'Bearer ', 'WWW-Authenticate', 'decnet.db', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/390599cfc019e671 b/.hypothesis/constants/390599cfc019e671
new file mode 100644
index 0000000..de80431
--- /dev/null
+++ b/.hypothesis/constants/390599cfc019e671
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/auth/api_change_pass.py
+# hypothesis_version: 6.151.12
+
+[401, 422, 'Authentication', 'Validation error', 'description', 'message', 'password_hash']
\ No newline at end of file
diff --git a/.hypothesis/constants/501339a603114c83 b/.hypothesis/constants/501339a603114c83
new file mode 100644
index 0000000..558909c
--- /dev/null
+++ b/.hypothesis/constants/501339a603114c83
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/api.py
+# hypothesis_version: 6.151.12
+
+[0.5, '/api/v1', '/docs', '/openapi.json', '/redoc', '1.0.0', 'Authorization', 'Content-Type', 'DELETE', 'GET', 'Last-Event-ID', 'OPTIONS', 'POST', 'PUT']
\ No newline at end of file
diff --git a/.hypothesis/constants/531c06e13f1be110 b/.hypothesis/constants/531c06e13f1be110
new file mode 100644
index 0000000..b72f330
--- /dev/null
+++ b/.hypothesis/constants/531c06e13f1be110
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/composer.py
+# hypothesis_version: 6.151.12
+
+['10m', '3.8', '5', 'BASE_IMAGE', 'HOSTNAME', 'NET_ADMIN', 'args', 'build', 'cap_add', 'command', 'container_name', 'depends_on', 'driver', 'environment', 'external', 'hostname', 'image', 'infinity', 'ipv4_address', 'json-file', 'logging', 'max-file', 'max-size', 'network_mode', 'networks', 'options', 'restart', 'services', 'sleep', 'sysctls', 'unless-stopped', 'version']
\ No newline at end of file
diff --git a/.hypothesis/constants/5807399fc21c16dd b/.hypothesis/constants/5807399fc21c16dd
new file mode 100644
index 0000000..1a13aad
--- /dev/null
+++ b/.hypothesis/constants/5807399fc21c16dd
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/deployer.py
+# hypothesis_version: 6.151.12
+
+[5.0, ', ', '--build', '--no-cache', '--watch', '-d', '-f', 'DECNET Deckies', 'Decky', 'Deployed Deckies', 'Hostname', 'IP', 'IPvlan', 'IPvlan L2', 'MACVLAN', 'Services', 'Status', '[green]up[/]', '[red]degraded[/]', 'absent', 'bold', 'build', 'cmdline', 'compose', 'decnet-compose.yml', 'decnet.cli', 'decnet.web.api:app', 'decnet_logging.py', 'docker', 'down', 'green', 'manifest for', 'manifest unknown', 'mutate', 'name', 'not found', 'pid', 'pull access denied', 'red', 'rm', 'running', 'stop', 'templates', 'up', 'uvicorn']
\ No newline at end of file
diff --git a/.hypothesis/constants/58af4768674ae1da b/.hypothesis/constants/58af4768674ae1da
new file mode 100644
index 0000000..f001b39
--- /dev/null
+++ b/.hypothesis/constants/58af4768674ae1da
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.12
+
+[8000, 8080, 65535, ',', '.env', '.env.local', '0.0.0.0', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'PYTEST', 'admin', 'changeme', 'password', 'secret', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/5a5b493f7a4d4651 b/.hypothesis/constants/5a5b493f7a4d4651
new file mode 100644
index 0000000..760d861
--- /dev/null
+++ b/.hypothesis/constants/5a5b493f7a4d4651
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/models.py
+# hypothesis_version: 6.151.12
+
+[512, 1024, 'bounty', 'logs', 'users', 'viewer']
\ No newline at end of file
diff --git a/.hypothesis/constants/65b34369abfe10d4 b/.hypothesis/constants/65b34369abfe10d4
new file mode 100644
index 0000000..f5f3347
--- /dev/null
+++ b/.hypothesis/constants/65b34369abfe10d4
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/database.py
+# hypothesis_version: 6.151.12
+
+['file:', 'sqlite+aiosqlite:///', 'sqlite:///', 'uri']
\ No newline at end of file
diff --git a/.hypothesis/constants/6603830361bc3ade b/.hypothesis/constants/6603830361bc3ade
new file mode 100644
index 0000000..ab817c4
--- /dev/null
+++ b/.hypothesis/constants/6603830361bc3ade
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/stats/api_get_stats.py
+# hypothesis_version: 6.151.12
+
+[401, 422, '/stats', 'Not authenticated', 'Observability', 'Validation error', 'description']
\ No newline at end of file
diff --git a/.hypothesis/constants/6614931f51b4fafe b/.hypothesis/constants/6614931f51b4fafe
new file mode 100644
index 0000000..f001b39
--- /dev/null
+++ b/.hypothesis/constants/6614931f51b4fafe
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.12
+
+[8000, 8080, 65535, ',', '.env', '.env.local', '0.0.0.0', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'PYTEST', 'admin', 'changeme', 'password', 'secret', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/6a92284baf31e457 b/.hypothesis/constants/6a92284baf31e457
new file mode 100644
index 0000000..77f3b36
--- /dev/null
+++ b/.hypothesis/constants/6a92284baf31e457
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/repository.py
+# hypothesis_version: 6.151.12
+
+[' AND ', ' WHERE ', '+00:00', ':', 'SELECT 1', 'Z', '[^a-zA-Z0-9_]', 'active_deckies', 'admin', 'attacker', 'attacker-ip', 'attacker_ip', 'count', 'decky', 'decnet.db', 'deployed_deckies', 'end_time', 'event', 'event_type', 'fields', 'm', 'p', 'payload', 'r', 'service', 'start_time', 'time', 'timestamp', 'total_logs', 'u', 'unique_attackers', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/701773137e12c840 b/.hypothesis/constants/701773137e12c840
new file mode 100644
index 0000000..8ef4c3b
--- /dev/null
+++ b/.hypothesis/constants/701773137e12c840
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/stream/api_stream_events.py
+# hypothesis_version: 6.151.12
+
+[401, 422, '/stream', 'Not authenticated', 'Observability', 'Validation error', 'description', 'id', 'lastEventId', 'text/event-stream']
\ No newline at end of file
diff --git a/.hypothesis/constants/76203473b0ec58d8 b/.hypothesis/constants/76203473b0ec58d8
new file mode 100644
index 0000000..d52aba0
--- /dev/null
+++ b/.hypothesis/constants/76203473b0ec58d8
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/repository.py
+# hypothesis_version: 6.151.12
+
+['+00:00', ':', 'SELECT 1', 'Z', '[^a-zA-Z0-9_]', 'active_deckies', 'admin', 'attacker', 'attacker-ip', 'attacker_ip', 'bucket_time', 'count', 'decky', 'decnet.db', 'deployed_deckies', 'event', 'fields', 'json', 'm', 'p', 'payload', 'r', 'service', 'time', 'timestamp', 'total_logs', 'u', 'unique_attackers', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/79521c54b0a7c145 b/.hypothesis/constants/79521c54b0a7c145
new file mode 100644
index 0000000..e7ce510
--- /dev/null
+++ b/.hypothesis/constants/79521c54b0a7c145
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/repository.py
+# hypothesis_version: 6.151.12
+
+['+00:00', ':', 'SELECT 1', 'Z', '[^a-zA-Z0-9_]', 'active_deckies', 'admin', 'attacker', 'attacker-ip', 'attacker_ip', 'bucket_time', 'count', 'decky', 'decnet.db', 'deployed_deckies', 'event', 'fields', 'm', 'p', 'payload', 'r', 'service', 'time', 'timestamp', 'total_logs', 'u', 'unique_attackers', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/7e878084491da5cb b/.hypothesis/constants/7e878084491da5cb
new file mode 100644
index 0000000..ae4d48b
--- /dev/null
+++ b/.hypothesis/constants/7e878084491da5cb
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/bounty/api_get_bounties.py
+# hypothesis_version: 6.151.12
+
+[401, 422, 1000, '/bounty', 'Bounty Vault', 'Not authenticated', 'Validation error', 'data', 'description', 'limit', 'offset', 'total']
\ No newline at end of file
diff --git a/.hypothesis/constants/8d179cdd823f0c67 b/.hypothesis/constants/8d179cdd823f0c67
new file mode 100644
index 0000000..b236300
--- /dev/null
+++ b/.hypothesis/constants/8d179cdd823f0c67
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.12
+
+[',', '.env', '.env.local', '0.0.0.0', '8000', '8080', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'admin', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/947fe201680339e3 b/.hypothesis/constants/947fe201680339e3
new file mode 100644
index 0000000..1d825ff
--- /dev/null
+++ b/.hypothesis/constants/947fe201680339e3
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/logs/api_get_logs.py
+# hypothesis_version: 6.151.12
+
+[1000, '/logs', 'Logs', 'data', 'limit', 'offset', 'total']
\ No newline at end of file
diff --git a/.hypothesis/constants/94c86c2ff2b6925f b/.hypothesis/constants/94c86c2ff2b6925f
new file mode 100644
index 0000000..a8ce520
--- /dev/null
+++ b/.hypothesis/constants/94c86c2ff2b6925f
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/logs/api_get_logs.py
+# hypothesis_version: 6.151.12
+
+[512, 1000, '/logs', 'Logs', 'data', 'limit', 'offset', 'total']
\ No newline at end of file
diff --git a/.hypothesis/constants/983cc2b7068d9460 b/.hypothesis/constants/983cc2b7068d9460
new file mode 100644
index 0000000..6ae9db2
--- /dev/null
+++ b/.hypothesis/constants/983cc2b7068d9460
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/config.py
+# hypothesis_version: 6.151.12
+
+[0.0, 'compose_path', 'config', 'debian', 'debian:bookworm-slim', 'decnet-state.json', 'linux', 'services', 'swarm', 'unihost']
\ No newline at end of file
diff --git a/.hypothesis/constants/9d0a6512c2df8b01 b/.hypothesis/constants/9d0a6512c2df8b01
new file mode 100644
index 0000000..3a81c87
--- /dev/null
+++ b/.hypothesis/constants/9d0a6512c2df8b01
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/auth/api_login.py
+# hypothesis_version: 6.151.12
+
+[401, 422, '/auth/login', 'Authentication', 'Bearer', 'Validation error', 'WWW-Authenticate', 'access_token', 'bearer', 'description', 'must_change_password', 'password_hash', 'token_type', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/aa6d6eae3a35bf24 b/.hypothesis/constants/aa6d6eae3a35bf24
new file mode 100644
index 0000000..5034021
--- /dev/null
+++ b/.hypothesis/constants/aa6d6eae3a35bf24
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/collector.py
+# hypothesis_version: 6.151.12
+
+['"', '%Y-%m-%d %H:%M:%S', '-', '.json', '/', 'Actor', 'Attributes', 'Collector error: %s', 'Unknown', '[', '\\', '\\"', '\\\\', '\\]', '\\]\\s+(.+)$', ']', '^decky-\\d+-\\w', 'a', 'attacker_ip', 'client_ip', 'container', 'decky', 'decnet.web.collector', 'event', 'event_type', 'fields', 'id', 'ip', 'msg', 'name', 'raw_line', 'remote_ip', 'replace', 'service', 'src', 'src_ip', 'start', 'timestamp', 'type', 'utf-8']
\ No newline at end of file
diff --git a/.hypothesis/constants/b0a0354c059c6400 b/.hypothesis/constants/b0a0354c059c6400
new file mode 100644
index 0000000..77f3b36
--- /dev/null
+++ b/.hypothesis/constants/b0a0354c059c6400
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/repository.py
+# hypothesis_version: 6.151.12
+
+[' AND ', ' WHERE ', '+00:00', ':', 'SELECT 1', 'Z', '[^a-zA-Z0-9_]', 'active_deckies', 'admin', 'attacker', 'attacker-ip', 'attacker_ip', 'count', 'decky', 'decnet.db', 'deployed_deckies', 'end_time', 'event', 'event_type', 'fields', 'm', 'p', 'payload', 'r', 'service', 'start_time', 'time', 'timestamp', 'total_logs', 'u', 'unique_attackers', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/b68f1633553e2484 b/.hypothesis/constants/b68f1633553e2484
new file mode 100644
index 0000000..c8771bc
--- /dev/null
+++ b/.hypothesis/constants/b68f1633553e2484
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/api.py
+# hypothesis_version: 6.151.12
+
+[0.5, '/api/v1', '/docs', '/openapi.json', '/redoc', '1.0.0', 'Authorization', 'Content-Type', 'DELETE', 'GET', 'OPTIONS', 'POST', 'PUT']
\ No newline at end of file
diff --git a/.hypothesis/constants/b74da5484c6a8a8f b/.hypothesis/constants/b74da5484c6a8a8f
new file mode 100644
index 0000000..cd00ccd
--- /dev/null
+++ b/.hypothesis/constants/b74da5484c6a8a8f
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_deploy_deckies.py
+# hypothesis_version: 6.151.12
+
+[400, 500, '/deckies/deploy', 'Fleet Management', 'decnet.web.api', 'message', 'unihost']
\ No newline at end of file
diff --git a/.hypothesis/constants/ba4e1d32ec08f759 b/.hypothesis/constants/ba4e1d32ec08f759
new file mode 100644
index 0000000..d874d83
--- /dev/null
+++ b/.hypothesis/constants/ba4e1d32ec08f759
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/repository.py
+# hypothesis_version: 6.151.11
+
+[' AND ', ' WHERE ', '+00:00', ':', 'SELECT 1', 'Z', '[^a-zA-Z0-9_]', 'active_deckies', 'admin', 'attacker', 'attacker-ip', 'attacker_ip', 'count', 'decky', 'decnet.db', 'deployed_deckies', 'end_time', 'event', 'event_type', 'fields', 'payload', 'service', 'start_time', 'time', 'timestamp', 'total_logs', 'unique_attackers']
\ No newline at end of file
diff --git a/.hypothesis/constants/be1efb4490b6491b b/.hypothesis/constants/be1efb4490b6491b
new file mode 100644
index 0000000..558909c
--- /dev/null
+++ b/.hypothesis/constants/be1efb4490b6491b
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/api.py
+# hypothesis_version: 6.151.12
+
+[0.5, '/api/v1', '/docs', '/openapi.json', '/redoc', '1.0.0', 'Authorization', 'Content-Type', 'DELETE', 'GET', 'Last-Event-ID', 'OPTIONS', 'POST', 'PUT']
\ No newline at end of file
diff --git a/.hypothesis/constants/c148444bb2acbe85 b/.hypothesis/constants/c148444bb2acbe85
new file mode 100644
index 0000000..46fc91c
--- /dev/null
+++ b/.hypothesis/constants/c148444bb2acbe85
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/auth/api_change_pass.py
+# hypothesis_version: 6.151.12
+
+['Authentication', 'message', 'password_hash']
\ No newline at end of file
diff --git a/.hypothesis/constants/c48a6c8f4e10707d b/.hypothesis/constants/c48a6c8f4e10707d
new file mode 100644
index 0000000..f001b39
--- /dev/null
+++ b/.hypothesis/constants/c48a6c8f4e10707d
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.12
+
+[8000, 8080, 65535, ',', '.env', '.env.local', '0.0.0.0', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'PYTEST', 'admin', 'changeme', 'password', 'secret', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/ccc50d1ce9a02c6b b/.hypothesis/constants/ccc50d1ce9a02c6b
new file mode 100644
index 0000000..7dec91b
--- /dev/null
+++ b/.hypothesis/constants/ccc50d1ce9a02c6b
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.11
+
+[',', '.env', '.env.local', '0.0.0.0', '8000', '8080', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'PYTEST', 'admin', 'changeme', 'password', 'secret', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/d17eec2e8aeda21b b/.hypothesis/constants/d17eec2e8aeda21b
new file mode 100644
index 0000000..a760ab7
--- /dev/null
+++ b/.hypothesis/constants/d17eec2e8aeda21b
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_mutate_interval.py
+# hypothesis_version: 6.151.12
+
+[401, 404, 422, 500, 'Decky not found', 'Fleet Management', 'No active deployment', 'Not authenticated', 'Validation error', 'description', 'message']
\ No newline at end of file
diff --git a/.hypothesis/constants/d49ada51f9025789 b/.hypothesis/constants/d49ada51f9025789
new file mode 100644
index 0000000..7b37d41
--- /dev/null
+++ b/.hypothesis/constants/d49ada51f9025789
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/cli.py
+# hypothesis_version: 6.151.12
+
+[8000, ',', ', ', '--all', '--api', '--api-port', '--archetype', '--config', '--deckies', '--decky', '--distro', '--dry-run', '--emit-syslog', '--host', '--id', '--interface', '--ip-start', '--ipvlan', '--log-file', '--min-deckies', '--mode', '--mutate-interval', '--no-cache', '--output', '--port', '--randomize-distros', '--randomize-services', '--services', '--subnet', '--watch', '--web-port', '-a', '-c', '-d', '-f', '-i', '-m', '-n', '-o', '-w', '/index.html', 'Available Services', 'Default Services', 'Description', 'Display Name', 'Docker Image', 'Image', 'Machine Archetypes', 'Name', 'Ports', 'Slug', 'archetypes', 'bold cyan', 'correlate', 'decnet', 'decnet.cli', 'decnet.log', 'decnet.web.api:app', 'decnet_web', 'dim', 'dist', 'distros', 'green', 'json', 'linux', 'mutate', 'services', 'swarm', 'syslog', 'table', 'unihost', 'uvicorn', 'web']
\ No newline at end of file
diff --git a/.hypothesis/constants/da1cde746d954b43 b/.hypothesis/constants/da1cde746d954b43
new file mode 100644
index 0000000..7deb872
--- /dev/null
+++ b/.hypothesis/constants/da1cde746d954b43
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/composer.py
+# hypothesis_version: 6.151.12
+
+[511, '/var/log/decnet', '3.8', 'BASE_IMAGE', 'DECNET_LOG_FILE', 'HOSTNAME', 'NET_ADMIN', 'args', 'bridge', 'build', 'cap_add', 'command', 'container_name', 'decnet_logs', 'depends_on', 'driver', 'environment', 'external', 'hostname', 'image', 'infinity', 'internal', 'ipv4_address', 'network_mode', 'networks', 'restart', 'services', 'sleep', 'sysctls', 'unless-stopped', 'version', 'volumes']
\ No newline at end of file
diff --git a/.hypothesis/constants/db4051caa70468e7 b/.hypothesis/constants/db4051caa70468e7
new file mode 100644
index 0000000..45b2292
--- /dev/null
+++ b/.hypothesis/constants/db4051caa70468e7
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/models.py
+# hypothesis_version: 6.151.11
+
+['bounty', 'logs', 'users', 'viewer']
\ No newline at end of file
diff --git a/.hypothesis/constants/de1994de9f46d0ad b/.hypothesis/constants/de1994de9f46d0ad
new file mode 100644
index 0000000..e6a1b51
--- /dev/null
+++ b/.hypothesis/constants/de1994de9f46d0ad
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/ini_loader.py
+# hypothesis_version: 6.151.12
+
+[100, 512, 1024, ',', '.', '1', '[', ']', 'amount', 'archetype', 'binary', 'custom-', 'exceeds maximum', 'exec', 'general', 'gw', 'interface', 'ip', 'mutate-interval', 'mutate_interval', 'net', 'nmap-os', 'nmap_os', 'ports', 'services']
\ No newline at end of file
diff --git a/.hypothesis/constants/e0e90731fc1ee103 b/.hypothesis/constants/e0e90731fc1ee103
new file mode 100644
index 0000000..cdc60d3
--- /dev/null
+++ b/.hypothesis/constants/e0e90731fc1ee103
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/env.py
+# hypothesis_version: 6.151.11
+
+[',', '.env', '.env.local', '0.0.0.0', '8000', '8080', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_CORS_ORIGINS', 'DECNET_DEVELOPER', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'False', 'PYTEST_CURRENT_TEST', 'admin', 'changeme', 'password', 'secret', 'true']
\ No newline at end of file
diff --git a/.hypothesis/constants/e46f67535c4d7df0 b/.hypothesis/constants/e46f67535c4d7df0
new file mode 100644
index 0000000..3af6df2
--- /dev/null
+++ b/.hypothesis/constants/e46f67535c4d7df0
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_mutate_decky.py
+# hypothesis_version: 6.151.12
+
+[404, 'Fleet Management', '^[a-z0-9\\-]{1,64}$', 'message']
\ No newline at end of file
diff --git a/.hypothesis/constants/e80ba61461b381ee b/.hypothesis/constants/e80ba61461b381ee
new file mode 100644
index 0000000..faea83d
--- /dev/null
+++ b/.hypothesis/constants/e80ba61461b381ee
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/dependencies.py
+# hypothesis_version: 6.151.12
+
+['/api/v1/auth/login', 'Authorization', 'Bearer', 'Bearer ', 'WWW-Authenticate', 'decnet.db', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/e9144578b3e37f8a b/.hypothesis/constants/e9144578b3e37f8a
new file mode 100644
index 0000000..8ef4c3b
--- /dev/null
+++ b/.hypothesis/constants/e9144578b3e37f8a
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/stream/api_stream_events.py
+# hypothesis_version: 6.151.12
+
+[401, 422, '/stream', 'Not authenticated', 'Observability', 'Validation error', 'description', 'id', 'lastEventId', 'text/event-stream']
\ No newline at end of file
diff --git a/.hypothesis/constants/f0f613672557afad b/.hypothesis/constants/f0f613672557afad
new file mode 100644
index 0000000..a5cc582
--- /dev/null
+++ b/.hypothesis/constants/f0f613672557afad
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/dependencies.py
+# hypothesis_version: 6.151.11
+
+['/api/v1/auth/login', 'Authorization', 'Bearer', 'Bearer ', 'WWW-Authenticate', 'decnet.db', 'token', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/f181f0db99f54b8a b/.hypothesis/constants/f181f0db99f54b8a
new file mode 100644
index 0000000..c62c37f
--- /dev/null
+++ b/.hypothesis/constants/f181f0db99f54b8a
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/ingester.py
+# hypothesis_version: 6.151.12
+
+['.json', 'attacker_ip', 'bounty_type', 'credential', 'decky', 'decnet.web.ingester', 'fields', 'password', 'payload', 'r', 'replace', 'service', 'username', 'utf-8']
\ No newline at end of file
diff --git a/.hypothesis/constants/f1b388f5e6b1c622 b/.hypothesis/constants/f1b388f5e6b1c622
new file mode 100644
index 0000000..9e49981
--- /dev/null
+++ b/.hypothesis/constants/f1b388f5e6b1c622
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/stats/api_get_stats.py
+# hypothesis_version: 6.151.12
+
+['/stats', 'Observability']
\ No newline at end of file
diff --git a/.hypothesis/constants/f47a7b51284d728b b/.hypothesis/constants/f47a7b51284d728b
new file mode 100644
index 0000000..760d861
--- /dev/null
+++ b/.hypothesis/constants/f47a7b51284d728b
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/models.py
+# hypothesis_version: 6.151.12
+
+[512, 1024, 'bounty', 'logs', 'users', 'viewer']
\ No newline at end of file
diff --git a/.hypothesis/constants/f482e30cab8c8a6a b/.hypothesis/constants/f482e30cab8c8a6a
new file mode 100644
index 0000000..07f72a4
--- /dev/null
+++ b/.hypothesis/constants/f482e30cab8c8a6a
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/auth/api_login.py
+# hypothesis_version: 6.151.12
+
+['/auth/login', 'Authentication', 'Bearer', 'WWW-Authenticate', 'access_token', 'bearer', 'must_change_password', 'password_hash', 'token_type', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/f5bfcf16c9e01ffc b/.hypothesis/constants/f5bfcf16c9e01ffc
new file mode 100644
index 0000000..77f3b36
--- /dev/null
+++ b/.hypothesis/constants/f5bfcf16c9e01ffc
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/repository.py
+# hypothesis_version: 6.151.12
+
+[' AND ', ' WHERE ', '+00:00', ':', 'SELECT 1', 'Z', '[^a-zA-Z0-9_]', 'active_deckies', 'admin', 'attacker', 'attacker-ip', 'attacker_ip', 'count', 'decky', 'decnet.db', 'deployed_deckies', 'end_time', 'event', 'event_type', 'fields', 'm', 'p', 'payload', 'r', 'service', 'start_time', 'time', 'timestamp', 'total_logs', 'u', 'unique_attackers', 'uuid']
\ No newline at end of file
diff --git a/.hypothesis/constants/f67728057ac2eaf3 b/.hypothesis/constants/f67728057ac2eaf3
new file mode 100644
index 0000000..ed8c520
--- /dev/null
+++ b/.hypothesis/constants/f67728057ac2eaf3
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/database.py
+# hypothesis_version: 6.151.12
+
+['uri']
\ No newline at end of file
diff --git a/.hypothesis/constants/fc9d0ff181f53975 b/.hypothesis/constants/fc9d0ff181f53975
new file mode 100644
index 0000000..5046274
--- /dev/null
+++ b/.hypothesis/constants/fc9d0ff181f53975
@@ -0,0 +1,4 @@
+# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_mutate_interval.py
+# hypothesis_version: 6.151.12
+
+[404, 500, 'Decky not found', 'Fleet Management', 'No active deployment', 'message']
\ No newline at end of file
diff --git a/.hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz b/.hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz
index 19c5c43..f534d58 100644
Binary files a/.hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz and b/.hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz differ
diff --git a/DEBT.md b/DEBT.md
index 02f9ef2..ea7369b 100644
--- a/DEBT.md
+++ b/DEBT.md
@@ -101,6 +101,11 @@ All route decorators now declare `responses={401: {"description": "Not authentic
~~**File:** `decnet/web/sqlite_repository.py` (~400 lines)~~
Fully refactored to `decnet/web/db/` modular layout: `models.py` (SQLModel schema), `repository.py` (abstract base), `sqlite/repository.py` (SQLite implementation), `sqlite/database.py` (engine/session factory). Commit `de84cc6`.
+### DEBT-026 โ IMAP/POP3 bait emails are hardcoded
+**Files:** `templates/imap/server.py`, `templates/pop3/server.py`
+Bait emails are hardcoded strings. A modular framework to dynamically inject personalized mailboxes, custom mails, and dynamic users should be implemented in the future for a more personalized feel.
+**Status:** Deferred โ out of current scope.
+
---
## ๐ข Low
@@ -152,6 +157,7 @@ Fully refactored to `decnet/web/db/` modular layout: `models.py` (SQLModel schem
| DEBT-023 | ๐ข Low | Infra | deferred (needs docker pull) |
| ~~DEBT-024~~ | โ
| Infra | resolved |
| ~~DEBT-025~~ | โ
| Build | resolved |
+| DEBT-026 | ๐ก Medium | Features | deferred (out of scope) |
-**Remaining open:** DEBT-011 (Alembic migrations), DEBT-023 (image digest pinning)
-**Estimated remaining effort:** ~7 hours
+**Remaining open:** DEBT-011 (Alembic migrations), DEBT-023 (image digest pinning), DEBT-026 (modular mailboxes)
+**Estimated remaining effort:** ~10 hours
diff --git a/README.md b/README.md
index ad47cc5..5e52a67 100644
--- a/README.md
+++ b/README.md
@@ -474,7 +474,7 @@ Key/value pairs are passed directly to the service plugin as persona config. Com
| `mongodb` | `mongo_version` |
| `elasticsearch` | `es_version`, `cluster_name` |
| `ldap` | `base_dn`, `domain` |
-| `snmp` | `snmp_community`, `sys_descr` |
+| `snmp` | `snmp_community`, `sys_descr`, `snmp_archetype` (picks predefined sysDescr for `water_plant`, `hospital`, etc.) |
| `mqtt` | `mqtt_version` |
| `sip` | `sip_server`, `sip_domain` |
| `k8s` | `k8s_version` |
diff --git a/decnet.db-shm b/decnet.db-shm
new file mode 100644
index 0000000..7c16666
Binary files /dev/null and b/decnet.db-shm differ
diff --git a/decnet.db-wal b/decnet.db-wal
new file mode 100644
index 0000000..143b25b
Binary files /dev/null and b/decnet.db-wal differ
diff --git a/decnet/services/conpot.py b/decnet/services/conpot.py
index 073d8dc..1b01f68 100644
--- a/decnet/services/conpot.py
+++ b/decnet/services/conpot.py
@@ -1,26 +1,35 @@
+import os
+from pathlib import Path
from decnet.services.base import BaseService
class ConpotService(BaseService):
"""ICS/SCADA honeypot covering Modbus (502), SNMP (161 UDP), and HTTP (80).
- Uses the official honeynet/conpot image which ships a default ICS profile
- that emulates a Siemens S7-200 PLC.
+ Uses a custom build context wrapping the official honeynet/conpot image
+ to fix Modbus binding to port 502.
"""
name = "conpot"
ports = [502, 161, 80]
- default_image = "honeynet/conpot"
+ default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
+ env = {
+ "CONPOT_TEMPLATE": "default",
+ "NODE_NAME": decky_name,
+ }
+ if log_target:
+ env["LOG_TARGET"] = log_target
+
return {
- "image": "honeynet/conpot",
+ "build": {
+ "context": str(self.dockerfile_context())
+ },
"container_name": f"{decky_name}-conpot",
"restart": "unless-stopped",
- "environment": {
- "CONPOT_TEMPLATE": "default",
- },
+ "environment": env,
}
def dockerfile_context(self):
- return None
+ return Path(__file__).parent.parent.parent / "templates" / "conpot"
diff --git a/decnet/web/db/sqlite/database.py b/decnet/web/db/sqlite/database.py
index d0a74d1..bb51467 100644
--- a/decnet/web/db/sqlite/database.py
+++ b/decnet/web/db/sqlite/database.py
@@ -1,7 +1,6 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import create_engine
from sqlmodel import SQLModel
-from pathlib import Path
# We need both sync and async engines for SQLite
# Sync for initialization (DDL) and async for standard queries
diff --git a/development/REALISM_AUDIT.md b/development/REALISM_AUDIT.md
new file mode 100644
index 0000000..09882f2
--- /dev/null
+++ b/development/REALISM_AUDIT.md
@@ -0,0 +1,374 @@
+# Service Realism Audit
+
+> Live-tested against `192.168.1.200` (omega-decky, full-audit.ini).
+> Every result below is from an actual `nc` or `nmap` probe, not code reading.
+
+---
+
+## nmap -sV Summary
+
+```
+21/tcp ftp vsftpd (before 2.0.8) or WU-FTPD โ WRONG: banner says "Twisted 25.5.0"
+23/tcp telnet (unrecognized โ Cowrie)
+25/tcp smtp Postfix smtpd โ
+80/tcp http Apache httpd 2.4.54 ((Debian)) โ BUT leaks Werkzeug
+110/tcp pop3 (unrecognized)
+143/tcp imap (unrecognized)
+389/tcp ldap Cisco LDAP server
+445/tcp microsoft-ds โ
+1433/tcp ms-sql-s? (partially recognized)
+1883/tcp mqtt โ
+2375/tcp docker Docker 24.0.5 โ
+3306/tcp mysql MySQL 5.7.38-log โ
+3389/tcp ms-wbt-server xrdp
+5060/tcp sip SIP endpoint; Status: 401 Unauthorized โ
+5432/tcp postgresql? (partially recognized)
+5900/tcp vnc VNC protocol 3.8 โ
+6379/tcp redis? (partially recognized)
+6443/tcp (unrecognized) โ K8s not responding at all
+9200/tcp wap-wsp? (completely unrecognized โ ES)
+27017/tcp mongod? (partially recognized)
+502/tcp CLOSED โ Conpot Modbus not on this port
+```
+
+---
+
+## Service-by-Service
+
+---
+
+### SMTP โ port 25
+
+**Probe:**
+```
+220 omega-decky ESMTP Postfix (Debian/GNU)
+250-PIPELINING / SIZE / VRFY / AUTH PLAIN LOGIN / ENHANCEDSTATUSCODES / 8BITMIME / DSN
+250 2.1.0 Ok โ MAIL FROM accepted
+250 2.1.5 Ok โ RCPT TO accepted for any domain โ (open relay bait)
+354 End data with... โ DATA opened
+502 5.5.2 Error: command not recognized โ BUG: each message line fails
+221 2.0.0 Bye
+```
+
+**Verdict:** Banner and EHLO are perfect. DATA handler is broken โ server reads the socket line-by-line but the asyncio handler dispatches each line as a new command instead of buffering until `.\r\n`. The result is every line of the email body gets a 502 and the message is silently dropped.
+
+**Fixes needed:**
+- Buffer DATA state until `\r\n.\r\n` terminator
+- Return `250 2.0.0 Ok: queued as <8-hex-id>` after message accepted
+- Don't require AUTH for relay (open relay is the point)
+- Optionally: store message content so IMAP can serve it later
+
+---
+
+### IMAP โ port 143
+
+**Probe:**
+```
+* OK [omega-decky] IMAP4rev1 Service Ready
+A1 OK CAPABILITY completed
+A2 NO [AUTHENTICATIONFAILED] Invalid credentials โ always, for any user/pass
+A3 BAD Command not recognized โ LIST, SELECT, FETCH all unknown
+```
+
+**Verdict:** Login always fails. No mailbox commands implemented. An attacker who tries credential stuffing or default passwords (admin/admin, root/root) gets nothing and moves on. This is the biggest missed opportunity in the whole stack.
+
+**Fixes needed:**
+- Accept configurable credentials (default `admin`/`admin` or pulled from persona config)
+- Implement: SELECT, LIST, FETCH, UID FETCH, SEARCH, LOGOUT
+- Serve seeded fake mailboxes with bait content (see IMAP_BAIT.md)
+- CAPABILITY should advertise `LITERAL+`, `SASL-IR`, `LOGIN-REFERRALS`, `ID`, `ENABLE`, `IDLE`
+- Banner should hint at Dovecot: `* OK Dovecot ready.`
+
+---
+
+### POP3 โ port 110
+
+**Probe:**
+```
++OK omega-decky POP3 server ready
++OK โ USER accepted
+-ERR Authentication failed โ always
+-ERR Unknown command โ STAT, LIST, RETR all unknown
+```
+
+**Verdict:** Same problem as IMAP. CAPA only returns `USER`. Should be paired with IMAP fix to serve the same fake mailbox.
+
+**Fixes needed:**
+- Accept same credentials as IMAP
+- Implement: STAT, LIST, RETR, DELE, TOP, UIDL, CAPA
+- CAPA should return: `TOP UIDL RESP-CODES AUTH-RESP-CODE SASL USER`
+
+---
+
+### HTTP โ port 80
+
+**Probe:**
+```
+HTTP/1.1 403 FORBIDDEN
+Server: Werkzeug/3.1.8 Python/3.11.2 โ DEAD GIVEAWAY
+Server: Apache/2.4.54 (Debian) โ duplicate Server header
+```
+
+**Verdict:** nmap gets the Apache fingerprint right, but any attacker who looks at response headers sees two `Server:` headers โ one of which is clearly Werkzeug/Flask. The HTTP body is also a bare `
403 Forbidden
` with no Apache default page styling.
+
+**Fixes needed:**
+- Strip Werkzeug from Server header (set `SERVER_NAME` on the Flask app or use middleware to overwrite)
+- Apache default 403 page should be the actual Apache HTML, not a bare `` tag
+- Per-path routing for fake apps: `/wp-login.php`, `/wp-admin/`, `/xmlrpc.php`, etc.
+- POST credential capture on login endpoints
+
+---
+
+### FTP โ port 21
+
+**Probe:**
+```
+220 Twisted 25.5.0 FTP Server โ terrible: exposes framework
+331 Guest login ok...
+550 Requested action not taken โ after login, nothing works
+503 Incorrect sequence of commands: must send PORT or PASV before RETR
+```
+
+**Verdict:** Banner immediately identifies this as Twisted's built-in FTP server. No directory listing. PASV mode not implemented so clients hang. Real FTP honeypots should expose anonymous access with a fake directory tree containing interesting-sounding files.
+
+**Fixes needed:**
+- Override banner to: `220 (vsFTPd 3.0.3)` or similar
+- Implement anonymous login (no password required)
+- Implement PASV and at minimum LIST โ return a fake directory with files: `backup.tar.gz`, `db_dump.sql`, `config.ini`, `credentials.txt`
+- Log any RETR attempts (file name, client IP)
+
+---
+
+### MySQL โ port 3306
+
+**Probe:**
+```
+HANDSHAKE: ...5.7.38-log...
+Version: 5.7.38-log
+```
+
+**Verdict:** Handshake is excellent. nmap fingerprints it perfectly. Always returns `Access denied` which is correct behavior. The only issue is the hardcoded auth plugin data bytes in the greeting โ a sophisticated scanner could detect the static challenge.
+
+**Fixes needed (low priority):**
+- Randomize the 20-byte auth plugin data per connection
+
+---
+
+### PostgreSQL โ port 5432
+
+**Probe:**
+```
+R\x00\x00\x00\x0c\x00\x00\x00\x05\xde\xad\xbe\xef
+```
+That's `AuthenticationMD5Password` (type=5) with salt `0xdeadbeef`.
+
+**Verdict:** Correct protocol response. Salt is hardcoded and static โ `deadbeef` is trivially identifiable as fake.
+
+**Fixes needed (low priority):**
+- Randomize the 4-byte MD5 salt per connection
+
+---
+
+### MSSQL โ port 1433
+
+**Probe:** No response to standard TDS pre-login packets. Server drops connection immediately.
+
+**Verdict:** Broken. TDS pre-login handler is likely mismatching the packet format we sent.
+
+**Fixes needed:**
+- Debug TDS pre-login response โ currently silent
+- Verify the hardcoded TDS response bytes are valid
+
+---
+
+### Redis โ port 6379
+
+**Probe:**
+```
++OK โ AUTH accepted (any password!)
+$150
+redis_version:7.2.7 / os:Linux 5.15.0 / uptime_in_seconds:864000 ...
+*0 โ KEYS * returns empty
+```
+
+**Verdict:** Accepts any AUTH password (intentional for bait). INFO looks real. But `KEYS *` returns nothing โ a real Redis exposed to the internet always has data. An attacker who gets `+OK` on AUTH will immediately run `KEYS *` or `SCAN 0` and leave when they find nothing.
+
+**Fixes needed:**
+- Add fake key-value store: session tokens, JWT secrets, cached user objects, API keys
+- `KEYS *` โ `["sessions:user:1234", "cache:api_key", "jwt:secret", "user:admin"]`
+- `GET sessions:user:1234` โ JSON user object with credentials
+- `GET jwt:secret` โ a plausible JWT signing key
+
+---
+
+### MongoDB โ port 27017
+
+**Probe:** No response to OP_MSG `isMaster` command.
+
+**Verdict:** Broken or rejecting the wire protocol format we sent.
+
+**Fixes needed:**
+- Debug the OP_MSG/OP_QUERY handler
+
+---
+
+### Elasticsearch โ port 9200
+
+**Probe:**
+```json
+{"name":"omega-decky","cluster_uuid":"xC3Pr9abTq2mNkOeLvXwYA","version":{"number":"7.17.9",...}}
+/_cat/indices โ [] โ empty: dead giveaway
+```
+
+**Verdict:** Root response is convincing. But `/_cat/indices` returns an empty array โ a real exposed ES instance has indices. nmap doesn't recognize port 9200 as Elasticsearch at all ("wap-wsp?").
+
+**Fixes needed:**
+- Add fake indices: `logs-2024.01`, `users`, `products`, `audit_trail`
+- `/_cat/indices` โ return rows with doc counts, sizes
+- `/_search` on those indices โ return sample documents (bait data: user records, API tokens)
+
+---
+
+### Docker API โ port 2375
+
+**Probe:**
+```json
+/version โ {Version: "24.0.5", ApiVersion: "1.43", GoVersion: "go1.20.6", ...} โ
+/containers/json โ [{"Id":"a1b2c3d4e5f6","Names":["/webapp"],"Image":"nginx:latest",...}]
+```
+
+**Verdict:** Version response is perfect. Container list is minimal (one hardcoded container). No `/images/json` data, no exec endpoint. An attacker will immediately try `POST /containers/webapp/exec` to get RCE.
+
+**Fixes needed:**
+- Add 3-5 containers with realistic names/images: `db` (postgres:14), `api` (node:18-alpine), `redis` (redis:7)
+- Add `/images/json` with corresponding images
+- Add exec endpoint that captures the command and returns `{"Id":""}` then a fake stream
+
+---
+
+### SMB โ port 445
+
+**Probe:** SMB1 negotiate response received (standard `\xff\x53\x4d\x42r` header).
+
+**Verdict:** Impacket SimpleSMBServer responds. nmap IDs it as `microsoft-ds`. Functional enough for credential capture.
+
+---
+
+### VNC โ port 5900
+
+**Probe:**
+```
+RFB 003.008 โ
+```
+
+**Verdict:** Correct RFB 3.8 handshake. nmap fingerprints it as VNC protocol 3.8. The 16-byte DES challenge is hardcoded โ same bytes every time.
+
+**Fixes needed (trivial):**
+- Randomize the 16-byte challenge per connection (`os.urandom(16)`)
+
+---
+
+### RDP โ port 3389
+
+**Probe:**
+```
+0300000b06d00000000000 โ X.224 Connection Confirm
+(connection closed)
+```
+
+**Verdict:** nmap identifies it as "xrdp" which is correct enough. The X.224 CC is fine. But the server closes immediately after โ no NLA/CredSSP negotiation, no credential capture. This is the single biggest missed opportunity for credential harvesting after SSH.
+
+**Fixes needed:**
+- Implement NTLM Type-1/Type-2/Type-3 exchange to capture NTLMv2 hashes
+- Alternatively: send a fake TLS certificate then disconnect (many scanners fingerprint by the cert)
+
+---
+
+### SIP โ port 5060
+
+**Probe:**
+```
+SIP/2.0 401 Unauthorized
+WWW-Authenticate: Digest realm="omega-decky", nonce="decnet0000", algorithm=MD5
+```
+
+**Verdict:** Functional. Correctly challenges with 401. But `nonce="decnet0000"` is a hardcoded string โ a Shodan signature would immediately pick this up.
+
+**Fixes needed (low effort):**
+- Generate a random hex nonce per connection
+
+---
+
+### MQTT โ port 1883
+
+**Probe:** `CONNACK` with return code `0x05` (not authorized).
+
+**Verdict:** Rejects all connections. For an ICS/water-plant persona, this should accept connections and expose fake sensor topics. See `ICS_SCADA.md`.
+
+**Fixes needed:**
+- Return CONNACK 0x00 (accepted)
+- Implement SUBSCRIBE: return retained sensor readings for bait topics
+- Implement PUBLISH: log any published commands (attacker trying to control plant)
+
+---
+
+### SNMP โ port 161/UDP
+
+Not directly testable without sudo for raw UDP send, but code review shows BER encoding is correct.
+
+**Verdict:** Functional. sysDescr is a generic Linux string โ should be tuned per archetype.
+
+---
+
+### LDAP โ port 389
+
+**Probe:** BER response received (code 49 = invalidCredentials).
+
+**Verdict:** Correct protocol. nmap IDs it as "Cisco LDAP server" which is fine. No rootDSE response for unauthenticated enumeration.
+
+---
+
+### Telnet โ port 23 (Cowrie)
+
+**Probe:**
+```
+login:
+Password:
+Login incorrect โ for all tried credentials
+```
+
+**Verdict:** Cowrie is running but rejecting everything. Default Cowrie credentials (root/1234, admin/admin, etc.) should work. May be a config issue with the decky hostname or user database.
+
+---
+
+### Conpot โ port 502
+
+**Verdict:** Not responding on port 502 (Modbus TCP). Conpot may use a different internal port that gets NAT'd, or it's not configured for Modbus. Needs investigation.
+
+---
+
+## Bug Ledger
+
+| # | Service | Bug | Severity |
+|---|------------|-------------------------------------------|----------|
+| 1 | SMTP | DATA handler returns 502 for every line | Critical |
+| 2 | HTTP | Werkzeug in Server header + bare 403 body | High |
+| 3 | FTP | "Twisted 25.5.0" in banner | High |
+| 4 | MSSQL | No response to TDS pre-login | High |
+| 5 | MongoDB | No response to OP_MSG isMaster | High |
+| 6 | K8s | Not responding (TLS setup?) | Medium |
+| 7 | IMAP/POP3 | Always rejects, no mailbox ops | Critical (feature gap) |
+| 8 | Redis | Empty keyspace after AUTH success | Medium |
+| 9 | SIP/VNC | Hardcoded nonce/challenge | Low |
+| 10| MQTT | Rejects all connections | High (ICS feature gap) |
+| 11| Conpot | No Modbus response | Medium |
+| 12| PostgreSQL | Hardcoded salt `deadbeef` | Low |
+
+---
+
+## Related Plans
+
+- [`SMTP_RELAY.md`](SMTP_RELAY.md) โ Fix DATA handler, implement open relay persona
+- [`IMAP_BAIT.md`](IMAP_BAIT.md) โ Auth + seeded mailboxes + POP3 parity
+- [`ICS_SCADA.md`](ICS_SCADA.md) โ MQTT water plant, SNMP tuning, Conpot
+- [`BUG_FIXES.md`](BUG_FIXES.md) โ HTTP header leak, FTP banner, MSSQL, MongoDB, Redis keys
diff --git a/templates/conpot/Dockerfile b/templates/conpot/Dockerfile
new file mode 100644
index 0000000..c1596ca
--- /dev/null
+++ b/templates/conpot/Dockerfile
@@ -0,0 +1,15 @@
+ARG BASE_IMAGE=honeynet/conpot:latest
+FROM ${BASE_IMAGE}
+
+USER root
+
+# Temporary fix: Conpot's default config binds Modbus to a non-privileged port (like 5020).
+# DECNET requires it to bind directly to 502 for the honeypot to work as expected.
+# We search the template directories and replace the port configuration.
+# This is a temporary fix pending an upstream PR from the Conpot maintainers.
+RUN find /opt /usr /etc /home -name "*.xml" -exec sed -i 's/5020<\/port>/502<\/port>/g' {} + 2>/dev/null || true
+RUN find /opt /usr /etc /home -name "*.xml" -exec sed -i 's/port="5020"/port="502"/g' {} + 2>/dev/null || true
+
+# Switching back to the internal user if standard in conpot (falling back to nobody/conpot as appropriate)
+# Conpot image usually runs as 'conpot' user
+USER conpot
diff --git a/templates/docker_api/decnet_logging.py b/templates/docker_api/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/docker_api/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/elasticsearch/decnet_logging.py b/templates/elasticsearch/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/elasticsearch/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/ftp/decnet_logging.py b/templates/ftp/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/ftp/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/http/decnet_logging.py b/templates/http/decnet_logging.py
index ff05fd8..5a09505 100644
--- a/templates/http/decnet_logging.py
+++ b/templates/http/decnet_logging.py
@@ -2,10 +2,9 @@
"""
Shared RFC 5424 syslog helper for DECNET service templates.
-Provides two functions consumed by every service's server.py:
- - syslog_line(service, hostname, event_type, severity, **fields) -> str
- - write_syslog_file(line: str) -> None
- - forward_syslog(line: str, log_target: str) -> None
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
RFC 5424 structure:
1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
@@ -13,12 +12,7 @@ RFC 5424 structure:
Facility: local0 (16), PEN for SD element ID: decnet@55555
"""
-import logging
-import logging.handlers
-import os
-import socket
from datetime import datetime, timezone
-from pathlib import Path
from typing import Any
# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -40,11 +34,6 @@ _MAX_HOSTNAME = 255
_MAX_APPNAME = 48
_MAX_MSGID = 32
-_LOG_FILE_ENV = "DECNET_LOG_FILE"
-_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
-_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
-_BACKUP_COUNT = 5
-
# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def _sd_escape(value: str) -> str:
@@ -90,156 +79,11 @@ def syslog_line(
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
-# โโโ File handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-_file_logger: logging.Logger | None = None
-
-
-def _get_file_logger() -> logging.Logger:
- global _file_logger
- if _file_logger is not None:
- return _file_logger
-
- log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
- try:
- log_path.parent.mkdir(parents=True, exist_ok=True)
- handler = logging.handlers.RotatingFileHandler(
- log_path,
- maxBytes=_MAX_BYTES,
- backupCount=_BACKUP_COUNT,
- encoding="utf-8",
- )
- except OSError:
- handler = logging.StreamHandler()
-
- handler.setFormatter(logging.Formatter("%(message)s"))
- _file_logger = logging.getLogger("decnet.syslog")
- _file_logger.setLevel(logging.DEBUG)
- _file_logger.propagate = False
- _file_logger.addHandler(handler)
- return _file_logger
-
-
-
-_json_logger: logging.Logger | None = None
-
-def _get_json_logger() -> logging.Logger:
- global _json_logger
- if _json_logger is not None:
- return _json_logger
-
- log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
- json_path = Path(log_path_str).with_suffix(".json")
- try:
- json_path.parent.mkdir(parents=True, exist_ok=True)
- handler = logging.handlers.RotatingFileHandler(
- json_path,
- maxBytes=_MAX_BYTES,
- backupCount=_BACKUP_COUNT,
- encoding="utf-8",
- )
- except OSError:
- handler = logging.StreamHandler()
-
- handler.setFormatter(logging.Formatter("%(message)s"))
- _json_logger = logging.getLogger("decnet.json")
- _json_logger.setLevel(logging.DEBUG)
- _json_logger.propagate = False
- _json_logger.addHandler(handler)
- return _json_logger
-
-
-
-
def write_syslog_file(line: str) -> None:
- """Append a syslog line to the rotating log file."""
- try:
- _get_file_logger().info(line)
-
- # Also parse and write JSON log
- import json
- import re
- from datetime import datetime
- from typing import Optional, Any
-
- _RFC5424_RE: re.Pattern = re.compile(
- r"^<\d+>1 "
- r"(\S+) " # 1: TIMESTAMP
- r"(\S+) " # 2: HOSTNAME (decky name)
- r"(\S+) " # 3: APP-NAME (service)
- r"- " # PROCID always NILVALUE
- r"(\S+) " # 4: MSGID (event_type)
- r"(.+)$", # 5: SD element + optional MSG
- )
- _SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
- _PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
- _IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
-
- _m: Optional[re.Match] = _RFC5424_RE.match(line)
- if _m:
- _ts_raw: str
- _decky: str
- _service: str
- _event_type: str
- _sd_rest: str
- _ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
-
- _fields: dict[str, str] = {}
- _msg: str = ""
-
- if _sd_rest.startswith("-"):
- _msg = _sd_rest[1:].lstrip()
- elif _sd_rest.startswith("["):
- _block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
- if _block:
- for _k, _v in _PARAM_RE.findall(_block.group(1)):
- _fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
-
- # extract msg after the block
- _msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
- if _msg_match:
- _msg = _msg_match.group(1).strip()
- else:
- _msg = _sd_rest
-
- _attacker_ip: str = "Unknown"
- for _fname in _IP_FIELDS:
- if _fname in _fields:
- _attacker_ip = _fields[_fname]
- break
-
- # Parse timestamp to normalize it
- _ts_formatted: str
- try:
- _ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
- except ValueError:
- _ts_formatted = _ts_raw
-
- _payload: dict[str, Any] = {
- "timestamp": _ts_formatted,
- "decky": _decky,
- "service": _service,
- "event_type": _event_type,
- "attacker_ip": _attacker_ip,
- "fields": json.dumps(_fields),
- "msg": _msg,
- "raw_line": line
- }
- _get_json_logger().info(json.dumps(_payload))
-
- except Exception:
- pass
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
-# โโโ TCP forwarding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
def forward_syslog(line: str, log_target: str) -> None:
- """Forward a syslog line over TCP to log_target (ip:port)."""
- if not log_target:
- return
- try:
- host, port = log_target.rsplit(":", 1)
- with socket.create_connection((host, int(port)), timeout=3) as s:
- s.sendall((line + "\n").encode())
- except Exception:
- pass
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/imap/decnet_logging.py b/templates/imap/decnet_logging.py
index ff05fd8..5a09505 100644
--- a/templates/imap/decnet_logging.py
+++ b/templates/imap/decnet_logging.py
@@ -2,10 +2,9 @@
"""
Shared RFC 5424 syslog helper for DECNET service templates.
-Provides two functions consumed by every service's server.py:
- - syslog_line(service, hostname, event_type, severity, **fields) -> str
- - write_syslog_file(line: str) -> None
- - forward_syslog(line: str, log_target: str) -> None
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
RFC 5424 structure:
1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
@@ -13,12 +12,7 @@ RFC 5424 structure:
Facility: local0 (16), PEN for SD element ID: decnet@55555
"""
-import logging
-import logging.handlers
-import os
-import socket
from datetime import datetime, timezone
-from pathlib import Path
from typing import Any
# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -40,11 +34,6 @@ _MAX_HOSTNAME = 255
_MAX_APPNAME = 48
_MAX_MSGID = 32
-_LOG_FILE_ENV = "DECNET_LOG_FILE"
-_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
-_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
-_BACKUP_COUNT = 5
-
# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def _sd_escape(value: str) -> str:
@@ -90,156 +79,11 @@ def syslog_line(
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
-# โโโ File handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-_file_logger: logging.Logger | None = None
-
-
-def _get_file_logger() -> logging.Logger:
- global _file_logger
- if _file_logger is not None:
- return _file_logger
-
- log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
- try:
- log_path.parent.mkdir(parents=True, exist_ok=True)
- handler = logging.handlers.RotatingFileHandler(
- log_path,
- maxBytes=_MAX_BYTES,
- backupCount=_BACKUP_COUNT,
- encoding="utf-8",
- )
- except OSError:
- handler = logging.StreamHandler()
-
- handler.setFormatter(logging.Formatter("%(message)s"))
- _file_logger = logging.getLogger("decnet.syslog")
- _file_logger.setLevel(logging.DEBUG)
- _file_logger.propagate = False
- _file_logger.addHandler(handler)
- return _file_logger
-
-
-
-_json_logger: logging.Logger | None = None
-
-def _get_json_logger() -> logging.Logger:
- global _json_logger
- if _json_logger is not None:
- return _json_logger
-
- log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
- json_path = Path(log_path_str).with_suffix(".json")
- try:
- json_path.parent.mkdir(parents=True, exist_ok=True)
- handler = logging.handlers.RotatingFileHandler(
- json_path,
- maxBytes=_MAX_BYTES,
- backupCount=_BACKUP_COUNT,
- encoding="utf-8",
- )
- except OSError:
- handler = logging.StreamHandler()
-
- handler.setFormatter(logging.Formatter("%(message)s"))
- _json_logger = logging.getLogger("decnet.json")
- _json_logger.setLevel(logging.DEBUG)
- _json_logger.propagate = False
- _json_logger.addHandler(handler)
- return _json_logger
-
-
-
-
def write_syslog_file(line: str) -> None:
- """Append a syslog line to the rotating log file."""
- try:
- _get_file_logger().info(line)
-
- # Also parse and write JSON log
- import json
- import re
- from datetime import datetime
- from typing import Optional, Any
-
- _RFC5424_RE: re.Pattern = re.compile(
- r"^<\d+>1 "
- r"(\S+) " # 1: TIMESTAMP
- r"(\S+) " # 2: HOSTNAME (decky name)
- r"(\S+) " # 3: APP-NAME (service)
- r"- " # PROCID always NILVALUE
- r"(\S+) " # 4: MSGID (event_type)
- r"(.+)$", # 5: SD element + optional MSG
- )
- _SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
- _PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
- _IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
-
- _m: Optional[re.Match] = _RFC5424_RE.match(line)
- if _m:
- _ts_raw: str
- _decky: str
- _service: str
- _event_type: str
- _sd_rest: str
- _ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
-
- _fields: dict[str, str] = {}
- _msg: str = ""
-
- if _sd_rest.startswith("-"):
- _msg = _sd_rest[1:].lstrip()
- elif _sd_rest.startswith("["):
- _block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
- if _block:
- for _k, _v in _PARAM_RE.findall(_block.group(1)):
- _fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
-
- # extract msg after the block
- _msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
- if _msg_match:
- _msg = _msg_match.group(1).strip()
- else:
- _msg = _sd_rest
-
- _attacker_ip: str = "Unknown"
- for _fname in _IP_FIELDS:
- if _fname in _fields:
- _attacker_ip = _fields[_fname]
- break
-
- # Parse timestamp to normalize it
- _ts_formatted: str
- try:
- _ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
- except ValueError:
- _ts_formatted = _ts_raw
-
- _payload: dict[str, Any] = {
- "timestamp": _ts_formatted,
- "decky": _decky,
- "service": _service,
- "event_type": _event_type,
- "attacker_ip": _attacker_ip,
- "fields": json.dumps(_fields),
- "msg": _msg,
- "raw_line": line
- }
- _get_json_logger().info(json.dumps(_payload))
-
- except Exception:
- pass
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
-# โโโ TCP forwarding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
def forward_syslog(line: str, log_target: str) -> None:
- """Forward a syslog line over TCP to log_target (ip:port)."""
- if not log_target:
- return
- try:
- host, port = log_target.rsplit(":", 1)
- with socket.create_connection((host, int(port)), timeout=3) as s:
- s.sendall((line + "\n").encode())
- except Exception:
- pass
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/imap/server.py b/templates/imap/server.py
index 98bf683..8a38293 100644
--- a/templates/imap/server.py
+++ b/templates/imap/server.py
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
"""
-IMAPserver.
-Presents an IMAP4rev1 banner, captures LOGIN credentials (plaintext and
-AUTHENTICATE), then returns a NO response. Logs all commands as JSON.
+IMAP server (port 143/993).
+Presents an IMAP4rev1 banner, captures LOGIN credentials.
+Implements a basic IMAP state machine (NOT_AUTHENTICATED -> AUTHENTICATED -> SELECTED).
+Provides hardcoded bait emails containing AWS API keys to attackers.
+Logs commands as JSON.
"""
import asyncio
@@ -12,10 +14,14 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
SERVICE_NAME = "imap"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
-BANNER = f"* OK [{NODE_NAME}] IMAP4rev1 Service Ready\r\n"
-
+IMAP_BANNER = os.environ.get("IMAP_BANNER", f"* OK [{NODE_NAME}] Dovecot ready.\r\n")
+IMAP_USERS = os.environ.get("IMAP_USERS", "admin:admin123,root:toor")
+_BAIT_EMAILS = [
+ (1, "Date: Tue, 01 Nov 2023 10:00:00 +0000\r\nFrom: sysadmin@company.com\r\nSubject: AWS Credentials\r\n\r\nHere are the new AWS keys:\r\nAKIAIOSFODNN7EXAMPLE\r\nwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\r\n"),
+ (2, "Date: Wed, 02 Nov 2023 11:30:00 +0000\r\nFrom: devops@company.com\r\nSubject: DB Password Reset\r\n\r\nThe production database password has been temporarily set to:\r\nProdDB_temp_2023!!\r\n"),
+]
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
@@ -23,18 +29,24 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
write_syslog_file(line)
forward_syslog(line, LOG_TARGET)
-
class IMAPProtocol(asyncio.Protocol):
def __init__(self):
self._transport = None
self._peer = None
self._buf = b""
+ self._state = "NOT_AUTHENTICATED"
+ self._valid_users = dict(u.split(":", 1) for u in IMAP_USERS.split(",") if ":" in u)
def connection_made(self, transport):
self._transport = transport
self._peer = transport.get_extra_info("peername", ("?", 0))
_log("connect", src=self._peer[0], src_port=self._peer[1])
- transport.write(BANNER.encode())
+ if IMAP_BANNER:
+ if not IMAP_BANNER.endswith("\r\n"):
+ padded_banner = IMAP_BANNER + "\r\n"
+ else:
+ padded_banner = IMAP_BANNER
+ transport.write(padded_banner.encode())
def data_received(self, data):
self._buf += data
@@ -50,22 +62,60 @@ class IMAPProtocol(asyncio.Protocol):
cmd = parts[1].upper() if len(parts) > 1 else ""
args = parts[2] if len(parts) > 2 else ""
- if cmd == "LOGIN":
+ _log("command", src=self._peer[0], cmd=line[:128], state=self._state)
+
+ if cmd == "CAPABILITY":
+ self._transport.write(b"* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=LOGIN\r\n")
+ self._transport.write(f"{tag} OK CAPABILITY completed\r\n".encode())
+
+ elif cmd == "LOGIN":
+ if self._state != "NOT_AUTHENTICATED":
+ self._transport.write(f"{tag} BAD Already authenticated\r\n".encode())
+ return
creds = args.split(None, 1)
username = creds[0].strip('"') if creds else ""
password = creds[1].strip('"') if len(creds) > 1 else ""
- _log("auth", src=self._peer[0], username=username, password=password)
- self._transport.write(f"{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n".encode())
- elif cmd == "CAPABILITY":
- self._transport.write(b"* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=LOGIN\r\n")
- self._transport.write(f"{tag} OK CAPABILITY completed\r\n".encode())
+
+ if username in self._valid_users and self._valid_users[username] == password:
+ self._state = "AUTHENTICATED"
+ _log("auth", src=self._peer[0], username=username, password=password, status="success")
+ self._transport.write(f"{tag} OK [CAPABILITY IMAP4rev1] Logged in\r\n".encode())
+ else:
+ _log("auth", src=self._peer[0], username=username, password=password, status="failed")
+ self._transport.write(f"{tag} NO [AUTHENTICATIONFAILED] Authentication failed.\r\n".encode())
+
+ elif cmd == "SELECT" or cmd == "EXAMINE":
+ if self._state == "NOT_AUTHENTICATED":
+ self._transport.write(f"{tag} BAD Not authenticated\r\n".encode())
+ return
+
+ self._state = "SELECTED"
+ count = len(_BAIT_EMAILS)
+ self._transport.write(f"* {count} EXISTS\r\n* 0 RECENT\r\n* OK [UIDVALIDITY 1] UIDs valid\r\n".encode())
+ self._transport.write(f"{tag} OK [READ-WRITE] Select completed.\r\n".encode())
+
+ elif cmd == "FETCH":
+ if self._state != "SELECTED":
+ self._transport.write(f"{tag} BAD Not selected\r\n".encode())
+ return
+
+ # rudimentary fetch match simply returning all if any match
+ # an attacker usually sends "FETCH 1:* (BODY[])" or similar
+ if "RFC822" in args.upper() or "BODY" in args.upper():
+ for uid, content in _BAIT_EMAILS:
+ content_encoded = content.encode()
+ self._transport.write(f"* {uid} FETCH (RFC822 {{{len(content_encoded)}}}\r\n".encode())
+ self._transport.write(content_encoded)
+ self._transport.write(b")\r\n")
+ self._transport.write(f"{tag} OK Fetch completed.\r\n".encode())
+
elif cmd == "LOGOUT":
- self._transport.write(b"* BYE IMAP4rev1 Server logging out\r\n")
- self._transport.write(f"{tag} OK LOGOUT completed\r\n".encode())
+ self._transport.write(b"* BYE Logging out\r\n")
+ self._transport.write(f"{tag} OK Logout completed.\r\n".encode())
self._transport.close()
+
else:
- _log("command", src=self._peer[0], cmd=line[:128])
- self._transport.write(f"{tag} BAD Command not recognized\r\n".encode())
+ self._transport.write(f"{tag} BAD Command not recognized or unsupported\r\n".encode())
def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?")
diff --git a/templates/k8s/decnet_logging.py b/templates/k8s/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/k8s/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/ldap/decnet_logging.py b/templates/ldap/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/ldap/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/llmnr/decnet_logging.py b/templates/llmnr/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/llmnr/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/mongodb/decnet_logging.py b/templates/mongodb/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/mongodb/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/mqtt/decnet_logging.py b/templates/mqtt/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/mqtt/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/mqtt/server.py b/templates/mqtt/server.py
index 7d2b2e7..c5f7f15 100644
--- a/templates/mqtt/server.py
+++ b/templates/mqtt/server.py
@@ -1,26 +1,30 @@
#!/usr/bin/env python3
"""
MQTT server (port 1883).
-Parses MQTT CONNECT packets, extracts client_id, username, and password,
-then returns CONNACK with return code 5 (not authorized). Logs all
-interactions as JSON.
+Parses MQTT CONNECT packets, extracts client_id, etc.
+Responds with CONNACK.
+Supports dynamic topics and retained publishes.
+Logs PUBLISH commands sent by clients.
"""
import asyncio
+import json
import os
+import random
import struct
from decnet_logging import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker")
SERVICE_NAME = "mqtt"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
+MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "1") == "1"
+MQTT_PERSONA = os.environ.get("MQTT_PERSONA", "water_plant")
+MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "")
-# CONNACK: packet type 0x20, remaining length 2, session_present=0, return_code=5
+_CONNACK_ACCEPTED = b"\x20\x02\x00\x00"
_CONNACK_NOT_AUTH = b"\x20\x02\x00\x05"
-
-
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
print(line, flush=True)
@@ -38,45 +42,128 @@ def _read_utf8(data: bytes, pos: int):
def _parse_connect(payload: bytes):
- """Extract client_id, username, password from MQTT CONNECT payload."""
pos = 0
- # Protocol name
proto_name, pos = _read_utf8(payload, pos)
- # Protocol level (1 byte)
if pos >= len(payload):
return {}, pos
_proto_level = payload[pos]
pos += 1
- # Connect flags (1 byte)
if pos >= len(payload):
return {}, pos
flags = payload[pos]
pos += 1
- # Keep alive (2 bytes)
- pos += 2
- # Client ID
+ pos += 2 # Keep alive
client_id, pos = _read_utf8(payload, pos)
result = {"client_id": client_id, "proto": proto_name}
- # Will flag
if flags & 0x04:
- _, pos = _read_utf8(payload, pos) # will topic
- _, pos = _read_utf8(payload, pos) # will message
- # Username flag
+ _, pos = _read_utf8(payload, pos)
+ _, pos = _read_utf8(payload, pos)
if flags & 0x80:
username, pos = _read_utf8(payload, pos)
result["username"] = username
- # Password flag
if flags & 0x40:
password, pos = _read_utf8(payload, pos)
result["password"] = password
return result
+def _parse_subscribe(payload: bytes):
+ """Returns (packet_id, [(topic, qos), ...])"""
+ if len(payload) < 2:
+ return 0, []
+ pos = 0
+ packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
+ pos += 2
+ topics = []
+ while pos < len(payload):
+ topic, pos = _read_utf8(payload, pos)
+ if pos >= len(payload):
+ break
+ qos = payload[pos] & 0x03
+ pos += 1
+ topics.append((topic, qos))
+ return packet_id, topics
+
+
+def _suback(packet_id: int, granted_qos: list[int]) -> bytes:
+ payload = struct.pack(">H", packet_id) + bytes(granted_qos)
+ return bytes([0x90, len(payload)]) + payload
+
+
+def _publish(topic: str, value: str, retain: bool = True) -> bytes:
+ topic_bytes = topic.encode()
+ topic_len = struct.pack(">H", len(topic_bytes))
+ payload = str(value).encode()
+ fixed = 0x31 if retain else 0x30
+ remaining = len(topic_len) + len(topic_bytes) + len(payload)
+
+ # variable length encoding
+ rem_bytes = []
+ while remaining > 0:
+ encoded = remaining % 128
+ remaining = remaining // 128
+ if remaining > 0:
+ encoded = encoded | 128
+ rem_bytes.append(encoded)
+ if not rem_bytes:
+ rem_bytes = [0]
+
+ return bytes([fixed]) + bytes(rem_bytes) + topic_len + topic_bytes + payload
+
+
+def _parse_publish(payload: bytes, qos: int):
+ pos = 0
+ topic, pos = _read_utf8(payload, pos)
+ packet_id = 0
+ if qos > 0:
+ if pos + 2 <= len(payload):
+ packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
+ pos += 2
+ data = payload[pos:]
+ return topic, packet_id, data
+
+
+def _generate_topics() -> dict:
+ topics: dict = {}
+ if MQTT_CUSTOM_TOPICS:
+ try:
+ topics = json.loads(MQTT_CUSTOM_TOPICS)
+ return topics
+ except Exception as e:
+ _log("config_error", severity=4, error=str(e))
+
+ if MQTT_PERSONA == "water_plant":
+ topics.update({
+ "plant/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
+ "plant/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}",
+ "plant/water/pump1/status": "RUNNING",
+ "plant/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}",
+ "plant/water/pump2/status": "STANDBY",
+ "plant/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}",
+ "plant/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}",
+ "plant/water/valve/inlet/state": "OPEN",
+ "plant/water/valve/drain/state": "CLOSED",
+ "plant/alarm/high_pressure": "0",
+ "plant/alarm/low_chlorine": "0",
+ "plant/alarm/pump_fault": "0",
+ "plant/$SYS/broker/version": "Mosquitto 2.0.15",
+ "plant/$SYS/broker/uptime": "2847392",
+ })
+ elif not topics:
+ topics = {
+ "device/status": "online",
+ "device/uptime": "3600"
+ }
+ return topics
+
+
class MQTTProtocol(asyncio.Protocol):
def __init__(self):
self._transport = None
self._peer = None
self._buf = b""
+ self._auth = False
+ self._topics = _generate_topics()
def connection_made(self, transport):
self._transport = transport
@@ -85,11 +172,20 @@ class MQTTProtocol(asyncio.Protocol):
def data_received(self, data):
self._buf += data
- self._process()
+ try:
+ self._process()
+ except Exception as e:
+ _log("protocol_error", severity=4, error=str(e))
+ if self._transport:
+ self._transport.close()
def _process(self):
while len(self._buf) >= 2:
- pkt_type = (self._buf[0] >> 4) & 0x0f
+ pkt_byte = self._buf[0]
+ pkt_type = (pkt_byte >> 4) & 0x0f
+ flags = pkt_byte & 0x0f
+ qos = (flags >> 1) & 0x03
+
# Decode remaining length (variable-length encoding)
pos = 1
remaining = 0
@@ -110,11 +206,49 @@ class MQTTProtocol(asyncio.Protocol):
if pkt_type == 1: # CONNECT
info = _parse_connect(payload)
- _log("auth", src=self._peer[0], **info)
- self._transport.write(_CONNACK_NOT_AUTH)
- self._transport.close()
+ _log("auth", **info)
+ if MQTT_ACCEPT_ALL:
+ self._auth = True
+ self._transport.write(_CONNACK_ACCEPTED)
+ else:
+ self._transport.write(_CONNACK_NOT_AUTH)
+ self._transport.close()
+ elif pkt_type == 8: # SUBSCRIBE
+ if not self._auth:
+ self._transport.close()
+ continue
+ packet_id, subs = _parse_subscribe(payload)
+ granted_qos = [1] * len(subs) # grant QoS 1 for all
+ self._transport.write(_suback(packet_id, granted_qos))
+
+ # Immediately send retained publishes matching topics
+ for sub_topic, _ in subs:
+ _log("subscribe", src=self._peer[0], topics=[sub_topic])
+ for t, v in self._topics.items():
+ # simple match: if topic ends with #, it matches prefix
+ if sub_topic.endswith("#"):
+ prefix = sub_topic[:-1]
+ if t.startswith(prefix):
+ self._transport.write(_publish(t, str(v)))
+ elif sub_topic == t:
+ self._transport.write(_publish(t, str(v)))
+
+ elif pkt_type == 3: # PUBLISH
+ if not self._auth:
+ self._transport.close()
+ continue
+ topic, packet_id, data = _parse_publish(payload, qos)
+ # Attacker command received!
+ _log("publish", src=self._peer[0], topic=topic, payload=data.decode(errors="replace"))
+
+ if qos == 1:
+ puback = bytes([0x40, 0x02]) + struct.pack(">H", packet_id)
+ self._transport.write(puback)
+
elif pkt_type == 12: # PINGREQ
self._transport.write(b"\xd0\x00") # PINGRESP
+ elif pkt_type == 14: # DISCONNECT
+ self._transport.close()
else:
_log("packet", src=self._peer[0], pkt_type=pkt_type)
self._transport.close()
diff --git a/templates/mssql/decnet_logging.py b/templates/mssql/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/mssql/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/mysql/decnet_logging.py b/templates/mysql/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/mysql/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/pop3/decnet_logging.py b/templates/pop3/decnet_logging.py
index ff05fd8..5a09505 100644
--- a/templates/pop3/decnet_logging.py
+++ b/templates/pop3/decnet_logging.py
@@ -2,10 +2,9 @@
"""
Shared RFC 5424 syslog helper for DECNET service templates.
-Provides two functions consumed by every service's server.py:
- - syslog_line(service, hostname, event_type, severity, **fields) -> str
- - write_syslog_file(line: str) -> None
- - forward_syslog(line: str, log_target: str) -> None
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
RFC 5424 structure:
1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
@@ -13,12 +12,7 @@ RFC 5424 structure:
Facility: local0 (16), PEN for SD element ID: decnet@55555
"""
-import logging
-import logging.handlers
-import os
-import socket
from datetime import datetime, timezone
-from pathlib import Path
from typing import Any
# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -40,11 +34,6 @@ _MAX_HOSTNAME = 255
_MAX_APPNAME = 48
_MAX_MSGID = 32
-_LOG_FILE_ENV = "DECNET_LOG_FILE"
-_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
-_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
-_BACKUP_COUNT = 5
-
# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def _sd_escape(value: str) -> str:
@@ -90,156 +79,11 @@ def syslog_line(
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
-# โโโ File handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-_file_logger: logging.Logger | None = None
-
-
-def _get_file_logger() -> logging.Logger:
- global _file_logger
- if _file_logger is not None:
- return _file_logger
-
- log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
- try:
- log_path.parent.mkdir(parents=True, exist_ok=True)
- handler = logging.handlers.RotatingFileHandler(
- log_path,
- maxBytes=_MAX_BYTES,
- backupCount=_BACKUP_COUNT,
- encoding="utf-8",
- )
- except OSError:
- handler = logging.StreamHandler()
-
- handler.setFormatter(logging.Formatter("%(message)s"))
- _file_logger = logging.getLogger("decnet.syslog")
- _file_logger.setLevel(logging.DEBUG)
- _file_logger.propagate = False
- _file_logger.addHandler(handler)
- return _file_logger
-
-
-
-_json_logger: logging.Logger | None = None
-
-def _get_json_logger() -> logging.Logger:
- global _json_logger
- if _json_logger is not None:
- return _json_logger
-
- log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
- json_path = Path(log_path_str).with_suffix(".json")
- try:
- json_path.parent.mkdir(parents=True, exist_ok=True)
- handler = logging.handlers.RotatingFileHandler(
- json_path,
- maxBytes=_MAX_BYTES,
- backupCount=_BACKUP_COUNT,
- encoding="utf-8",
- )
- except OSError:
- handler = logging.StreamHandler()
-
- handler.setFormatter(logging.Formatter("%(message)s"))
- _json_logger = logging.getLogger("decnet.json")
- _json_logger.setLevel(logging.DEBUG)
- _json_logger.propagate = False
- _json_logger.addHandler(handler)
- return _json_logger
-
-
-
-
def write_syslog_file(line: str) -> None:
- """Append a syslog line to the rotating log file."""
- try:
- _get_file_logger().info(line)
-
- # Also parse and write JSON log
- import json
- import re
- from datetime import datetime
- from typing import Optional, Any
-
- _RFC5424_RE: re.Pattern = re.compile(
- r"^<\d+>1 "
- r"(\S+) " # 1: TIMESTAMP
- r"(\S+) " # 2: HOSTNAME (decky name)
- r"(\S+) " # 3: APP-NAME (service)
- r"- " # PROCID always NILVALUE
- r"(\S+) " # 4: MSGID (event_type)
- r"(.+)$", # 5: SD element + optional MSG
- )
- _SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
- _PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
- _IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
-
- _m: Optional[re.Match] = _RFC5424_RE.match(line)
- if _m:
- _ts_raw: str
- _decky: str
- _service: str
- _event_type: str
- _sd_rest: str
- _ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
-
- _fields: dict[str, str] = {}
- _msg: str = ""
-
- if _sd_rest.startswith("-"):
- _msg = _sd_rest[1:].lstrip()
- elif _sd_rest.startswith("["):
- _block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
- if _block:
- for _k, _v in _PARAM_RE.findall(_block.group(1)):
- _fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
-
- # extract msg after the block
- _msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
- if _msg_match:
- _msg = _msg_match.group(1).strip()
- else:
- _msg = _sd_rest
-
- _attacker_ip: str = "Unknown"
- for _fname in _IP_FIELDS:
- if _fname in _fields:
- _attacker_ip = _fields[_fname]
- break
-
- # Parse timestamp to normalize it
- _ts_formatted: str
- try:
- _ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
- except ValueError:
- _ts_formatted = _ts_raw
-
- _payload: dict[str, Any] = {
- "timestamp": _ts_formatted,
- "decky": _decky,
- "service": _service,
- "event_type": _event_type,
- "attacker_ip": _attacker_ip,
- "fields": json.dumps(_fields),
- "msg": _msg,
- "raw_line": line
- }
- _get_json_logger().info(json.dumps(_payload))
-
- except Exception:
- pass
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
-# โโโ TCP forwarding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
def forward_syslog(line: str, log_target: str) -> None:
- """Forward a syslog line over TCP to log_target (ip:port)."""
- if not log_target:
- return
- try:
- host, port = log_target.rsplit(":", 1)
- with socket.create_connection((host, int(port)), timeout=3) as s:
- s.sendall((line + "\n").encode())
- except Exception:
- pass
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/pop3/server.py b/templates/pop3/server.py
index c59a0a7..17f0bdf 100644
--- a/templates/pop3/server.py
+++ b/templates/pop3/server.py
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""
-POP3server.
-Presents a convincing POP3 banner, collects USER/PASS credentials, then
-stalls with a generic error. Logs every interaction as JSON and forwards
-to LOG_TARGET if set.
+POP3 server (port 110/995).
+Presents a POP3 banner, captures USER and PASS credentials.
+Implements a basic POP3 state machine (AUTHORIZATION -> TRANSACTION).
+Provides hardcoded bait emails.
+Logs commands as JSON.
"""
import asyncio
@@ -13,10 +14,14 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
SERVICE_NAME = "pop3"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
-BANNER = f"+OK {NODE_NAME} POP3 server ready\r\n"
-
+POP3_BANNER = os.environ.get("IMAP_BANNER", f"+OK [{NODE_NAME}] Dovecot ready.\r\n")
+IMAP_USERS = os.environ.get("IMAP_USERS", "admin:admin123,root:toor")
+_BAIT_EMAILS = [
+ "Date: Tue, 01 Nov 2023 10:00:00 +0000\r\nFrom: sysadmin@company.com\r\nSubject: AWS Credentials\r\n\r\nHere are the new AWS keys:\r\nAKIAIOSFODNN7EXAMPLE\r\nwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\r\n",
+ "Date: Wed, 02 Nov 2023 11:30:00 +0000\r\nFrom: devops@company.com\r\nSubject: DB Password Reset\r\n\r\nThe production database password has been temporarily set to:\r\nProdDB_temp_2023!!\r\n",
+]
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
@@ -24,19 +29,27 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
write_syslog_file(line)
forward_syslog(line, LOG_TARGET)
-
class POP3Protocol(asyncio.Protocol):
def __init__(self):
self._transport = None
self._peer = None
- self._user = None
self._buf = b""
+ self._state = "AUTHORIZATION"
+ self._valid_users = dict(u.split(":", 1) for u in IMAP_USERS.split(",") if ":" in u)
+ self._current_user = None
def connection_made(self, transport):
self._transport = transport
self._peer = transport.get_extra_info("peername", ("?", 0))
_log("connect", src=self._peer[0], src_port=self._peer[1])
- transport.write(BANNER.encode())
+ if POP3_BANNER:
+ if not POP3_BANNER.endswith("\r\n"):
+ padded_banner = POP3_BANNER + "\r\n"
+ else:
+ padded_banner = POP3_BANNER
+ if not padded_banner.startswith("+OK"):
+ padded_banner = "+OK " + padded_banner.lstrip("* OK ") # replace IMAP prefix with POP3
+ transport.write(padded_banner.encode())
def data_received(self, data):
self._buf += data
@@ -45,28 +58,98 @@ class POP3Protocol(asyncio.Protocol):
self._handle_line(line.decode(errors="replace").strip())
def _handle_line(self, line: str):
- upper = line.upper()
- if upper.startswith("USER "):
- self._user = line[5:].strip()
- _log("user", src=self._peer[0], username=self._user)
- self._transport.write(b"+OK\r\n")
- elif upper.startswith("PASS "):
- password = line[5:].strip()
- _log("auth", src=self._peer[0], username=self._user, password=password)
- self._transport.write(b"-ERR Authentication failed\r\n")
- elif upper == "QUIT":
- self._transport.write(b"+OK Bye\r\n")
- self._transport.close()
- elif upper == "CAPA":
+ parts = line.split(None, 1)
+ if not parts:
+ return
+ cmd = parts[0].upper()
+ args = parts[1] if len(parts) > 1 else ""
+
+ _log("command", src=self._peer[0], cmd=line[:128], state=self._state)
+
+ if cmd == "CAPA":
self._transport.write(b"+OK Capability list follows\r\nUSER\r\n.\r\n")
+
+ elif cmd == "USER":
+ if self._state != "AUTHORIZATION":
+ self._transport.write(b"-ERR Already authenticated.\r\n")
+ return
+ self._current_user = args
+ self._transport.write(b"+OK User name accepted, password please\r\n")
+
+ elif cmd == "PASS":
+ if self._state != "AUTHORIZATION":
+ self._transport.write(b"-ERR Already authenticated.\r\n")
+ return
+ if not self._current_user:
+ self._transport.write(b"-ERR USER required first.\r\n")
+ return
+
+ password = args
+ username = self._current_user
+
+ if username in self._valid_users and self._valid_users[username] == password:
+ self._state = "TRANSACTION"
+ _log("auth", src=self._peer[0], username=username, password=password, status="success")
+ self._transport.write(b"+OK Logged in.\r\n")
+ else:
+ _log("auth", src=self._peer[0], username=username, password=password, status="failed")
+ self._transport.write(b"-ERR Authentication failed.\r\n")
+ self._current_user = None
+
+ elif cmd == "STAT":
+ if self._state != "TRANSACTION":
+ self._transport.write(b"-ERR Not authenticated\r\n")
+ return
+ total_size = sum(len(e) for e in _BAIT_EMAILS)
+ self._transport.write(f"+OK {len(_BAIT_EMAILS)} {total_size}\r\n".encode())
+
+ elif cmd == "LIST":
+ if self._state != "TRANSACTION":
+ self._transport.write(b"-ERR Not authenticated\r\n")
+ return
+
+ if args:
+ try:
+ idx = int(args) - 1
+ if 0 <= idx < len(_BAIT_EMAILS):
+ self._transport.write(f"+OK {idx + 1} {len(_BAIT_EMAILS[idx])}\r\n".encode())
+ else:
+ self._transport.write(b"-ERR No such message\r\n")
+ except ValueError:
+ self._transport.write(b"-ERR Invalid argument\r\n")
+ else:
+ total_size = sum(len(e) for e in _BAIT_EMAILS)
+ self._transport.write(f"+OK {len(_BAIT_EMAILS)} messages ({total_size} octets)\r\n".encode())
+ for i, email in enumerate(_BAIT_EMAILS):
+ self._transport.write(f"{i + 1} {len(email)}\r\n".encode())
+ self._transport.write(b".\r\n")
+
+ elif cmd == "RETR":
+ if self._state != "TRANSACTION":
+ self._transport.write(b"-ERR Not authenticated\r\n")
+ return
+ try:
+ idx = int(args) - 1
+ if 0 <= idx < len(_BAIT_EMAILS):
+ email = _BAIT_EMAILS[idx]
+ self._transport.write(f"+OK {len(email)} octets\r\n".encode())
+ self._transport.write(email.encode())
+ self._transport.write(b".\r\n")
+ else:
+ self._transport.write(b"-ERR No such message\r\n")
+ except ValueError:
+ self._transport.write(b"-ERR Invalid argument\r\n")
+
+ elif cmd == "QUIT":
+ self._transport.write(b"+OK Logging out.\r\n")
+ self._transport.close()
+
else:
- _log("command", src=self._peer[0], cmd=line[:128])
- self._transport.write(b"-ERR Unknown command\r\n")
+ self._transport.write(b"-ERR Command not recognized\r\n")
def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?")
-
async def main():
_log("startup", msg=f"POP3 server starting as {NODE_NAME}")
loop = asyncio.get_running_loop()
@@ -74,6 +157,5 @@ async def main():
async with server:
await server.serve_forever()
-
if __name__ == "__main__":
asyncio.run(main())
diff --git a/templates/postgres/decnet_logging.py b/templates/postgres/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/postgres/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/rdp/decnet_logging.py b/templates/rdp/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/rdp/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/redis/decnet_logging.py b/templates/redis/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/redis/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/sip/decnet_logging.py b/templates/sip/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/sip/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/smb/decnet_logging.py b/templates/smb/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/smb/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/smtp/decnet_logging.py b/templates/smtp/decnet_logging.py
index ff05fd8..5a09505 100644
--- a/templates/smtp/decnet_logging.py
+++ b/templates/smtp/decnet_logging.py
@@ -2,10 +2,9 @@
"""
Shared RFC 5424 syslog helper for DECNET service templates.
-Provides two functions consumed by every service's server.py:
- - syslog_line(service, hostname, event_type, severity, **fields) -> str
- - write_syslog_file(line: str) -> None
- - forward_syslog(line: str, log_target: str) -> None
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
RFC 5424 structure:
1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
@@ -13,12 +12,7 @@ RFC 5424 structure:
Facility: local0 (16), PEN for SD element ID: decnet@55555
"""
-import logging
-import logging.handlers
-import os
-import socket
from datetime import datetime, timezone
-from pathlib import Path
from typing import Any
# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -40,11 +34,6 @@ _MAX_HOSTNAME = 255
_MAX_APPNAME = 48
_MAX_MSGID = 32
-_LOG_FILE_ENV = "DECNET_LOG_FILE"
-_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
-_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
-_BACKUP_COUNT = 5
-
# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def _sd_escape(value: str) -> str:
@@ -90,156 +79,11 @@ def syslog_line(
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
-# โโโ File handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-_file_logger: logging.Logger | None = None
-
-
-def _get_file_logger() -> logging.Logger:
- global _file_logger
- if _file_logger is not None:
- return _file_logger
-
- log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
- try:
- log_path.parent.mkdir(parents=True, exist_ok=True)
- handler = logging.handlers.RotatingFileHandler(
- log_path,
- maxBytes=_MAX_BYTES,
- backupCount=_BACKUP_COUNT,
- encoding="utf-8",
- )
- except OSError:
- handler = logging.StreamHandler()
-
- handler.setFormatter(logging.Formatter("%(message)s"))
- _file_logger = logging.getLogger("decnet.syslog")
- _file_logger.setLevel(logging.DEBUG)
- _file_logger.propagate = False
- _file_logger.addHandler(handler)
- return _file_logger
-
-
-
-_json_logger: logging.Logger | None = None
-
-def _get_json_logger() -> logging.Logger:
- global _json_logger
- if _json_logger is not None:
- return _json_logger
-
- log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
- json_path = Path(log_path_str).with_suffix(".json")
- try:
- json_path.parent.mkdir(parents=True, exist_ok=True)
- handler = logging.handlers.RotatingFileHandler(
- json_path,
- maxBytes=_MAX_BYTES,
- backupCount=_BACKUP_COUNT,
- encoding="utf-8",
- )
- except OSError:
- handler = logging.StreamHandler()
-
- handler.setFormatter(logging.Formatter("%(message)s"))
- _json_logger = logging.getLogger("decnet.json")
- _json_logger.setLevel(logging.DEBUG)
- _json_logger.propagate = False
- _json_logger.addHandler(handler)
- return _json_logger
-
-
-
-
def write_syslog_file(line: str) -> None:
- """Append a syslog line to the rotating log file."""
- try:
- _get_file_logger().info(line)
-
- # Also parse and write JSON log
- import json
- import re
- from datetime import datetime
- from typing import Optional, Any
-
- _RFC5424_RE: re.Pattern = re.compile(
- r"^<\d+>1 "
- r"(\S+) " # 1: TIMESTAMP
- r"(\S+) " # 2: HOSTNAME (decky name)
- r"(\S+) " # 3: APP-NAME (service)
- r"- " # PROCID always NILVALUE
- r"(\S+) " # 4: MSGID (event_type)
- r"(.+)$", # 5: SD element + optional MSG
- )
- _SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
- _PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
- _IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
-
- _m: Optional[re.Match] = _RFC5424_RE.match(line)
- if _m:
- _ts_raw: str
- _decky: str
- _service: str
- _event_type: str
- _sd_rest: str
- _ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
-
- _fields: dict[str, str] = {}
- _msg: str = ""
-
- if _sd_rest.startswith("-"):
- _msg = _sd_rest[1:].lstrip()
- elif _sd_rest.startswith("["):
- _block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
- if _block:
- for _k, _v in _PARAM_RE.findall(_block.group(1)):
- _fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
-
- # extract msg after the block
- _msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
- if _msg_match:
- _msg = _msg_match.group(1).strip()
- else:
- _msg = _sd_rest
-
- _attacker_ip: str = "Unknown"
- for _fname in _IP_FIELDS:
- if _fname in _fields:
- _attacker_ip = _fields[_fname]
- break
-
- # Parse timestamp to normalize it
- _ts_formatted: str
- try:
- _ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
- except ValueError:
- _ts_formatted = _ts_raw
-
- _payload: dict[str, Any] = {
- "timestamp": _ts_formatted,
- "decky": _decky,
- "service": _service,
- "event_type": _event_type,
- "attacker_ip": _attacker_ip,
- "fields": json.dumps(_fields),
- "msg": _msg,
- "raw_line": line
- }
- _get_json_logger().info(json.dumps(_payload))
-
- except Exception:
- pass
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
-# โโโ TCP forwarding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
def forward_syslog(line: str, log_target: str) -> None:
- """Forward a syslog line over TCP to log_target (ip:port)."""
- if not log_target:
- return
- try:
- host, port = log_target.rsplit(":", 1)
- with socket.create_connection((host, int(port)), timeout=3) as s:
- s.sendall((line + "\n").encode())
- except Exception:
- pass
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/snmp/decnet_logging.py b/templates/snmp/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/snmp/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/snmp/server.py b/templates/snmp/server.py
index b07ecaf..2fd88d4 100644
--- a/templates/snmp/server.py
+++ b/templates/snmp/server.py
@@ -14,21 +14,58 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "switch")
SERVICE_NAME = "snmp"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
+SNMP_ARCHETYPE = os.environ.get("SNMP_ARCHETYPE", "default")
+
+
+def _get_archetype_values() -> dict:
+ archetypes = {
+ "water_plant": {
+ "sysDescr": f"Linux {NODE_NAME} 4.19.0-18-amd64 #1 SMP Debian 4.19.208-1 (2021-09-29) x86_64",
+ "sysContact": "ICS Admin ",
+ "sysName": NODE_NAME,
+ "sysLocation": "Water Treatment Facility โ Pump Room B",
+ },
+ "factory": {
+ "sysDescr": "VxWorks 6.9 (Rockwell Automation Allen-Bradley ControlLogix 5580)",
+ "sysContact": "Factory Floor Support ",
+ "sysName": NODE_NAME,
+ "sysLocation": "Factory Floor",
+ },
+ "substation": {
+ "sysDescr": "SEL Real-Time Automation Controller RTAC SEL-3555 firmware 1.9.7.0",
+ "sysContact": "Grid Ops ",
+ "sysName": NODE_NAME,
+ "sysLocation": "Main Substation",
+ },
+ "hospital": {
+ "sysDescr": f"Linux {NODE_NAME} 5.10.0-21-amd64 #1 SMP Debian 5.10.162-1 x86_64",
+ "sysContact": "Medical IT ",
+ "sysName": NODE_NAME,
+ "sysLocation": "ICU Ward 3",
+ },
+ "default": {
+ "sysDescr": f"Linux {NODE_NAME} 5.15.0-91-generic #101-Ubuntu SMP Tue Nov 14 13:30:08 UTC 2023 x86_64",
+ "sysContact": "admin@localhost",
+ "sysName": NODE_NAME,
+ "sysLocation": "Server Room",
+ }
+ }
+ return archetypes.get(SNMP_ARCHETYPE, archetypes["default"])
+
+_arch = _get_archetype_values()
# OID value map โ fake but plausible
_OID_VALUES = {
- "1.3.6.1.2.1.1.1.0": f"Linux {NODE_NAME} 5.15.0-76-generic #83-Ubuntu SMP x86_64",
+ "1.3.6.1.2.1.1.1.0": _arch["sysDescr"],
"1.3.6.1.2.1.1.2.0": "1.3.6.1.4.1.8072.3.2.10",
"1.3.6.1.2.1.1.3.0": "12345678", # sysUpTime
- "1.3.6.1.2.1.1.4.0": "admin@localhost",
- "1.3.6.1.2.1.1.5.0": NODE_NAME,
- "1.3.6.1.2.1.1.6.0": "Server Room",
+ "1.3.6.1.2.1.1.4.0": _arch["sysContact"],
+ "1.3.6.1.2.1.1.5.0": _arch["sysName"],
+ "1.3.6.1.2.1.1.6.0": _arch["sysLocation"],
"1.3.6.1.2.1.1.7.0": "72",
}
-
-
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
print(line, flush=True)
@@ -37,10 +74,14 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
def _read_ber_length(data: bytes, pos: int):
+ if pos >= len(data):
+ raise ValueError("Unexpected end of data reading ASN.1 length")
b = data[pos]
if b < 0x80:
return b, pos + 1
n = b & 0x7f
+ if pos + 1 + n > len(data):
+ raise ValueError("BER length bytes truncated")
length = int.from_bytes(data[pos + 1:pos + 1 + n], "big")
return length, pos + 1 + n
@@ -91,42 +132,67 @@ def _ber_tlv(tag: int, value: bytes) -> bytes:
def _parse_snmp(data: bytes):
"""Return (version, community, request_id, oids) or raise."""
pos = 0
- assert data[pos] == 0x30 # nosec B101
+ if len(data) == 0 or data[pos] != 0x30:
+ raise ValueError("Not a valid ASN.1 sequence")
pos += 1
_, pos = _read_ber_length(data, pos)
# version
- assert data[pos] == 0x02 # nosec B101
+ if pos >= len(data) or data[pos] != 0x02:
+ raise ValueError("Expected SNMP version INTEGER")
pos += 1
v_len, pos = _read_ber_length(data, pos)
version = int.from_bytes(data[pos:pos + v_len], "big")
pos += v_len
# community
- assert data[pos] == 0x04 # nosec B101
+ if pos >= len(data) or data[pos] != 0x04:
+ raise ValueError("Expected SNMP community OCTET STREAM")
pos += 1
c_len, pos = _read_ber_length(data, pos)
community = data[pos:pos + c_len].decode(errors="replace")
pos += c_len
# PDU type (0xa0 = GetRequest, 0xa1 = GetNextRequest)
+ if pos >= len(data):
+ raise ValueError("Missing PDU type")
+
+ pdu_type = data[pos]
+ if pdu_type not in (0xa0, 0xa1):
+ raise ValueError(f"Invalid PDU type {pdu_type}")
+
pos += 1
_, pos = _read_ber_length(data, pos)
# request-id
- assert data[pos] == 0x02 # nosec B101
+ if pos >= len(data) or data[pos] != 0x02:
+ raise ValueError("Expected Request ID INTEGER")
pos += 1
r_len, pos = _read_ber_length(data, pos)
request_id = int.from_bytes(data[pos:pos + r_len], "big")
pos += r_len
- pos += 4 # skip error-status and error-index
+ # skip error-status
+ if pos >= len(data) or data[pos] != 0x02:
+ raise ValueError("Expected error-status INTEGER")
+ pos += 1
+ e_len, pos = _read_ber_length(data, pos)
+ pos += e_len
+ # skip error-index
+ if pos >= len(data) or data[pos] != 0x02:
+ raise ValueError("Expected error-index INTEGER")
+ pos += 1
+ i_len, pos = _read_ber_length(data, pos)
+ pos += i_len
# varbind list
- assert data[pos] == 0x30 # nosec B101
+ if pos >= len(data) or data[pos] != 0x30:
+ raise ValueError("Expected varbind list SEQUENCE")
pos += 1
vbl_len, pos = _read_ber_length(data, pos)
end = pos + vbl_len
oids = []
while pos < end:
- assert data[pos] == 0x30 # nosec B101
+ if data[pos] != 0x30:
+ raise ValueError("Expected varbind SEQUENCE")
pos += 1
vb_len, pos = _read_ber_length(data, pos)
- assert data[pos] == 0x06 # nosec B101
+ if data[pos] != 0x06:
+ raise ValueError("Expected Object Identifier")
pos += 1
oid_len, pos = _read_ber_length(data, pos)
oid = _decode_oid(data[pos:pos + oid_len])
@@ -169,14 +235,14 @@ class SNMPProtocol(asyncio.DatagramProtocol):
response = _build_response(version, community, request_id, oids)
self._transport.sendto(response, addr)
except Exception as e:
- _log("parse_error", src=addr[0], error=str(e), data=data[:64].hex())
+ _log("parse_error", severity=4, src=addr[0], error=str(e), data=data[:64].hex())
def error_received(self, exc):
pass
async def main():
- _log("startup", msg=f"SNMP server starting as {NODE_NAME}")
+ _log("startup", msg=f"SNMP server starting as {NODE_NAME} with archetype {SNMP_ARCHETYPE}")
loop = asyncio.get_running_loop()
transport, _ = await loop.create_datagram_endpoint(
SNMPProtocol, local_addr=("0.0.0.0", 161) # nosec B104
diff --git a/templates/tftp/decnet_logging.py b/templates/tftp/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/tftp/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/templates/vnc/decnet_logging.py b/templates/vnc/decnet_logging.py
new file mode 100644
index 0000000..5a09505
--- /dev/null
+++ b/templates/vnc/decnet_logging.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Shared RFC 5424 syslog helper for DECNET service templates.
+
+Services call syslog_line() to format an RFC 5424 message, then
+write_syslog_file() to emit it to stdout โ Docker captures it, and the
+host-side collector streams it into the log file.
+
+RFC 5424 structure:
+ 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
+
+Facility: local0 (16), PEN for SD element ID: decnet@55555
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+# โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_FACILITY_LOCAL0 = 16
+_SD_ID = "decnet@55555"
+_NILVALUE = "-"
+
+SEVERITY_EMERG = 0
+SEVERITY_ALERT = 1
+SEVERITY_CRIT = 2
+SEVERITY_ERROR = 3
+SEVERITY_WARNING = 4
+SEVERITY_NOTICE = 5
+SEVERITY_INFO = 6
+SEVERITY_DEBUG = 7
+
+_MAX_HOSTNAME = 255
+_MAX_APPNAME = 48
+_MAX_MSGID = 32
+
+# โโโ Formatter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _sd_escape(value: str) -> str:
+ """Escape SD-PARAM-VALUE per RFC 5424 ยง6.3.3."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
+
+
+def _sd_element(fields: dict[str, Any]) -> str:
+ if not fields:
+ return _NILVALUE
+ params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
+ return f"[{_SD_ID} {params}]"
+
+
+def syslog_line(
+ service: str,
+ hostname: str,
+ event_type: str,
+ severity: int = SEVERITY_INFO,
+ timestamp: datetime | None = None,
+ msg: str | None = None,
+ **fields: Any,
+) -> str:
+ """
+ Return a single RFC 5424-compliant syslog line (no trailing newline).
+
+ Args:
+ service: APP-NAME (e.g. "http", "mysql")
+ hostname: HOSTNAME (decky node name)
+ event_type: MSGID (e.g. "request", "login_attempt")
+ severity: Syslog severity integer (default: INFO=6)
+ timestamp: UTC datetime; defaults to now
+ msg: Optional free-text MSG
+ **fields: Encoded as structured data params
+ """
+ pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
+ ts = (timestamp or datetime.now(timezone.utc)).isoformat()
+ host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
+ appname = (service or _NILVALUE)[:_MAX_APPNAME]
+ msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
+ sd = _sd_element(fields)
+ message = f" {msg}" if msg else ""
+ return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
+
+
+def write_syslog_file(line: str) -> None:
+ """Emit a syslog line to stdout for Docker log capture."""
+ print(line, flush=True)
+
+
+def forward_syslog(line: str, log_target: str) -> None:
+ """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
+ pass
diff --git a/tests/api/auth/test_login.py b/tests/api/auth/test_login.py
index 1e04b78..4087a40 100644
--- a/tests/api/auth/test_login.py
+++ b/tests/api/auth/test_login.py
@@ -1,7 +1,5 @@
import json
import pytest
-from fastapi.testclient import TestClient
-from decnet.web.api import app
from hypothesis import given, strategies as st, settings
import httpx
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
diff --git a/tests/api/conftest.py b/tests/api/conftest.py
index 97b0612..d0116ba 100644
--- a/tests/api/conftest.py
+++ b/tests/api/conftest.py
@@ -10,6 +10,7 @@ from hypothesis import HealthCheck
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
+import os as _os
# Must be set before any decnet import touches decnet.env
os.environ["DECNET_JWT_SECRET"] = "test-secret-key-at-least-32-chars-long!!"
@@ -123,7 +124,6 @@ def mock_state_file(patch_state_file: Path):
# Share fuzz settings across API tests
# FUZZ_EXAMPLES: keep low for dev speed; bump via HYPOTHESIS_MAX_EXAMPLES env var in CI
-import os as _os
_FUZZ_EXAMPLES = int(_os.environ.get("HYPOTHESIS_MAX_EXAMPLES", "10"))
_FUZZ_SETTINGS: dict[str, Any] = {
"max_examples": _FUZZ_EXAMPLES,
diff --git a/tests/api/fleet/test_get_deckies.py b/tests/api/fleet/test_get_deckies.py
index 93cdcb4..8af6d09 100644
--- a/tests/api/fleet/test_get_deckies.py
+++ b/tests/api/fleet/test_get_deckies.py
@@ -1,7 +1,6 @@
import pytest
import httpx
from hypothesis import given, settings, strategies as st
-from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS
@pytest.mark.anyio
diff --git a/tests/api/logs/test_get_logs.py b/tests/api/logs/test_get_logs.py
index edca28c..05cc677 100644
--- a/tests/api/logs/test_get_logs.py
+++ b/tests/api/logs/test_get_logs.py
@@ -1,7 +1,6 @@
import pytest
import httpx
from typing import Any, Optional
-from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS
from hypothesis import given, strategies as st, settings
diff --git a/tests/api/logs/test_histogram.py b/tests/api/logs/test_histogram.py
index 0c03b52..6bae2d6 100644
--- a/tests/api/logs/test_histogram.py
+++ b/tests/api/logs/test_histogram.py
@@ -5,7 +5,6 @@ freeze_time controls Python's datetime.now() so we can compute
explicit bucket timestamps deterministically, then pass them to
add_log and verify SQLite groups them into the right buckets.
"""
-import json
import pytest
from datetime import datetime, timedelta
from freezegun import freeze_time
diff --git a/tests/api/stats/test_get_stats.py b/tests/api/stats/test_get_stats.py
index 0438e5c..6cf8109 100644
--- a/tests/api/stats/test_get_stats.py
+++ b/tests/api/stats/test_get_stats.py
@@ -1,7 +1,5 @@
import pytest
import httpx
-from typing import Any
-from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS
from hypothesis import given, strategies as st, settings
diff --git a/tests/service_testing/test_imap.py b/tests/service_testing/test_imap.py
new file mode 100644
index 0000000..9b655c7
--- /dev/null
+++ b/tests/service_testing/test_imap.py
@@ -0,0 +1,89 @@
+"""
+Tests for templates/imap/server.py
+
+Exercises IMAP state machine, auth, and negative tests.
+"""
+
+import importlib.util
+import sys
+from types import ModuleType
+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
+
+def _load_imap():
+ 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"):
+ del sys.modules[key]
+
+ sys.modules["decnet_logging"] = _make_fake_decnet_logging()
+
+ spec = importlib.util.spec_from_file_location("imap_server", "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 _make_protocol(mod):
+ proto = mod.IMAPProtocol()
+ transport = MagicMock()
+ written: list[bytes] = []
+ transport.write.side_effect = written.append
+ proto.connection_made(transport)
+ written.clear()
+ return proto, transport, written
+
+def _send(proto, data: str) -> None:
+ proto.data_received(data.encode() + b"\r\n")
+
+@pytest.fixture
+def imap_mod():
+ return _load_imap()
+
+def test_imap_login_success(imap_mod):
+ proto, transport, written = _make_protocol(imap_mod)
+ _send(proto, 'A1 LOGIN admin admin123')
+ assert b"A1 OK" in b"".join(written)
+ assert proto._state == "AUTHENTICATED"
+
+def test_imap_login_fail(imap_mod):
+ proto, transport, written = _make_protocol(imap_mod)
+ _send(proto, 'A1 LOGIN admin wrongpass')
+ assert b"A1 NO" in b"".join(written)
+ assert proto._state == "NOT_AUTHENTICATED"
+
+def test_imap_select_before_auth(imap_mod):
+ proto, transport, written = _make_protocol(imap_mod)
+ _send(proto, 'A2 SELECT INBOX')
+ assert b"A2 BAD" in b"".join(written)
+
+def test_imap_fetch_after_select(imap_mod):
+ proto, transport, written = _make_protocol(imap_mod)
+ _send(proto, 'A1 LOGIN admin admin123')
+ written.clear()
+ _send(proto, 'A2 SELECT INBOX')
+ written.clear()
+ _send(proto, 'A3 FETCH 1 RFC822')
+ combined = b"".join(written)
+ assert b"A3 OK" in combined
+ assert b"AKIAIOSFODNN7EXAMPLE" in combined
+
+def test_imap_invalid_command(imap_mod):
+ proto, transport, written = _make_protocol(imap_mod)
+ _send(proto, 'A1 INVALID')
+ assert b"A1 BAD" in b"".join(written)
diff --git a/tests/service_testing/test_mqtt.py b/tests/service_testing/test_mqtt.py
new file mode 100644
index 0000000..0c856c1
--- /dev/null
+++ b/tests/service_testing/test_mqtt.py
@@ -0,0 +1,195 @@
+"""
+Tests for templates/mqtt/server.py
+
+Exercises behavior with MQTT_ACCEPT_ALL=1 and customizable topics.
+Uses asyncio transport/protocol directly.
+"""
+
+import importlib.util
+import json
+import sys
+from types import ModuleType
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+# โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+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
+
+
+def _load_mqtt(accept_all: bool = True, custom_topics: str = "", persona: str = "water_plant"):
+ env = {
+ "MQTT_ACCEPT_ALL": "1" if accept_all else "0",
+ "NODE_NAME": "testhost",
+ "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]
+
+ sys.modules["decnet_logging"] = _make_fake_decnet_logging()
+
+ spec = importlib.util.spec_from_file_location("mqtt_server", "templates/mqtt/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 _make_protocol(mod):
+ proto = mod.MQTTProtocol()
+ transport = MagicMock()
+ written: list[bytes] = []
+ transport.write.side_effect = written.append
+ proto.connection_made(transport)
+ written.clear()
+ return proto, transport, written
+
+
+def _send(proto, data: bytes) -> None:
+ proto.data_received(data)
+
+
+# โโ Fixtures โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+@pytest.fixture
+def mqtt_mod():
+ return _load_mqtt()
+
+@pytest.fixture
+def mqtt_no_auth_mod():
+ return _load_mqtt(accept_all=False)
+
+
+# โโ Packet Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _connect_packet() -> bytes:
+ # 0x10, len 14, 00 04 MQTT 04 02 00 3c 00 02 id
+ return b"\x10\x0e\x00\x04MQTT\x04\x02\x00\x3c\x00\x02id"
+
+def _subscribe_packet(topic: str, pid: int = 1) -> bytes:
+ topic_bytes = topic.encode()
+ payload = pid.to_bytes(2, "big") + len(topic_bytes).to_bytes(2, "big") + topic_bytes + b"\x01" # qos 1
+ return bytes([0x82, len(payload)]) + payload
+
+def _publish_packet(topic: str, payload: str, qos: int = 1, pid: int = 1) -> bytes:
+ topic_bytes = topic.encode()
+ payload_bytes = payload.encode()
+ flags = qos << 1
+ byte0 = 0x30 | flags
+ if qos > 0:
+ packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + pid.to_bytes(2, "big") + payload_bytes
+ else:
+ packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + payload_bytes
+
+ return bytes([byte0, len(packet_payload)]) + packet_payload
+
+def _pingreq_packet() -> bytes:
+ return b"\xc0\x00"
+
+def _disconnect_packet() -> bytes:
+ return b"\xe0\x00"
+
+
+# โโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def test_connect_accept(mqtt_mod):
+ proto, transport, written = _make_protocol(mqtt_mod)
+ _send(proto, _connect_packet())
+ assert len(written) == 1
+ assert written[0] == b"\x20\x02\x00\x00"
+ assert proto._auth is True
+
+def test_connect_reject(mqtt_no_auth_mod):
+ proto, transport, written = _make_protocol(mqtt_no_auth_mod)
+ _send(proto, _connect_packet())
+ assert len(written) == 1
+ assert written[0] == b"\x20\x02\x00\x05"
+ assert transport.close.called
+
+def test_pingreq(mqtt_mod):
+ proto, _, written = _make_protocol(mqtt_mod)
+ _send(proto, _pingreq_packet())
+ assert written[0] == b"\xd0\x00"
+
+def test_subscribe_wildcard_retained(mqtt_mod):
+ proto, _, written = _make_protocol(mqtt_mod)
+ _send(proto, _connect_packet())
+ written.clear()
+
+ _send(proto, _subscribe_packet("plant/#"))
+
+ 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
+
+def test_publish_qos1_returns_puback(mqtt_mod):
+ proto, _, written = _make_protocol(mqtt_mod)
+ _send(proto, _connect_packet())
+ written.clear()
+
+ _send(proto, _publish_packet("target/topic", "malicious_payload", qos=1, pid=42))
+ assert len(written) == 1
+ # PUBACK (0x40), len=2, pid=42
+ assert written[0] == b"\x40\x02\x00\x2a"
+
+def test_custom_topics():
+ custom = {"custom/1": "val1", "custom/2": "val2"}
+ mod = _load_mqtt(custom_topics=json.dumps(custom))
+ proto, _, written = _make_protocol(mod)
+ _send(proto, _connect_packet())
+ written.clear()
+
+ _send(proto, _subscribe_packet("custom/1"))
+ assert len(written) > 1
+ combined = b"".join(written[1:])
+ assert b"custom/1" in combined
+ assert b"val1" in combined
+
+# โโ Negative Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def test_subscribe_before_auth_closes(mqtt_mod):
+ proto, transport, written = _make_protocol(mqtt_mod)
+ _send(proto, _subscribe_packet("plant/#"))
+ assert transport.close.called
+
+def test_publish_before_auth_closes(mqtt_mod):
+ proto, transport, written = _make_protocol(mqtt_mod)
+ _send(proto, _publish_packet("test", "test", qos=0))
+ assert transport.close.called
+
+def test_malformed_connect_len(mqtt_mod):
+ proto, transport, _ = _make_protocol(mqtt_mod)
+ _send(proto, b"\x10\x05\x00\x04MQT")
+ # buffer handles it
+ _send(proto, b"\x10\x02\x00\x04")
+ # No crash
+
+def test_bad_packet_type_closer(mqtt_mod):
+ proto, transport, _ = _make_protocol(mqtt_mod)
+ _send(proto, b"\xf0\x00") # Reserved type 15
+ assert transport.close.called
+
+def test_invalid_json_config():
+ mod = _load_mqtt(custom_topics="{invalid: json}")
+ proto, _, _ = _make_protocol(mod)
+ assert len(proto._topics) > 0 # fell back to persona
+
+def test_disconnect_packet(mqtt_mod):
+ proto, transport, _ = _make_protocol(mqtt_mod)
+ _send(proto, _connect_packet())
+ _send(proto, _disconnect_packet())
+ assert transport.close.called
diff --git a/tests/service_testing/test_pop3.py b/tests/service_testing/test_pop3.py
new file mode 100644
index 0000000..337f467
--- /dev/null
+++ b/tests/service_testing/test_pop3.py
@@ -0,0 +1,98 @@
+"""
+Tests for templates/pop3/server.py
+
+Exercises POP3 state machine, auth, and negative tests.
+"""
+
+import importlib.util
+import sys
+from types import ModuleType
+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
+
+def _load_pop3():
+ 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 ("pop3_server", "decnet_logging"):
+ del sys.modules[key]
+
+ sys.modules["decnet_logging"] = _make_fake_decnet_logging()
+
+ spec = importlib.util.spec_from_file_location("pop3_server", "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 _make_protocol(mod):
+ proto = mod.POP3Protocol()
+ transport = MagicMock()
+ written: list[bytes] = []
+ transport.write.side_effect = written.append
+ proto.connection_made(transport)
+ written.clear()
+ return proto, transport, written
+
+def _send(proto, data: str) -> None:
+ proto.data_received(data.encode() + b"\r\n")
+
+@pytest.fixture
+def pop3_mod():
+ return _load_pop3()
+
+def test_pop3_login_success(pop3_mod):
+ proto, transport, written = _make_protocol(pop3_mod)
+ _send(proto, 'USER admin')
+ assert b"+OK" in b"".join(written)
+ written.clear()
+ _send(proto, 'PASS admin123')
+ assert b"+OK Logged in" in b"".join(written)
+ assert proto._state == "TRANSACTION"
+
+def test_pop3_login_fail(pop3_mod):
+ proto, transport, written = _make_protocol(pop3_mod)
+ _send(proto, 'USER admin')
+ written.clear()
+ _send(proto, 'PASS wrongpass')
+ assert b"-ERR" in b"".join(written)
+ assert proto._state == "AUTHORIZATION"
+
+def test_pop3_pass_before_user(pop3_mod):
+ proto, transport, written = _make_protocol(pop3_mod)
+ _send(proto, 'PASS admin123')
+ assert b"-ERR" in b"".join(written)
+
+def test_pop3_stat_before_auth(pop3_mod):
+ proto, transport, written = _make_protocol(pop3_mod)
+ _send(proto, 'STAT')
+ assert b"-ERR" in b"".join(written)
+
+def test_pop3_retr_after_auth(pop3_mod):
+ proto, transport, written = _make_protocol(pop3_mod)
+ _send(proto, 'USER admin')
+ _send(proto, 'PASS admin123')
+ written.clear()
+ _send(proto, 'RETR 1')
+ combined = b"".join(written)
+ assert b"+OK" in combined
+ assert b"AKIAIOSFODNN7EXAMPLE" in combined
+
+def test_pop3_invalid_command(pop3_mod):
+ proto, transport, written = _make_protocol(pop3_mod)
+ _send(proto, 'INVALID')
+ assert b"-ERR" in b"".join(written)
diff --git a/tests/test_smtp.py b/tests/service_testing/test_smtp.py
similarity index 100%
rename from tests/test_smtp.py
rename to tests/service_testing/test_smtp.py
diff --git a/tests/service_testing/test_snmp.py b/tests/service_testing/test_snmp.py
new file mode 100644
index 0000000..3bbe768
--- /dev/null
+++ b/tests/service_testing/test_snmp.py
@@ -0,0 +1,148 @@
+"""
+Tests for templates/snmp/server.py
+
+Exercises behavior with SNMP_ARCHETYPE modifications.
+Uses asyncio DatagramProtocol directly.
+"""
+
+import importlib.util
+import sys
+from types import ModuleType
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+# โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _make_fake_decnet_logging() -> ModuleType:
+ mod = ModuleType("decnet_logging")
+ def syslog_line(*args, **kwargs):
+ print("LOG:", args, kwargs)
+ return ""
+ mod.syslog_line = syslog_line
+ mod.write_syslog_file = MagicMock()
+ mod.forward_syslog = MagicMock()
+ mod.SEVERITY_WARNING = 4
+ mod.SEVERITY_INFO = 6
+ return mod
+
+
+def _load_snmp(archetype: str = "default"):
+ env = {
+ "NODE_NAME": "testhost",
+ "SNMP_ARCHETYPE": archetype,
+ }
+ for key in list(sys.modules):
+ if key in ("snmp_server", "decnet_logging"):
+ del sys.modules[key]
+
+ sys.modules["decnet_logging"] = _make_fake_decnet_logging()
+
+ spec = importlib.util.spec_from_file_location("snmp_server", "templates/snmp/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 _make_protocol(mod):
+ proto = mod.SNMPProtocol()
+ transport = MagicMock()
+ sent: list[tuple] = []
+
+ def sendto(data, addr):
+ sent.append((data, addr))
+
+ transport.sendto = sendto
+ proto.connection_made(transport)
+ sent.clear()
+ return proto, transport, sent
+
+
+def _send(proto, data: bytes, addr=("127.0.0.1", 12345)) -> None:
+ proto.datagram_received(data, addr)
+
+# โโ Packet Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _ber_tlv(tag: int, value: bytes) -> bytes:
+ length = len(value)
+ if length < 0x80:
+ return bytes([tag, length]) + value
+ elif length < 0x100:
+ return bytes([tag, 0x81, length]) + value
+ else:
+ return bytes([tag, 0x82]) + int.to_bytes(length, 2, "big") + value
+
+def _get_request_packet(community: str, request_id: int, oid_enc: bytes) -> bytes:
+ # Build a simple GetRequest for a single OID
+ varbind = _ber_tlv(0x30, _ber_tlv(0x06, oid_enc) + _ber_tlv(0x05, b"")) # 0x05 is NULL
+ varbind_list = _ber_tlv(0x30, varbind)
+ req_id_tlv = _ber_tlv(0x02, request_id.to_bytes(4, "big"))
+ err_stat = _ber_tlv(0x02, b"\x00")
+ err_idx = _ber_tlv(0x02, b"\x00")
+ pdu = _ber_tlv(0xa0, req_id_tlv + err_stat + err_idx + varbind_list)
+ ver = _ber_tlv(0x02, b"\x01") # v2c
+ comm = _ber_tlv(0x04, community.encode())
+ return _ber_tlv(0x30, ver + comm + pdu)
+
+# 1.3.6.1.2.1.1.1.0 = b"\x2b\x06\x01\x02\x01\x01\x01\x00"
+SYS_DESCR_OID_ENC = b"\x2b\x06\x01\x02\x01\x01\x01\x00"
+
+# โโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+@pytest.fixture
+def snmp_default():
+ return _load_snmp()
+
+@pytest.fixture
+def snmp_water_plant():
+ return _load_snmp("water_plant")
+
+
+def test_sysdescr_default(snmp_default):
+ proto, transport, sent = _make_protocol(snmp_default)
+ packet = _get_request_packet("public", 1, SYS_DESCR_OID_ENC)
+ _send(proto, packet)
+
+ assert len(sent) == 1
+ resp, addr = sent[0]
+ assert addr == ("127.0.0.1", 12345)
+
+ # default sysDescr has "Ubuntu SMP" in it
+ assert b"Ubuntu SMP" in resp
+
+def test_sysdescr_water_plant(snmp_water_plant):
+ proto, transport, sent = _make_protocol(snmp_water_plant)
+ packet = _get_request_packet("public", 2, SYS_DESCR_OID_ENC)
+ _send(proto, packet)
+
+ assert len(sent) == 1
+ resp, _ = sent[0]
+
+ assert b"Debian" in resp
+
+# โโ Negative Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def test_invalid_asn1_sequence(snmp_default):
+ proto, transport, sent = _make_protocol(snmp_default)
+ # 0x31 instead of 0x30
+ _send(proto, b"\x31\x02\x00\x00")
+ assert len(sent) == 0 # Caught and logged
+
+def test_truncated_packet(snmp_default):
+ proto, transport, sent = _make_protocol(snmp_default)
+ packet = _get_request_packet("public", 3, SYS_DESCR_OID_ENC)
+ _send(proto, packet[:10]) # chop it
+ assert len(sent) == 0
+
+def test_invalid_pdu_type(snmp_default):
+ proto, transport, sent = _make_protocol(snmp_default)
+ packet = _get_request_packet("public", 4, SYS_DESCR_OID_ENC).replace(b"\xa0", b"\xa3", 1)
+ _send(proto, packet)
+ assert len(sent) == 0
+
+def test_bad_oid_encoding(snmp_default):
+ proto, transport, sent = _make_protocol(snmp_default)
+ _send(proto, b"\x30\x84\xff\xff\xff\xff")
+ assert len(sent) == 0
diff --git a/tests/test_composer.py b/tests/test_composer.py
index 308e93f..68be330 100644
--- a/tests/test_composer.py
+++ b/tests/test_composer.py
@@ -23,10 +23,10 @@ BUILD_SERVICES = [
"ssh", "http", "rdp", "smb", "ftp", "smtp", "elasticsearch",
"pop3", "imap", "mysql", "mssql", "redis", "mongodb", "postgres",
"ldap", "vnc", "docker_api", "k8s", "sip",
- "mqtt", "llmnr", "snmp", "tftp",
+ "mqtt", "llmnr", "snmp", "tftp", "conpot"
]
-UPSTREAM_SERVICES = ["telnet", "conpot"]
+UPSTREAM_SERVICES = ["telnet"]
def _make_config(services, distro="debian", base_image=None, build_base=None):
diff --git a/tests/test_config.py b/tests/test_config.py
index 754db8d..f909a7b 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -2,10 +2,7 @@
Tests for decnet.config โ Pydantic models, save/load/clear state.
Covers the uncovered lines: validators, save_state, load_state, clear_state.
"""
-import json
import pytest
-from pathlib import Path
-from unittest.mock import patch
import decnet.config as config_module
from decnet.config import (
diff --git a/tests/test_custom_service.py b/tests/test_custom_service.py
index 7f9a223..41d81ff 100644
--- a/tests/test_custom_service.py
+++ b/tests/test_custom_service.py
@@ -1,7 +1,6 @@
"""
Tests for decnet.custom_service โ BYOS (bring-your-own-service) support.
"""
-import pytest
from decnet.custom_service import CustomService
diff --git a/tests/test_logging_forwarder.py b/tests/test_logging_forwarder.py
index d345936..1a795f5 100644
--- a/tests/test_logging_forwarder.py
+++ b/tests/test_logging_forwarder.py
@@ -1,7 +1,6 @@
"""
Tests for decnet.logging.forwarder โ parse_log_target, probe_log_target.
"""
-import socket
from unittest.mock import MagicMock, patch
import pytest
diff --git a/tests/test_mutator.py b/tests/test_mutator.py
index 51afaf3..f81d7ac 100644
--- a/tests/test_mutator.py
+++ b/tests/test_mutator.py
@@ -5,7 +5,7 @@ All subprocess and state I/O is mocked; no Docker or filesystem access.
import subprocess
import time
from pathlib import Path
-from unittest.mock import MagicMock, call, patch
+from unittest.mock import MagicMock, patch
import pytest
diff --git a/tests/test_services.py b/tests/test_services.py
index 4256da1..14a9a95 100644
--- a/tests/test_services.py
+++ b/tests/test_services.py
@@ -33,7 +33,6 @@ def _is_build_service(name: str) -> bool:
UPSTREAM_SERVICES = {
"telnet": ("cowrie/cowrie", [23]),
- "conpot": ("honeynet/conpot", [502, 161, 80]),
}
# ---------------------------------------------------------------------------
@@ -64,6 +63,7 @@ BUILD_SERVICES = {
"llmnr": ([5355, 5353], "llmnr"),
"snmp": ([161], "snmp"),
"tftp": ([69], "tftp"),
+ "conpot": ([502, 161, 80], "conpot"),
}
ALL_SERVICE_NAMES = list(UPSTREAM_SERVICES) + list(BUILD_SERVICES)