Implement ICS/SCADA and IMAP Bait features

This commit is contained in:
2026-04-10 01:50:08 -04:00
parent 63fb477e1f
commit 08242a4d84
112 changed files with 3239 additions and 764 deletions

View File

@@ -9,7 +9,15 @@
"Bash(pip show:*)", "Bash(pip show:*)",
"Bash(python:*)", "Bash(python:*)",
"Bash(DECNET_JWT_SECRET=\"test-secret-xyz-1234!\" DECNET_ADMIN_PASSWORD=\"test-pass-xyz-1234!\" 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/)"
] ]
} }
} }

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/db/repository.py
# hypothesis_version: 6.151.11
[]

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/database.py
# hypothesis_version: 6.151.11
[]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/db/models.py
# hypothesis_version: 6.151.11
['bounty', 'logs', 'users', 'viewer']

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/stats/api_get_stats.py
# hypothesis_version: 6.151.12
['/stats', 'Observability']

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/db/sqlite/database.py
# hypothesis_version: 6.151.12
['uri']

View File

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

10
DEBT.md
View File

@@ -101,6 +101,11 @@ All route decorators now declare `responses={401: {"description": "Not authentic
~~**File:** `decnet/web/sqlite_repository.py` (~400 lines)~~ ~~**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`. 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 ## 🟢 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-023 | 🟢 Low | Infra | deferred (needs docker pull) |
| ~~DEBT-024~~ | ✅ | Infra | resolved | | ~~DEBT-024~~ | ✅ | Infra | resolved |
| ~~DEBT-025~~ | ✅ | Build | resolved | | ~~DEBT-025~~ | ✅ | Build | resolved |
| DEBT-026 | 🟡 Medium | Features | deferred (out of scope) |
**Remaining open:** DEBT-011 (Alembic migrations), DEBT-023 (image digest pinning) **Remaining open:** DEBT-011 (Alembic migrations), DEBT-023 (image digest pinning), DEBT-026 (modular mailboxes)
**Estimated remaining effort:** ~7 hours **Estimated remaining effort:** ~10 hours

View File

@@ -474,7 +474,7 @@ Key/value pairs are passed directly to the service plugin as persona config. Com
| `mongodb` | `mongo_version` | | `mongodb` | `mongo_version` |
| `elasticsearch` | `es_version`, `cluster_name` | | `elasticsearch` | `es_version`, `cluster_name` |
| `ldap` | `base_dn`, `domain` | | `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` | | `mqtt` | `mqtt_version` |
| `sip` | `sip_server`, `sip_domain` | | `sip` | `sip_server`, `sip_domain` |
| `k8s` | `k8s_version` | | `k8s` | `k8s_version` |

BIN
decnet.db-shm Normal file

Binary file not shown.

BIN
decnet.db-wal Normal file

Binary file not shown.

View File

@@ -1,26 +1,35 @@
import os
from pathlib import Path
from decnet.services.base import BaseService from decnet.services.base import BaseService
class ConpotService(BaseService): class ConpotService(BaseService):
"""ICS/SCADA honeypot covering Modbus (502), SNMP (161 UDP), and HTTP (80). """ICS/SCADA honeypot covering Modbus (502), SNMP (161 UDP), and HTTP (80).
Uses the official honeynet/conpot image which ships a default ICS profile Uses a custom build context wrapping the official honeynet/conpot image
that emulates a Siemens S7-200 PLC. to fix Modbus binding to port 502.
""" """
name = "conpot" name = "conpot"
ports = [502, 161, 80] 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: 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 { return {
"image": "honeynet/conpot", "build": {
"context": str(self.dockerfile_context())
},
"container_name": f"{decky_name}-conpot", "container_name": f"{decky_name}-conpot",
"restart": "unless-stopped", "restart": "unless-stopped",
"environment": { "environment": env,
"CONPOT_TEMPLATE": "default",
},
} }
def dockerfile_context(self): def dockerfile_context(self):
return None return Path(__file__).parent.parent.parent / "templates" / "conpot"

View File

