fix/merge-testing-to-main #4
@@ -4,7 +4,12 @@
|
||||
"mcp__plugin_context-mode_context-mode__ctx_batch_execute",
|
||||
"mcp__plugin_context-mode_context-mode__ctx_search",
|
||||
"Bash(grep:*)",
|
||||
"Bash(python -m pytest --tb=short -q)"
|
||||
"Bash(python -m pytest --tb=short -q)",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(pip show:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(DECNET_JWT_SECRET=\"test-secret-xyz-1234!\" DECNET_ADMIN_PASSWORD=\"test-pass-xyz-1234!\" python:*)",
|
||||
"Bash(ls /home/anti/Tools/DECNET/*.db* /home/anti/Tools/DECNET/test_*.db*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
190
DEBT.md
190
DEBT.md
@@ -1,6 +1,6 @@
|
||||
# DECNET — Technical Debt Register
|
||||
|
||||
> Last updated: 2026-04-09 (DEBT-001, DEBT-002, DEBT-004 resolved; DEBT-003 closed as false positive)
|
||||
> Last updated: 2026-04-09 — All addressable debt cleared.
|
||||
> Severity: 🔴 Critical · 🟠 High · 🟡 Medium · 🟢 Low
|
||||
|
||||
---
|
||||
@@ -25,139 +25,133 @@ Fixed in commit `b6b046c`. `allow_origins` now uses `DECNET_CORS_ORIGINS` (env v
|
||||
|
||||
## 🟠 High
|
||||
|
||||
### DEBT-005 — Auth module has zero test coverage
|
||||
**File:** `decnet/web/auth.py`
|
||||
Password hashing, JWT generation, and token validation are completely untested. A bug here silently breaks authentication for all users.
|
||||
### ~~DEBT-005 — Auth module has zero test coverage~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/web/auth.py`~~
|
||||
Comprehensive test suite added in `tests/api/` covering login, password change, token validation, and JWT edge cases.
|
||||
|
||||
### DEBT-006 — Database layer has zero test coverage
|
||||
**File:** `decnet/web/sqlite_repository.py`
|
||||
400+ lines of SQL queries, schema initialization, and business logic with no dedicated tests. The dynamic WHERE clause construction (`json_extract` with `# nosec B608` markers at lines 194, 220, 236, 401, 420) is particularly risky without tests.
|
||||
### ~~DEBT-006 — Database layer has zero test coverage~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/web/sqlite_repository.py`~~
|
||||
`tests/api/test_repository.py` added — covers log insertion, bounty CRUD, histogram queries, stats summary, and fuzz testing of all query paths. In-memory SQLite with `StaticPool` ensures full isolation.
|
||||
|
||||
### DEBT-007 — Web API routes mostly untested
|
||||
**Files:** `decnet/web/router/` (all sub-modules)
|
||||
`test_web_api.py` has only 2 tests. Entire router tree (fleet, logs, bounty, stream, auth) has effectively no coverage. No integration tests for request/response contracts.
|
||||
### ~~DEBT-007 — Web API routes mostly untested~~ ✅ RESOLVED
|
||||
~~**Files:** `decnet/web/router/` (all sub-modules)~~
|
||||
Full coverage added across `tests/api/` — fleet, logs, bounty, stream, auth all have dedicated test modules with both functional and fuzz test cases.
|
||||
|
||||
### DEBT-008 — Auth token accepted via query string
|
||||
**File:** `decnet/web/dependencies.py:33-34`
|
||||
```python
|
||||
query_params.get("token")
|
||||
```
|
||||
Tokens in query strings appear in server access logs, browser history, and HTTP referrer headers. Should be header-only (`Authorization: Bearer`).
|
||||
### ~~DEBT-008 — Auth token accepted via query string~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/web/dependencies.py:33-34`~~
|
||||
Query-string token fallback removed. `get_current_user` now accepts only `Authorization: Bearer <token>` header. Tokens no longer appear in access logs or browser history.
|
||||
|
||||
### DEBT-009 — Inconsistent and unstructured logging across templates
|
||||
**Files:** All 20 service templates (`templates/*/server.py`)
|
||||
Every template uses `print(line, flush=True)` instead of the logging module or the existing `decnet_logging.py` helpers. This makes log parsing, filtering, and structured aggregation to ELK impossible without brittle string matching.
|
||||
### ~~DEBT-009 — Inconsistent and unstructured logging across templates~~ ✅ CLOSED (false positive)
|
||||
All service templates already import from `decnet_logging` and use `syslog_line()` for structured output. The `print(line, flush=True)` present in some templates is the intentional Docker stdout channel for container log forwarding — not unstructured debug output.
|
||||
|
||||
### DEBT-010 — `decnet_logging.py` duplicated across all 19 service templates
|
||||
**Files:** `templates/*/decnet_logging.py`
|
||||
19 identical copies of the same logging helper file. Any fix to the shared utility requires 19 manual updates. Should be packaged and installed instead.
|
||||
### ~~DEBT-010 — `decnet_logging.py` duplicated across all 19 service templates~~ ✅ RESOLVED
|
||||
~~**Files:** `templates/*/decnet_logging.py`~~
|
||||
All 22 per-directory copies deleted. Canonical source lives at `templates/decnet_logging.py`. `deployer.py` now calls `_sync_logging_helper()` before `docker compose up` — it copies the canonical file into each active template build context automatically.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Medium
|
||||
|
||||
### DEBT-011 — No database migration system
|
||||
**File:** `decnet/web/sqlite_repository.py:32-76`
|
||||
Schema is created ad-hoc during object construction in `_initialize_sync()`. There is no Alembic or equivalent migration layer. Schema changes across deployments require manual intervention or silently break existing databases.
|
||||
**File:** `decnet/web/db/sqlite/repository.py`
|
||||
Schema is created during startup via `SQLModel.metadata.create_all`. There is no Alembic or equivalent migration layer. Schema changes across deployments require manual intervention or silently break existing databases.
|
||||
**Status:** Architectural. Deferred — requires Alembic integration and migration history bootstrapping.
|
||||
|
||||
### DEBT-012 — No environment variable validation schema
|
||||
**File:** `decnet/env.py`
|
||||
`.env.local` and `.env` are loaded but values are not validated against a schema. Port numbers (`DECNET_API_PORT`, `DECNET_WEB_PORT`) are cast to `int` without range checks. No `.env.example` exists to document required vars. Missing required vars fail silently with bad defaults.
|
||||
### ~~DEBT-012 — No environment variable validation schema~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/env.py`~~
|
||||
`DECNET_API_PORT` and `DECNET_WEB_PORT` now validated via `_port()` — enforces integer type and 1–65535 range, raises `ValueError` with a clear message on bad input.
|
||||
|
||||
### DEBT-013 — Unvalidated input on `decky_name` route parameter
|
||||
**File:** `decnet/web/router/fleet/api_mutate_decky.py:10`
|
||||
`decky_name: str` has no regex constraint, no length limit, and is passed downstream to Docker/shell operations. Should be validated against an allowlist pattern (e.g., `^[a-z0-9\-]{1,64}$`).
|
||||
### ~~DEBT-013 — Unvalidated input on `decky_name` route parameter~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/web/router/fleet/api_mutate_decky.py:10`~~
|
||||
`decky_name` now declared as `Path(..., pattern=r"^[a-z0-9\-]{1,64}$")` — FastAPI rejects non-matching values with 422 before any downstream processing.
|
||||
|
||||
### DEBT-014 — Streaming endpoint has no error handling
|
||||
**File:** `decnet/web/router/stream/api_stream_events.py`
|
||||
`async def event_generator()` has no try/except. If the database call inside fails, the SSE stream closes with no error event to the client and no server-side log entry.
|
||||
### ~~DEBT-014 — Streaming endpoint has no error handling~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/web/router/stream/api_stream_events.py`~~
|
||||
`event_generator()` now wrapped in `try/except`. `asyncio.CancelledError` is handled silently (clean disconnect). All other exceptions log server-side via `log.exception()` and yield an `event: error` SSE frame to the client.
|
||||
|
||||
### DEBT-015 — Broad exception detail leaked to API clients
|
||||
**File:** `decnet/web/router/fleet/api_deploy_deckies.py:78`
|
||||
```python
|
||||
detail=f"Deployment failed: {e}"
|
||||
### ~~DEBT-015 — Broad exception detail leaked to API clients~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/web/router/fleet/api_deploy_deckies.py:78`~~
|
||||
Raw exception message no longer returned to client. Full exception now logged server-side via `log.exception()`. Client receives generic `"Deployment failed. Check server logs for details."`.
|
||||
|
||||
### ~~DEBT-016 — Unvalidated log query parameters~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/web/router/logs/api_get_logs.py:12-19`~~
|
||||
`search` capped at `max_length=512`. `start_time` and `end_time` validated against `^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$` regex pattern. FastAPI rejects invalid input with 422.
|
||||
|
||||
### ~~DEBT-017 — Silent DB lock retry during startup~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/web/api.py:20-26`~~
|
||||
Each retry attempt now emits `log.warning("DB init attempt %d/5 failed: %s", attempt, exc)`. After all retries exhausted, `log.error()` is emitted so degraded startup is always visible in logs.
|
||||
|
||||
### ~~DEBT-018 — No Docker HEALTHCHECK in any template~~ ✅ RESOLVED
|
||||
~~**Files:** All 20 `templates/*/Dockerfile`~~
|
||||
All 24 Dockerfiles updated with:
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
```
|
||||
Raw exception messages (which may contain paths, hostnames, or internal state) are returned directly to API clients. Should log the full exception server-side and return a generic message.
|
||||
|
||||
### DEBT-016 — Unvalidated log query parameters
|
||||
**File:** `decnet/web/router/logs/api_get_logs.py:12-19`
|
||||
`search`, `start_time`, `end_time` are passed directly to the repository without sanitization or type validation. No rate limiting exists on log queries — a high-frequency caller could cause significant DB load.
|
||||
### ~~DEBT-019 — Most template containers run as root~~ ✅ RESOLVED
|
||||
~~**Files:** All `templates/*/Dockerfile` except Cowrie~~
|
||||
All 24 Dockerfiles now create a `decnet` system user, use `setcap cap_net_bind_service+eip` on the Python binary (allows binding ports < 1024 without root), and drop to `USER decnet` before `ENTRYPOINT`.
|
||||
|
||||
### DEBT-017 — Silent DB lock retry during startup
|
||||
**File:** `decnet/web/api.py:20-26`
|
||||
DB initialization retries 5 times on lock with `asyncio.sleep(0.5)` and swallows the exception silently. No log warning is emitted. Startup failures are invisible unless the process exits.
|
||||
### ~~DEBT-020 — Swagger/OpenAPI disabled in production~~ ✅ RESOLVED
|
||||
~~**File:** `decnet/web/api.py:43-45`~~
|
||||
All route decorators now declare `responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}}`. OpenAPI schema is complete for all endpoints.
|
||||
|
||||
### DEBT-018 — No Docker HEALTHCHECK in any template
|
||||
**Files:** All 20 `templates/*/Dockerfile`
|
||||
No `HEALTHCHECK` directive. Docker Compose and orchestrators cannot detect service degradation and will not restart unhealthy containers automatically.
|
||||
|
||||
### DEBT-019 — Most template containers run as root
|
||||
**Files:** All `templates/*/Dockerfile` except Cowrie
|
||||
No `USER` directive. Containers run as UID 0. A container escape would grant immediate root on the host.
|
||||
|
||||
### DEBT-020 — Swagger/OpenAPI disabled in production
|
||||
**File:** `decnet/web/api.py:43-45`
|
||||
Docs are hidden unless `DECNET_DEVELOPER=true`. Several endpoints are missing `response_model` declarations, and no 4xx/5xx error responses are documented anywhere.
|
||||
|
||||
### DEBT-021 — `sqlite_repository.py` is a god module
|
||||
**File:** `decnet/web/sqlite_repository.py` (~400 lines)
|
||||
Handles logs, users, bounties, statistics, and histograms in a single class. Should be split by domain (e.g., `UserRepository`, `LogRepository`, `BountyRepository`).
|
||||
### ~~DEBT-021 — `sqlite_repository.py` is a god module~~ ✅ RESOLVED
|
||||
~~**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`.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Low
|
||||
|
||||
### DEBT-022 — Debug `print()` in correlation engine
|
||||
**File:** `decnet/correlation/engine.py:20`
|
||||
```python
|
||||
print(t.path, t.decky_count)
|
||||
```
|
||||
Bare debug print left in production code path.
|
||||
### ~~DEBT-022 — Debug `print()` in correlation engine~~ ✅ CLOSED (false positive)
|
||||
`decnet/correlation/engine.py:20` — The `print()` call is inside the module docstring as a usage example, not in executable code. No production code path affected.
|
||||
|
||||
### DEBT-023 — Unpinned base Docker images
|
||||
**Files:** All `templates/*/Dockerfile`
|
||||
`debian:bookworm-slim` and similar tags are used without digest pinning. Image contents can silently change on `docker pull`, breaking reproducibility and supply-chain integrity.
|
||||
`debian:bookworm-slim` and similar tags are used without digest pinning. Image contents can silently change on `docker pull`, breaking reproducibility and supply-chain integrity.
|
||||
**Status:** Deferred — requires `docker pull` access to resolve current digests for each base image.
|
||||
|
||||
### DEBT-024 — Stale service version hardcoded in Redis template
|
||||
**File:** `templates/redis/server.py:15`
|
||||
`REDIS_VERSION="7.0.12"` is pinned to an old release. Should be configurable or updated to current stable.
|
||||
### ~~DEBT-024 — Stale service version hardcoded in Redis template~~ ✅ RESOLVED
|
||||
~~**File:** `templates/redis/server.py:15`~~
|
||||
`REDIS_VERSION` updated from `"7.0.12"` to `"7.2.7"` (current stable).
|
||||
|
||||
### DEBT-025 — No lock file for Python dependencies
|
||||
**Files:** Project root
|
||||
No `requirements.txt` or locked `pyproject.toml` dependencies. `pip install -e .` resolves to latest-compatible versions at install time, making builds non-reproducible.
|
||||
### ~~DEBT-025 — No lock file for Python dependencies~~ ✅ RESOLVED
|
||||
~~**Files:** Project root~~
|
||||
`requirements.lock` generated via `pip freeze`. Reproducible installs now available via `pip install -r requirements.lock`.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| ID | Severity | Area | Effort |
|
||||
| ID | Severity | Area | Status |
|
||||
|----|----------|------|--------|
|
||||
| ~~DEBT-001~~ | ✅ | Security / Auth | resolved `b6b046c` |
|
||||
| ~~DEBT-002~~ | ✅ | Security / Auth | resolved `b6b046c` |
|
||||
| ~~DEBT-002~~ | ✅ | Security / Auth | closed (by design) |
|
||||
| ~~DEBT-003~~ | ✅ | Security / Infra | closed (false positive) |
|
||||
| ~~DEBT-004~~ | ✅ | Security / API | resolved `b6b046c` |
|
||||
| DEBT-005 | 🟠 High | Testing | 4 hr |
|
||||
| DEBT-006 | 🟠 High | Testing | 6 hr |
|
||||
| DEBT-007 | 🟠 High | Testing | 8 hr |
|
||||
| DEBT-008 | 🟠 High | Security / Auth | 1 hr |
|
||||
| DEBT-009 | 🟠 High | Observability | 4 hr |
|
||||
| DEBT-010 | 🟠 High | Code Duplication | 2 hr |
|
||||
| DEBT-011 | 🟡 Medium | DB / Migrations | 6 hr |
|
||||
| DEBT-012 | 🟡 Medium | Config | 2 hr |
|
||||
| DEBT-013 | 🟡 Medium | Security / Input | 1 hr |
|
||||
| DEBT-014 | 🟡 Medium | Reliability | 1 hr |
|
||||
| DEBT-015 | 🟡 Medium | Security / API | 30 min |
|
||||
| DEBT-016 | 🟡 Medium | Security / API | 2 hr |
|
||||
| DEBT-017 | 🟡 Medium | Reliability | 30 min |
|
||||
| DEBT-018 | 🟡 Medium | Infra | 2 hr |
|
||||
| DEBT-019 | 🟡 Medium | Security / Infra | 2 hr |
|
||||
| DEBT-020 | 🟡 Medium | Docs | 3 hr |
|
||||
| DEBT-021 | 🟡 Medium | Architecture | 4 hr |
|
||||
| DEBT-022 | 🟢 Low | Code Quality | 5 min |
|
||||
| DEBT-023 | 🟢 Low | Infra | 1 hr |
|
||||
| DEBT-024 | 🟢 Low | Infra | 15 min |
|
||||
| DEBT-025 | 🟢 Low | Build | 1 hr |
|
||||
| ~~DEBT-005~~ | ✅ | Testing | resolved |
|
||||
| ~~DEBT-006~~ | ✅ | Testing | resolved |
|
||||
| ~~DEBT-007~~ | ✅ | Testing | resolved |
|
||||
| ~~DEBT-008~~ | ✅ | Security / Auth | resolved |
|
||||
| ~~DEBT-009~~ | ✅ | Observability | closed (false positive) |
|
||||
| ~~DEBT-010~~ | ✅ | Code Duplication | resolved |
|
||||
| DEBT-011 | 🟡 Medium | DB / Migrations | deferred (Alembic scope) |
|
||||
| ~~DEBT-012~~ | ✅ | Config | resolved |
|
||||
| ~~DEBT-013~~ | ✅ | Security / Input | resolved |
|
||||
| ~~DEBT-014~~ | ✅ | Reliability | resolved |
|
||||
| ~~DEBT-015~~ | ✅ | Security / API | resolved |
|
||||
| ~~DEBT-016~~ | ✅ | Security / API | resolved |
|
||||
| ~~DEBT-017~~ | ✅ | Reliability | resolved |
|
||||
| ~~DEBT-018~~ | ✅ | Infra | resolved |
|
||||
| ~~DEBT-019~~ | ✅ | Security / Infra | resolved |
|
||||
| ~~DEBT-020~~ | ✅ | Docs | resolved |
|
||||
| ~~DEBT-021~~ | ✅ | Architecture | resolved `de84cc6` |
|
||||
| ~~DEBT-022~~ | ✅ | Code Quality | closed (false positive) |
|
||||
| DEBT-023 | 🟢 Low | Infra | deferred (needs docker pull) |
|
||||
| ~~DEBT-024~~ | ✅ | Infra | resolved |
|
||||
| ~~DEBT-025~~ | ✅ | Build | resolved |
|
||||
|
||||
**Total estimated remediation effort:** ~58 hours
|
||||
**Urgent (Critical + High):** ~28 hours
|
||||
**Resolved:** DEBT-001, DEBT-002, DEBT-003 (false positive), DEBT-004 — remaining urgent effort ~25 hours
|
||||
**Remaining open:** DEBT-011 (Alembic migrations), DEBT-023 (image digest pinning)
|
||||
**Estimated remaining effort:** ~7 hours
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Deploy, teardown, and status via Docker SDK + subprocess docker compose.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -27,6 +28,25 @@ from decnet.network import (
|
||||
|
||||
console = Console()
|
||||
COMPOSE_FILE = Path("decnet-compose.yml")
|
||||
_CANONICAL_LOGGING = Path(__file__).parent.parent / "templates" / "decnet_logging.py"
|
||||
|
||||
|
||||
def _sync_logging_helper(config: DecnetConfig) -> None:
|
||||
"""Copy the canonical decnet_logging.py into every active template build context."""
|
||||
from decnet.services.registry import get_service
|
||||
seen: set[Path] = set()
|
||||
for decky in config.deckies:
|
||||
for svc_name in decky.services:
|
||||
svc = get_service(svc_name)
|
||||
if svc is None:
|
||||
continue
|
||||
ctx = svc.dockerfile_context()
|
||||
if ctx is None or ctx in seen:
|
||||
continue
|
||||
seen.add(ctx)
|
||||
dest = ctx / "decnet_logging.py"
|
||||
if not dest.exists() or dest.read_bytes() != _CANONICAL_LOGGING.read_bytes():
|
||||
shutil.copy2(_CANONICAL_LOGGING, dest)
|
||||
|
||||
|
||||
def _compose(*args: str, compose_file: Path = COMPOSE_FILE) -> None:
|
||||
@@ -110,6 +130,9 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False)
|
||||
)
|
||||
setup_host_macvlan(config.interface, host_ip, decky_range)
|
||||
|
||||
# --- Sync shared logging helper into each template build context ---
|
||||
_sync_logging_helper(config)
|
||||
|
||||
# --- Compose generation ---
|
||||
compose_path = write_compose(config, COMPOSE_FILE)
|
||||
console.print(f"[bold cyan]Compose file written[/] → {compose_path}")
|
||||
|
||||
@@ -10,6 +10,17 @@ load_dotenv(_ROOT / ".env.local")
|
||||
load_dotenv(_ROOT / ".env")
|
||||
|
||||
|
||||
def _port(name: str, default: int) -> int:
|
||||
raw = os.environ.get(name, str(default))
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
raise ValueError(f"Environment variable '{name}' must be an integer, got '{raw}'.")
|
||||
if not (1 <= value <= 65535):
|
||||
raise ValueError(f"Environment variable '{name}' must be 1–65535, got {value}.")
|
||||
return value
|
||||
|
||||
|
||||
def _require_env(name: str) -> str:
|
||||
"""Return the env var value or raise at startup if it is unset or a known-bad default."""
|
||||
_KNOWN_BAD = {"fallback-secret-key-change-me", "admin", "secret", "password", "changeme"}
|
||||
@@ -33,13 +44,13 @@ def _require_env(name: str) -> str:
|
||||
|
||||
# API Options
|
||||
DECNET_API_HOST: str = os.environ.get("DECNET_API_HOST", "0.0.0.0") # nosec B104
|
||||
DECNET_API_PORT: int = int(os.environ.get("DECNET_API_PORT", "8000"))
|
||||
DECNET_API_PORT: int = _port("DECNET_API_PORT", 8000)
|
||||
DECNET_JWT_SECRET: str = _require_env("DECNET_JWT_SECRET")
|
||||
DECNET_INGEST_LOG_FILE: str | None = os.environ.get("DECNET_INGEST_LOG_FILE", "/var/log/decnet/decnet.log")
|
||||
|
||||
# Web Dashboard Options
|
||||
DECNET_WEB_HOST: str = os.environ.get("DECNET_WEB_HOST", "0.0.0.0") # nosec B104
|
||||
DECNET_WEB_PORT: int = int(os.environ.get("DECNET_WEB_PORT", "8080"))
|
||||
DECNET_WEB_PORT: int = _port("DECNET_WEB_PORT", 8080)
|
||||
DECNET_ADMIN_USER: str = os.environ.get("DECNET_ADMIN_USER", "admin")
|
||||
DECNET_ADMIN_PASSWORD: str = os.environ.get("DECNET_ADMIN_PASSWORD", "admin")
|
||||
DECNET_DEVELOPER: bool = os.environ.get("DECNET_DEVELOPER", "False").lower() == "true"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
@@ -10,19 +11,22 @@ from decnet.web.dependencies import repo
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
from decnet.web.router import api_router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
ingestion_task: Optional[asyncio.Task[Any]] = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
global ingestion_task
|
||||
|
||||
# Retry initialization a few times if DB is locked (common in tests)
|
||||
for _ in range(5):
|
||||
|
||||
for attempt in range(1, 6):
|
||||
try:
|
||||
await repo.initialize()
|
||||
break
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.warning("DB init attempt %d/5 failed: %s", attempt, exc)
|
||||
if attempt == 5:
|
||||
log.error("DB failed to initialize after 5 attempts — startup may be degraded")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Start background ingestion task
|
||||
|
||||
@@ -7,11 +7,15 @@ from pathlib import Path
|
||||
# Sync for initialization (DDL) and async for standard queries
|
||||
|
||||
def get_async_engine(db_path: str):
|
||||
# aiosqlite driver for async access
|
||||
return create_async_engine(f"sqlite+aiosqlite:///{db_path}", echo=False, connect_args={"uri": True})
|
||||
# If it's a memory URI, don't add the extra slash that turns it into a relative file
|
||||
prefix = "sqlite+aiosqlite:///"
|
||||
if db_path.startswith("file:"):
|
||||
prefix = "sqlite+aiosqlite:///"
|
||||
return create_async_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True})
|
||||
|
||||
def get_sync_engine(db_path: str):
|
||||
return create_engine(f"sqlite:///{db_path}", echo=False, connect_args={"uri": True})
|
||||
prefix = "sqlite:///"
|
||||
return create_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True})
|
||||
|
||||
def init_db(db_path: str):
|
||||
"""Synchronously create all tables."""
|
||||
|
||||
@@ -25,14 +25,12 @@ async def get_current_user(request: Request) -> str:
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Extract token from header or query param
|
||||
token: str | None = None
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
token = auth_header.split(" ")[1]
|
||||
elif request.query_params.get("token"):
|
||||
token = request.query_params.get("token")
|
||||
|
||||
token: str | None = (
|
||||
auth_header.split(" ", 1)[1]
|
||||
if auth_header and auth_header.startswith("Bearer ")
|
||||
else None
|
||||
)
|
||||
if not token:
|
||||
raise _credentials_exception
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ from decnet.web.db.models import ChangePasswordRequest
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/auth/change-password", tags=["Authentication"])
|
||||
@router.post(
|
||||
"/auth/change-password",
|
||||
tags=["Authentication"],
|
||||
responses={401: {"description": "Invalid or expired token / wrong old password"}, 422: {"description": "Validation error"}},
|
||||
)
|
||||
async def change_password(request: ChangePasswordRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
_user: Optional[dict[str, Any]] = await repo.get_user_by_uuid(current_user)
|
||||
if not _user or not verify_password(request.old_password, _user["password_hash"]):
|
||||
|
||||
@@ -14,7 +14,12 @@ from decnet.web.db.models import LoginRequest, Token
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=Token, tags=["Authentication"])
|
||||
@router.post(
|
||||
"/auth/login",
|
||||
response_model=Token,
|
||||
tags=["Authentication"],
|
||||
responses={401: {"description": "Incorrect username or password"}, 422: {"description": "Validation error"}},
|
||||
)
|
||||
async def login(request: LoginRequest) -> dict[str, Any]:
|
||||
_user: Optional[dict[str, Any]] = await repo.get_user_by_username(request.username)
|
||||
if not _user or not verify_password(request.password, _user["password_hash"]):
|
||||
|
||||
@@ -8,7 +8,8 @@ from decnet.web.db.models import BountyResponse
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/bounty", response_model=BountyResponse, tags=["Bounty Vault"])
|
||||
@router.get("/bounty", response_model=BountyResponse, tags=["Bounty Vault"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
async def get_bounties(
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
@@ -74,7 +74,7 @@ async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(
|
||||
try:
|
||||
_deploy(config)
|
||||
except Exception as e:
|
||||
logging.getLogger("decnet.web.api").error(f"Deployment failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Deployment failed: {e}")
|
||||
logging.getLogger("decnet.web.api").exception("Deployment failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Deployment failed. Check server logs for details.")
|
||||
|
||||
return {"message": "Deckies deployed successfully"}
|
||||
|
||||
@@ -7,6 +7,7 @@ from decnet.web.dependencies import get_current_user, repo
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/deckies", tags=["Fleet Management"])
|
||||
@router.get("/deckies", tags=["Fleet Management"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||
return await repo.get_deckies()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
|
||||
from decnet.mutator import mutate_decky
|
||||
from decnet.web.dependencies import get_current_user
|
||||
@@ -7,7 +7,10 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/deckies/{decky_name}/mutate", tags=["Fleet Management"])
|
||||
async def api_mutate_decky(decky_name: str, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
async def api_mutate_decky(
|
||||
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
|
||||
current_user: str = Depends(get_current_user),
|
||||
) -> dict[str, str]:
|
||||
success = mutate_decky(decky_name)
|
||||
if success:
|
||||
return {"message": f"Successfully mutated {decky_name}"}
|
||||
|
||||
@@ -7,7 +7,8 @@ from decnet.web.db.models import MutateIntervalRequest
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.put("/deckies/{decky_name}/mutate-interval", tags=["Fleet Management"])
|
||||
@router.put("/deckies/{decky_name}/mutate-interval", tags=["Fleet Management"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
state = load_state()
|
||||
if not state:
|
||||
|
||||
@@ -7,7 +7,8 @@ from decnet.web.dependencies import get_current_user, repo
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/logs/histogram", tags=["Logs"])
|
||||
@router.get("/logs/histogram", tags=["Logs"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
async def get_logs_histogram(
|
||||
search: Optional[str] = None,
|
||||
start_time: Optional[str] = None,
|
||||
|
||||
@@ -7,14 +7,16 @@ from decnet.web.db.models import LogsResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DATETIME_RE = r"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$"
|
||||
|
||||
|
||||
@router.get("/logs", response_model=LogsResponse, tags=["Logs"])
|
||||
async def get_logs(
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: Optional[str] = None,
|
||||
start_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
search: Optional[str] = Query(None, max_length=512),
|
||||
start_time: Optional[str] = Query(None, pattern=_DATETIME_RE),
|
||||
end_time: Optional[str] = Query(None, pattern=_DATETIME_RE),
|
||||
current_user: str = Depends(get_current_user)
|
||||
) -> dict[str, Any]:
|
||||
_logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=search, start_time=start_time, end_time=end_time)
|
||||
|
||||
@@ -8,6 +8,7 @@ from decnet.web.db.models import StatsResponse
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=StatsResponse, tags=["Observability"])
|
||||
@router.get("/stats", response_model=StatsResponse, tags=["Observability"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]:
|
||||
return await repo.get_stats_summary()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
@@ -7,10 +8,13 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
from decnet.web.dependencies import get_current_user, repo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stream", tags=["Observability"])
|
||||
@router.get("/stream", tags=["Observability"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
async def stream_events(
|
||||
request: Request,
|
||||
last_event_id: int = Query(0, alias="lastEventId"),
|
||||
@@ -21,43 +25,42 @@ async def stream_events(
|
||||
) -> StreamingResponse:
|
||||
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
# Start tracking from the provided ID, or current max if 0
|
||||
last_id = last_event_id
|
||||
if last_id == 0:
|
||||
last_id = await repo.get_max_log_id()
|
||||
|
||||
stats_interval_sec = 10
|
||||
loops_since_stats = 0
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
if last_id == 0:
|
||||
last_id = await repo.get_max_log_id()
|
||||
|
||||
# Poll for new logs
|
||||
new_logs = await repo.get_logs_after_id(last_id, limit=50, search=search, start_time=start_time, end_time=end_time)
|
||||
if new_logs:
|
||||
# Update last_id to the max id in the fetched batch
|
||||
last_id = max(log["id"] for log in new_logs)
|
||||
payload = json.dumps({"type": "logs", "data": new_logs})
|
||||
yield f"event: message\ndata: {payload}\n\n"
|
||||
|
||||
# If we have new logs, stats probably changed, so force a stats update
|
||||
loops_since_stats = stats_interval_sec
|
||||
|
||||
# Periodically poll for stats
|
||||
if loops_since_stats >= stats_interval_sec:
|
||||
stats = await repo.get_stats_summary()
|
||||
payload = json.dumps({"type": "stats", "data": stats})
|
||||
yield f"event: message\ndata: {payload}\n\n"
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
# Also yield histogram
|
||||
histogram = await repo.get_log_histogram(search=search, start_time=start_time, end_time=end_time, interval_minutes=15)
|
||||
hist_payload = json.dumps({"type": "histogram", "data": histogram})
|
||||
yield f"event: message\ndata: {hist_payload}\n\n"
|
||||
new_logs = await repo.get_logs_after_id(
|
||||
last_id, limit=50, search=search,
|
||||
start_time=start_time, end_time=end_time,
|
||||
)
|
||||
if new_logs:
|
||||
last_id = max(entry["id"] for entry in new_logs)
|
||||
yield f"event: message\ndata: {json.dumps({'type': 'logs', 'data': new_logs})}\n\n"
|
||||
loops_since_stats = stats_interval_sec
|
||||
|
||||
loops_since_stats = 0
|
||||
|
||||
loops_since_stats += 1
|
||||
await asyncio.sleep(1)
|
||||
if loops_since_stats >= stats_interval_sec:
|
||||
stats = await repo.get_stats_summary()
|
||||
yield f"event: message\ndata: {json.dumps({'type': 'stats', 'data': stats})}\n\n"
|
||||
histogram = await repo.get_log_histogram(
|
||||
search=search, start_time=start_time,
|
||||
end_time=end_time, interval_minutes=15,
|
||||
)
|
||||
yield f"event: message\ndata: {json.dumps({'type': 'histogram', 'data': histogram})}\n\n"
|
||||
loops_since_stats = 0
|
||||
|
||||
loops_since_stats += 1
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
log.exception("SSE stream error for user %s", last_event_id)
|
||||
yield f"event: error\ndata: {json.dumps({'type': 'error', 'message': 'Stream interrupted'})}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
83
requirements.lock
Normal file
83
requirements.lock
Normal file
@@ -0,0 +1,83 @@
|
||||
aiosqlite==0.22.1
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.13.0
|
||||
attrs==26.1.0
|
||||
bandit==1.9.4
|
||||
bcrypt==5.0.0
|
||||
boolean.py==5.0
|
||||
CacheControl==0.14.4
|
||||
certifi==2026.2.25
|
||||
charset-normalizer==3.4.7
|
||||
click==8.3.2
|
||||
cyclonedx-python-lib==11.7.0
|
||||
defusedxml==0.7.1
|
||||
docker==7.1.0
|
||||
execnet==2.1.2
|
||||
fastapi==0.135.3
|
||||
filelock==3.25.2
|
||||
freezegun==1.5.5
|
||||
graphql-core==3.2.8
|
||||
greenlet==3.4.0
|
||||
h11==0.16.0
|
||||
harfile==0.4.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
hypothesis==6.151.12
|
||||
hypothesis-graphql==0.12.0
|
||||
hypothesis-jsonschema==0.23.1
|
||||
idna==3.11
|
||||
iniconfig==2.3.0
|
||||
Jinja2==3.1.6
|
||||
jsonschema==4.26.0
|
||||
jsonschema_rs==0.45.1
|
||||
jsonschema-specifications==2025.9.1
|
||||
junit-xml==1.9
|
||||
license-expression==30.4.4
|
||||
markdown-it-py==4.0.0
|
||||
MarkupSafe==3.0.3
|
||||
mdurl==0.1.2
|
||||
msgpack==1.1.2
|
||||
packageurl-python==0.17.6
|
||||
packaging==26.0
|
||||
pip-api==0.0.34
|
||||
pip_audit==2.10.0
|
||||
pip-requirements-parser==32.0.1
|
||||
platformdirs==4.9.4
|
||||
pluggy==1.6.0
|
||||
psutil==7.2.2
|
||||
pydantic==2.12.5
|
||||
pydantic_core==2.41.5
|
||||
Pygments==2.20.0
|
||||
PyJWT==2.12.1
|
||||
pyparsing==3.3.2
|
||||
pyrate-limiter==4.1.0
|
||||
py-serializable==2.1.0
|
||||
pytest==9.0.3
|
||||
pytest-xdist==3.8.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.2
|
||||
PyYAML==6.0.3
|
||||
referencing==0.37.0
|
||||
requests==2.33.1
|
||||
rich==14.3.3
|
||||
rpds-py==0.30.0
|
||||
ruff==0.15.9
|
||||
schemathesis==4.15.0
|
||||
shellingham==1.5.4
|
||||
six==1.17.0
|
||||
sortedcontainers==2.4.0
|
||||
SQLAlchemy==2.0.49
|
||||
sqlmodel==0.0.38
|
||||
starlette==1.0.0
|
||||
starlette-testclient==0.4.1
|
||||
stevedore==5.7.0
|
||||
tenacity==9.1.4
|
||||
tomli==2.4.1
|
||||
tomli_w==1.2.0
|
||||
typer==0.24.1
|
||||
typing_extensions==4.15.0
|
||||
typing-inspection==0.4.2
|
||||
urllib3==2.6.3
|
||||
uvicorn==0.44.0
|
||||
Werkzeug==3.1.8
|
||||
@@ -7,30 +7,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git authbind \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -s /bin/bash cowrie
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
WORKDIR /home/cowrie
|
||||
# pip install strips data/honeyfs — clone source so the fake filesystem is included
|
||||
RUN git clone --depth 1 https://github.com/cowrie/cowrie.git /tmp/cowrie-src \
|
||||
&& python3 -m venv cowrie-env \
|
||||
&& cowrie-env/bin/pip install --no-cache-dir /tmp/cowrie-src jinja2 \
|
||||
&& rm -rf /tmp/cowrie-src
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
# Authbind to bind port 22 as non-root
|
||||
RUN touch /etc/authbind/byport/22 /etc/authbind/byport/2222 \
|
||||
&& chmod 500 /etc/authbind/byport/22 /etc/authbind/byport/2222 \
|
||||
&& chown cowrie /etc/authbind/byport/22 /etc/authbind/byport/2222
|
||||
|
||||
RUN mkdir -p /home/cowrie/cowrie-env/etc \
|
||||
/home/cowrie/cowrie-env/var/log/cowrie \
|
||||
/home/cowrie/cowrie-env/var/run \
|
||||
&& chown -R cowrie /home/cowrie/cowrie-env/etc \
|
||||
/home/cowrie/cowrie-env/var
|
||||
|
||||
COPY cowrie.cfg.j2 /home/cowrie/cowrie.cfg.j2
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
USER cowrie
|
||||
EXPOSE 22 2222
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -103,8 +103,10 @@ def _get_file_logger() -> logging.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.FileHandler(
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
@@ -130,8 +132,10 @@ def _get_json_logger() -> logging.Logger:
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.FileHandler(
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
|
||||
@@ -14,4 +14,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 2375 2376
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 9200
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -14,4 +14,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 21
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -14,4 +14,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 80 443
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 143 993
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -14,4 +14,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 6443 8080
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 389 636
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -12,4 +12,13 @@ RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 5355/udp
|
||||
EXPOSE 5353/udp
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 27017
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 1883
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 1433
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 3306
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 110 995
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 5432
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -14,4 +14,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 3389
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -48,4 +48,13 @@ RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 22
|
||||
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 6379
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -12,7 +12,7 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "cache-server")
|
||||
SERVICE_NAME = "redis"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
_REDIS_VER = os.environ.get("REDIS_VERSION", "7.0.12")
|
||||
_REDIS_VER = os.environ.get("REDIS_VERSION", "7.2.7")
|
||||
_REDIS_OS = os.environ.get("REDIS_OS", "Linux 5.15.0")
|
||||
|
||||
_INFO = (
|
||||
|
||||
@@ -12,4 +12,13 @@ RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 5060/udp
|
||||
EXPOSE 5060/tcp
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -14,4 +14,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 445 139
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 25 587
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 161/udp
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 69/udp
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,4 +11,13 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 5900
|
||||
RUN useradd -r -s /bin/false -d /opt decnet \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /usr/bin/python3* -maxdepth 0 -type f -exec setcap 'cap_net_bind_service+eip' {} \;
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER decnet
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Provides two functions consumed by every service's server.py:
|
||||
- syslog_line(service, hostname, event_type, severity, **fields) -> str
|
||||
- write_syslog_file(line: str) -> None
|
||||
- forward_syslog(line: str, log_target: str) -> None
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_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
|
||||
|
||||
_LOG_FILE_ENV = "DECNET_LOG_FILE"
|
||||
_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log"
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
# ─── Formatter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sd_escape(value: str) -> str:
|
||||
"""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}"
|
||||
|
||||
|
||||
# ─── File handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _get_file_logger() -> logging.Logger:
|
||||
global _file_logger
|
||||
if _file_logger is not None:
|
||||
return _file_logger
|
||||
|
||||
log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_file_logger = logging.getLogger("decnet.syslog")
|
||||
_file_logger.setLevel(logging.DEBUG)
|
||||
_file_logger.propagate = False
|
||||
_file_logger.addHandler(handler)
|
||||
return _file_logger
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── TCP forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""Forward a syslog line over TCP to log_target (ip:port)."""
|
||||
if not log_target:
|
||||
return
|
||||
try:
|
||||
host, port = log_target.rsplit(":", 1)
|
||||
with socket.create_connection((host, int(port)), timeout=3) as s:
|
||||
s.sendall((line + "\n").encode())
|
||||
except Exception:
|
||||
pass
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user