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)