@@ -1,7 +1,6 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlmodel import SQLModel from sqlmodel import SQLModel
from pathlib import Path
# We need both sync and async engines for SQLite # We need both sync and async engines for SQLite
# Sync for initialization (DDL) and async for standard queries # Sync for initialization (DDL) and async for standard queries

View File

@@ -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 `<h1>403 Forbidden</h1>` 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 `<h1>` 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":"<random>"}` 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: <IAC WILL ECHO>
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

View File

@@ -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/<port>5020<\/port>/<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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,9 @@
""" """
Shared RFC 5424 syslog helper for DECNET service templates. Shared RFC 5424 syslog helper for DECNET service templates.
Provides two functions consumed by every service's server.py: Services call syslog_line() to format an RFC 5424 message, then
- syslog_line(service, hostname, event_type, severity, **fields) -> str write_syslog_file() to emit it to stdout — Docker captures it, and the
- write_syslog_file(line: str) -> None host-side collector streams it into the log file.
- forward_syslog(line: str, log_target: str) -> None
RFC 5424 structure: RFC 5424 structure:
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG <PRI>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 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 datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
# ─── Constants ──────────────────────────────────────────────────────────────── # ─── Constants ────────────────────────────────────────────────────────────────
@@ -40,11 +34,6 @@ _MAX_HOSTNAME = 255
_MAX_APPNAME = 48 _MAX_APPNAME = 48
_MAX_MSGID = 32 _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 ──────────────────────────────────────────────────────────────── # ─── Formatter ────────────────────────────────────────────────────────────────
def _sd_escape(value: str) -> str: def _sd_escape(value: str) -> str:
@@ -90,156 +79,11 @@ def syslog_line(
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" 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: def write_syslog_file(line: str) -> None:
"""Append a syslog line to the rotating log file.""" """Emit a syslog line to stdout for Docker log capture."""
try: print(line, flush=True)
_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
# ─── TCP forwarding ───────────────────────────────────────────────────────────
def forward_syslog(line: str, log_target: str) -> None: def forward_syslog(line: str, log_target: str) -> None:
"""Forward a syslog line over TCP to log_target (ip:port).""" """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
if not log_target: pass
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

View File

@@ -2,10 +2,9 @@
""" """
Shared RFC 5424 syslog helper for DECNET service templates. Shared RFC 5424 syslog helper for DECNET service templates.
Provides two functions consumed by every service's server.py: Services call syslog_line() to format an RFC 5424 message, then
- syslog_line(service, hostname, event_type, severity, **fields) -> str write_syslog_file() to emit it to stdout — Docker captures it, and the
- write_syslog_file(line: str) -> None host-side collector streams it into the log file.
- forward_syslog(line: str, log_target: str) -> None
RFC 5424 structure: RFC 5424 structure:
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG <PRI>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 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 datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
# ─── Constants ──────────────────────────────────────────────────────────────── # ─── Constants ────────────────────────────────────────────────────────────────
@@ -40,11 +34,6 @@ _MAX_HOSTNAME = 255
_MAX_APPNAME = 48 _MAX_APPNAME = 48
_MAX_MSGID = 32 _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 ──────────────────────────────────────────────────────────────── # ─── Formatter ────────────────────────────────────────────────────────────────
def _sd_escape(value: str) -> str: def _sd_escape(value: str) -> str:
@@ -90,156 +79,11 @@ def syslog_line(
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" 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: def write_syslog_file(line: str) -> None:
"""Append a syslog line to the rotating log file.""" """Emit a syslog line to stdout for Docker log capture."""
try: print(line, flush=True)
_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
# ─── TCP forwarding ───────────────────────────────────────────────────────────
def forward_syslog(line: str, log_target: str) -> None: def forward_syslog(line: str, log_target: str) -> None:
"""Forward a syslog line over TCP to log_target (ip:port).""" """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
if not log_target: pass
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

View File

@@ -1,8 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
IMAPserver. IMAP server (port 143/993).
Presents an IMAP4rev1 banner, captures LOGIN credentials (plaintext and Presents an IMAP4rev1 banner, captures LOGIN credentials.
AUTHENTICATE), then returns a NO response. Logs all commands as JSON. 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 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") NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
SERVICE_NAME = "imap" SERVICE_NAME = "imap"
LOG_TARGET = os.environ.get("LOG_TARGET", "") 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: def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) 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) write_syslog_file(line)
forward_syslog(line, LOG_TARGET) forward_syslog(line, LOG_TARGET)
class IMAPProtocol(asyncio.Protocol): class IMAPProtocol(asyncio.Protocol):
def __init__(self): def __init__(self):
self._transport = None self._transport = None
self._peer = None self._peer = None
self._buf = b"" 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): def connection_made(self, transport):
self._transport = transport self._transport = transport
self._peer = transport.get_extra_info("peername", ("?", 0)) self._peer = transport.get_extra_info("peername", ("?", 0))
_log("connect", src=self._peer[0], src_port=self._peer[1]) _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): def data_received(self, data):
self._buf += data self._buf += data
@@ -50,22 +62,60 @@ class IMAPProtocol(asyncio.Protocol):
cmd = parts[1].upper() if len(parts) > 1 else "" cmd = parts[1].upper() if len(parts) > 1 else ""
args = parts[2] if len(parts) > 2 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) creds = args.split(None, 1)
username = creds[0].strip('"') if creds else "" username = creds[0].strip('"') if creds else ""
password = creds[1].strip('"') if len(creds) > 1 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()) if username in self._valid_users and self._valid_users[username] == password:
elif cmd == "CAPABILITY": self._state = "AUTHENTICATED"
self._transport.write(b"* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=LOGIN\r\n") _log("auth", src=self._peer[0], username=username, password=password, status="success")
self._transport.write(f"{tag} OK CAPABILITY completed\r\n".encode()) 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": elif cmd == "LOGOUT":
self._transport.write(b"* BYE IMAP4rev1 Server logging out\r\n") self._transport.write(b"* BYE Logging out\r\n")
self._transport.write(f"{tag} OK LOGOUT completed\r\n".encode()) self._transport.write(f"{tag} OK Logout completed.\r\n".encode())
self._transport.close() self._transport.close()
else: else:
_log("command", src=self._peer[0], cmd=line[:128]) self._transport.write(f"{tag} BAD Command not recognized or unsupported\r\n".encode())
self._transport.write(f"{tag} BAD Command not recognized\r\n".encode())
def connection_lost(self, exc): def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?") _log("disconnect", src=self._peer[0] if self._peer else "?")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,30 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
MQTT server (port 1883). MQTT server (port 1883).
Parses MQTT CONNECT packets, extracts client_id, username, and password, Parses MQTT CONNECT packets, extracts client_id, etc.
then returns CONNACK with return code 5 (not authorized). Logs all Responds with CONNACK.
interactions as JSON. Supports dynamic topics and retained publishes.
Logs PUBLISH commands sent by clients.
""" """
import asyncio import asyncio
import json
import os import os
import random
import struct import struct
from decnet_logging import syslog_line, write_syslog_file, forward_syslog from decnet_logging import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker") NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker")
SERVICE_NAME = "mqtt" SERVICE_NAME = "mqtt"
LOG_TARGET = os.environ.get("LOG_TARGET", "") 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" _CONNACK_NOT_AUTH = b"\x20\x02\x00\x05"
def _log(event_type: str, severity: int = 6, **kwargs) -> None: def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
print(line, flush=True) print(line, flush=True)
@@ -38,45 +42,128 @@ def _read_utf8(data: bytes, pos: int):
def _parse_connect(payload: bytes): def _parse_connect(payload: bytes):
"""Extract client_id, username, password from MQTT CONNECT payload."""
pos = 0 pos = 0
# Protocol name
proto_name, pos = _read_utf8(payload, pos) proto_name, pos = _read_utf8(payload, pos)
# Protocol level (1 byte)
if pos >= len(payload): if pos >= len(payload):
return {}, pos return {}, pos
_proto_level = payload[pos] _proto_level = payload[pos]
pos += 1 pos += 1
# Connect flags (1 byte)
if pos >= len(payload): if pos >= len(payload):
return {}, pos return {}, pos
flags = payload[pos] flags = payload[pos]
pos += 1 pos += 1
# Keep alive (2 bytes) pos += 2 # Keep alive
pos += 2
# Client ID
client_id, pos = _read_utf8(payload, pos) client_id, pos = _read_utf8(payload, pos)
result = {"client_id": client_id, "proto": proto_name} result = {"client_id": client_id, "proto": proto_name}
# Will flag
if flags & 0x04: if flags & 0x04:
_, pos = _read_utf8(payload, pos) # will topic _, pos = _read_utf8(payload, pos)
_, pos = _read_utf8(payload, pos) # will message _, pos = _read_utf8(payload, pos)
# Username flag
if flags & 0x80: if flags & 0x80:
username, pos = _read_utf8(payload, pos) username, pos = _read_utf8(payload, pos)
result["username"] = username result["username"] = username
# Password flag
if flags & 0x40: if flags & 0x40:
password, pos = _read_utf8(payload, pos) password, pos = _read_utf8(payload, pos)
result["password"] = password result["password"] = password
return result 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): class MQTTProtocol(asyncio.Protocol):
def __init__(self): def __init__(self):
self._transport = None self._transport = None
self._peer = None self._peer = None
self._buf = b"" self._buf = b""
self._auth = False
self._topics = _generate_topics()
def connection_made(self, transport): def connection_made(self, transport):
self._transport = transport self._transport = transport
@@ -85,11 +172,20 @@ class MQTTProtocol(asyncio.Protocol):
def data_received(self, data): def data_received(self, data):
self._buf += 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): def _process(self):
while len(self._buf) >= 2: 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) # Decode remaining length (variable-length encoding)
pos = 1 pos = 1
remaining = 0 remaining = 0
@@ -110,11 +206,49 @@ class MQTTProtocol(asyncio.Protocol):
if pkt_type == 1: # CONNECT if pkt_type == 1: # CONNECT
info = _parse_connect(payload) info = _parse_connect(payload)
_log("auth", src=self._peer[0], **info) _log("auth", **info)
self._transport.write(_CONNACK_NOT_AUTH) if MQTT_ACCEPT_ALL:
self._transport.close() 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 elif pkt_type == 12: # PINGREQ
self._transport.write(b"\xd0\x00") # PINGRESP self._transport.write(b"\xd0\x00") # PINGRESP
elif pkt_type == 14: # DISCONNECT
self._transport.close()
else: else:
_log("packet", src=self._peer[0], pkt_type=pkt_type) _log("packet", src=self._peer[0], pkt_type=pkt_type)
self._transport.close() self._transport.close()

View File

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

View File

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

View File

@@ -2,10 +2,9 @@
""" """
Shared RFC 5424 syslog helper for DECNET service templates. Shared RFC 5424 syslog helper for DECNET service templates.
Provides two functions consumed by every service's server.py: Services call syslog_line() to format an RFC 5424 message, then
- syslog_line(service, hostname, event_type, severity, **fields) -> str write_syslog_file() to emit it to stdout — Docker captures it, and the
- write_syslog_file(line: str) -> None host-side collector streams it into the log file.
- forward_syslog(line: str, log_target: str) -> None
RFC 5424 structure: RFC 5424 structure:
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG <PRI>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 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 datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
# ─── Constants ──────────────────────────────────────────────────────────────── # ─── Constants ────────────────────────────────────────────────────────────────
@@ -40,11 +34,6 @@ _MAX_HOSTNAME = 255
_MAX_APPNAME = 48 _MAX_APPNAME = 48
_MAX_MSGID = 32 _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 ──────────────────────────────────────────────────────────────── # ─── Formatter ────────────────────────────────────────────────────────────────
def _sd_escape(value: str) -> str: def _sd_escape(value: str) -> str:
@@ -90,156 +79,11 @@ def syslog_line(
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" 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: def write_syslog_file(line: str) -> None:
"""Append a syslog line to the rotating log file.""" """Emit a syslog line to stdout for Docker log capture."""
try: print(line, flush=True)
_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
# ─── TCP forwarding ───────────────────────────────────────────────────────────
def forward_syslog(line: str, log_target: str) -> None: def forward_syslog(line: str, log_target: str) -> None:
"""Forward a syslog line over TCP to log_target (ip:port).""" """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
if not log_target: pass
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

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
POP3server. POP3 server (port 110/995).
Presents a convincing POP3 banner, collects USER/PASS credentials, then Presents a POP3 banner, captures USER and PASS credentials.
stalls with a generic error. Logs every interaction as JSON and forwards Implements a basic POP3 state machine (AUTHORIZATION -> TRANSACTION).
to LOG_TARGET if set. Provides hardcoded bait emails.
Logs commands as JSON.
""" """
import asyncio 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") NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
SERVICE_NAME = "pop3" SERVICE_NAME = "pop3"
LOG_TARGET = os.environ.get("LOG_TARGET", "") 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: def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) 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) write_syslog_file(line)
forward_syslog(line, LOG_TARGET) forward_syslog(line, LOG_TARGET)
class POP3Protocol(asyncio.Protocol): class POP3Protocol(asyncio.Protocol):
def __init__(self): def __init__(self):
self._transport = None self._transport = None
self._peer = None self._peer = None
self._user = None
self._buf = b"" 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): def connection_made(self, transport):
self._transport = transport self._transport = transport
self._peer = transport.get_extra_info("peername", ("?", 0)) self._peer = transport.get_extra_info("peername", ("?", 0))
_log("connect", src=self._peer[0], src_port=self._peer[1]) _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): def data_received(self, data):
self._buf += data self._buf += data
@@ -45,28 +58,98 @@ class POP3Protocol(asyncio.Protocol):
self._handle_line(line.decode(errors="replace").strip()) self._handle_line(line.decode(errors="replace").strip())
def _handle_line(self, line: str): def _handle_line(self, line: str):
upper = line.upper() parts = line.split(None, 1)
if upper.startswith("USER "): if not parts:
self._user = line[5:].strip() return
_log("user", src=self._peer[0], username=self._user) cmd = parts[0].upper()
self._transport.write(b"+OK\r\n") args = parts[1] if len(parts) > 1 else ""
elif upper.startswith("PASS "):
password = line[5:].strip() _log("command", src=self._peer[0], cmd=line[:128], state=self._state)
_log("auth", src=self._peer[0], username=self._user, password=password)
self._transport.write(b"-ERR Authentication failed\r\n") if cmd == "CAPA":
elif upper == "QUIT":
self._transport.write(b"+OK Bye\r\n")
self._transport.close()
elif upper == "CAPA":
self._transport.write(b"+OK Capability list follows\r\nUSER\r\n.\r\n") 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: else:
_log("command", src=self._peer[0], cmd=line[:128]) self._transport.write(b"-ERR Command not recognized\r\n")
self._transport.write(b"-ERR Unknown command\r\n")
def connection_lost(self, exc): def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?") _log("disconnect", src=self._peer[0] if self._peer else "?")
async def main(): async def main():
_log("startup", msg=f"POP3 server starting as {NODE_NAME}") _log("startup", msg=f"POP3 server starting as {NODE_NAME}")
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -74,6 +157,5 @@ async def main():
async with server: async with server:
await server.serve_forever() await server.serve_forever()
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,9 @@
""" """
Shared RFC 5424 syslog helper for DECNET service templates. Shared RFC 5424 syslog helper for DECNET service templates.
Provides two functions consumed by every service's server.py: Services call syslog_line() to format an RFC 5424 message, then
- syslog_line(service, hostname, event_type, severity, **fields) -> str write_syslog_file() to emit it to stdout — Docker captures it, and the
- write_syslog_file(line: str) -> None host-side collector streams it into the log file.
- forward_syslog(line: str, log_target: str) -> None
RFC 5424 structure: RFC 5424 structure:
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG <PRI>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 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 datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
# ─── Constants ──────────────────────────────────────────────────────────────── # ─── Constants ────────────────────────────────────────────────────────────────
@@ -40,11 +34,6 @@ _MAX_HOSTNAME = 255
_MAX_APPNAME = 48 _MAX_APPNAME = 48
_MAX_MSGID = 32 _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 ──────────────────────────────────────────────────────────────── # ─── Formatter ────────────────────────────────────────────────────────────────
def _sd_escape(value: str) -> str: def _sd_escape(value: str) -> str:
@@ -90,156 +79,11 @@ def syslog_line(
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" 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: def write_syslog_file(line: str) -> None:
"""Append a syslog line to the rotating log file.""" """Emit a syslog line to stdout for Docker log capture."""
try: print(line, flush=True)
_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
# ─── TCP forwarding ───────────────────────────────────────────────────────────
def forward_syslog(line: str, log_target: str) -> None: def forward_syslog(line: str, log_target: str) -> None:
"""Forward a syslog line over TCP to log_target (ip:port).""" """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
if not log_target: pass
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

View File

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

View File

@@ -14,21 +14,58 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "switch") NODE_NAME = os.environ.get("NODE_NAME", "switch")
SERVICE_NAME = "snmp" SERVICE_NAME = "snmp"
LOG_TARGET = os.environ.get("LOG_TARGET", "") 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 <ics-admin@plant.local>",
"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 <support@factory.local>",
"sysName": NODE_NAME,
"sysLocation": "Factory Floor",
},
"substation": {
"sysDescr": "SEL Real-Time Automation Controller RTAC SEL-3555 firmware 1.9.7.0",
"sysContact": "Grid Ops <gridops@utility.local>",
"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 <medit@hospital.local>",
"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 value map — fake but plausible
_OID_VALUES = { _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.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.3.0": "12345678", # sysUpTime
"1.3.6.1.2.1.1.4.0": "admin@localhost", "1.3.6.1.2.1.1.4.0": _arch["sysContact"],
"1.3.6.1.2.1.1.5.0": NODE_NAME, "1.3.6.1.2.1.1.5.0": _arch["sysName"],
"1.3.6.1.2.1.1.6.0": "Server Room", "1.3.6.1.2.1.1.6.0": _arch["sysLocation"],
"1.3.6.1.2.1.1.7.0": "72", "1.3.6.1.2.1.1.7.0": "72",
} }
def _log(event_type: str, severity: int = 6, **kwargs) -> None: def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
print(line, flush=True) 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): 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] b = data[pos]
if b < 0x80: if b < 0x80:
return b, pos + 1 return b, pos + 1
n = b & 0x7f 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") length = int.from_bytes(data[pos + 1:pos + 1 + n], "big")
return length, pos + 1 + n return length, pos + 1 + n
@@ -91,42 +132,67 @@ def _ber_tlv(tag: int, value: bytes) -> bytes:
def _parse_snmp(data: bytes): def _parse_snmp(data: bytes):
"""Return (version, community, request_id, oids) or raise.""" """Return (version, community, request_id, oids) or raise."""
pos = 0 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 += 1
_, pos = _read_ber_length(data, pos) _, pos = _read_ber_length(data, pos)
# version # version
assert data[pos] == 0x02 # nosec B101 if pos >= len(data) or data[pos] != 0x02:
raise ValueError("Expected SNMP version INTEGER")
pos += 1 pos += 1
v_len, pos = _read_ber_length(data, pos) v_len, pos = _read_ber_length(data, pos)
version = int.from_bytes(data[pos:pos + v_len], "big") version = int.from_bytes(data[pos:pos + v_len], "big")
pos += v_len pos += v_len
# community # community
assert data[pos] == 0x04 # nosec B101 if pos >= len(data) or data[pos] != 0x04:
raise ValueError("Expected SNMP community OCTET STREAM")
pos += 1 pos += 1
c_len, pos = _read_ber_length(data, pos) c_len, pos = _read_ber_length(data, pos)
community = data[pos:pos + c_len].decode(errors="replace") community = data[pos:pos + c_len].decode(errors="replace")
pos += c_len pos += c_len
# PDU type (0xa0 = GetRequest, 0xa1 = GetNextRequest) # 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 += 1
_, pos = _read_ber_length(data, pos) _, pos = _read_ber_length(data, pos)
# request-id # request-id
assert data[pos] == 0x02 # nosec B101 if pos >= len(data) or data[pos] != 0x02:
raise ValueError("Expected Request ID INTEGER")
pos += 1 pos += 1
r_len, pos = _read_ber_length(data, pos) r_len, pos = _read_ber_length(data, pos)
request_id = int.from_bytes(data[pos:pos + r_len], "big") request_id = int.from_bytes(data[pos:pos + r_len], "big")
pos += r_len 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 # varbind list
assert data[pos] == 0x30 # nosec B101 if pos >= len(data) or data[pos] != 0x30:
raise ValueError("Expected varbind list SEQUENCE")
pos += 1 pos += 1
vbl_len, pos = _read_ber_length(data, pos) vbl_len, pos = _read_ber_length(data, pos)
end = pos + vbl_len end = pos + vbl_len
oids = [] oids = []
while pos < end: while pos < end:
assert data[pos] == 0x30 # nosec B101 if data[pos] != 0x30:
raise ValueError("Expected varbind SEQUENCE")
pos += 1 pos += 1
vb_len, pos = _read_ber_length(data, pos) 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 pos += 1
oid_len, pos = _read_ber_length(data, pos) oid_len, pos = _read_ber_length(data, pos)
oid = _decode_oid(data[pos:pos + oid_len]) oid = _decode_oid(data[pos:pos + oid_len])
@@ -169,14 +235,14 @@ class SNMPProtocol(asyncio.DatagramProtocol):
response = _build_response(version, community, request_id, oids) response = _build_response(version, community, request_id, oids)
self._transport.sendto(response, addr) self._transport.sendto(response, addr)
except Exception as e: 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): def error_received(self, exc):
pass pass
async def main(): 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() loop = asyncio.get_running_loop()
transport, _ = await loop.create_datagram_endpoint( transport, _ = await loop.create_datagram_endpoint(
SNMPProtocol, local_addr=("0.0.0.0", 161) # nosec B104 SNMPProtocol, local_addr=("0.0.0.0", 161) # nosec B104

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
import json import json
import pytest import pytest
from fastapi.testclient import TestClient
from decnet.web.api import app
from hypothesis import given, strategies as st, settings from hypothesis import given, strategies as st, settings
import httpx import httpx
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD

View File

@@ -10,6 +10,7 @@ from hypothesis import HealthCheck
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
import os as _os
# Must be set before any decnet import touches decnet.env # Must be set before any decnet import touches decnet.env
os.environ["DECNET_JWT_SECRET"] = "test-secret-key-at-least-32-chars-long!!" 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 # Share fuzz settings across API tests
# FUZZ_EXAMPLES: keep low for dev speed; bump via HYPOTHESIS_MAX_EXAMPLES env var in CI # 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_EXAMPLES = int(_os.environ.get("HYPOTHESIS_MAX_EXAMPLES", "10"))
_FUZZ_SETTINGS: dict[str, Any] = { _FUZZ_SETTINGS: dict[str, Any] = {
"max_examples": _FUZZ_EXAMPLES, "max_examples": _FUZZ_EXAMPLES,

View File

@@ -1,7 +1,6 @@
import pytest import pytest
import httpx import httpx
from hypothesis import given, settings, strategies as st from hypothesis import given, settings, strategies as st
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS from ..conftest import _FUZZ_SETTINGS
@pytest.mark.anyio @pytest.mark.anyio

View File

@@ -1,7 +1,6 @@
import pytest import pytest
import httpx import httpx
from typing import Any, Optional from typing import Any, Optional
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS from ..conftest import _FUZZ_SETTINGS
from hypothesis import given, strategies as st, settings from hypothesis import given, strategies as st, settings

View File

@@ -5,7 +5,6 @@ freeze_time controls Python's datetime.now() so we can compute
explicit bucket timestamps deterministically, then pass them to explicit bucket timestamps deterministically, then pass them to
add_log and verify SQLite groups them into the right buckets. add_log and verify SQLite groups them into the right buckets.
""" """
import json
import pytest import pytest
from datetime import datetime, timedelta from datetime import datetime, timedelta
from freezegun import freeze_time from freezegun import freeze_time

Some files were not shown because too many files have changed in this diff Show More