Skip to content

Commit 9c381c4

Browse files
committed
feat: container healthcheck, alert-flush retries, audit hardening (v0.16.0)
June 12 audit remediation: liveness heartbeat + Dockerfile HEALTHCHECK, queued-alert flush keeps failed alerts for one retry, model_select callback validated against registry, blocking pip-audit in CI (aiohttp CVEs assessed not exploitable, ignored with justification), diagnostic prompt sanitizes container config, explicit LLM client timeouts.
1 parent 0c8f572 commit 9c381c4

20 files changed

Lines changed: 406 additions & 31 deletions

.claude/structure/utils.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,15 @@ files:
191191
items: [Path]
192192
- from: tempfile
193193
items: [tempfile]
194+
195+
heartbeat.py:
196+
description: Liveness heartbeat loop touching a file the Docker HEALTHCHECK reads; fresh mtime proves the event loop is responsive.
197+
exports:
198+
- name: heartbeat_loop
199+
kind: function
200+
description: Touch HEARTBEAT_PATH every interval seconds forever; OSError is logged, never raised
201+
params: [{name: path, type: str}, {name: interval, type: float}]
202+
returns: None
203+
imports:
204+
- from: src.constants
205+
items: [HEARTBEAT_INTERVAL_SECONDS, HEARTBEAT_PATH]

.github/workflows/ci.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ jobs:
3333
- name: Test (pytest)
3434
run: uv run pytest tests/
3535

36-
# Audits the project venv for known CVEs. Non-blocking is deliberate:
37-
# a new upstream CVE shouldn't break unrelated PRs — review the log.
36+
# Audits the project venv for known CVEs. Blocking, so new CVEs surface
37+
# immediately. Ignored IDs are aiohttp issues fixed only in 3.14, which
38+
# aiogram caps below; neither is exploitable here (no CookieJar.load(),
39+
# no per-request cookies) — see pyproject.toml. Drop the ignores once
40+
# aiogram allows aiohttp>=3.14.
3841
- name: Dependency audit (pip-audit)
39-
run: uv run pip-audit || true
42+
run: >
43+
uv run pip-audit
44+
--ignore-vuln CVE-2026-34993
45+
--ignore-vuln CVE-2026-47265

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
All notable changes to UnraidMonitor will be documented in this file.
44

5+
## [0.16.0] - 2026-06-12
6+
7+
Remediation of the June 12 codebase audit (vault: `audits/Unraid Monitor/reports/audit-2026-06-12-0639.md`) — all actionable findings implemented.
8+
9+
### Added
10+
- **Container health check** — the bot now touches a liveness heartbeat file from its event loop every 60s (`src/utils/heartbeat.py`), and the Dockerfile gained a `HEALTHCHECK` that fails when the file goes stale. Bot health shows up in `docker ps` and the Unraid dashboard with no port mapping required. The heartbeat starts before the setup wizard too, so a container mid-setup doesn't report unhealthy.
11+
- 10 new tests (1179 total): flush retry/drop semantics, queue dedup, spoofed `model_select` rejection, heartbeat liveness.
12+
13+
### Fixed
14+
- **Queued alerts are no longer lost on a failed flush** — the pre-`/start` queue was cleared *before* delivery, so a transient Telegram failure during the flush silently discarded boot-time alerts. An alert now counts as delivered once any chat receives it; alerts that fail every chat get one retry on the next flush, then are dropped with a logged warning.
15+
- **Identical consecutive alerts dedup at queue time** — two identical crash alerts queued before `/start` no longer both deliver.
16+
- **Auto-heal save surfaces dropped names** — if `set_containers()` ever filters an invalid name, the `/manage` save answer now says how many were skipped instead of staying silent.
17+
18+
### Security
19+
- **`model_select:` callback validated against the registry** — the last callback family without input validation. A spoofed callback can no longer persist arbitrary provider/model strings into `data/model_selection.json`; unknown pairs get "Invalid selection".
20+
- **pip-audit is now blocking in CI** — it previously ran with `|| true`, which is how the aiohttp CVEs went unnoticed. The two known aiohttp advisories (CVE-2026-34993, CVE-2026-47265; fixed only in 3.14, which aiogram caps below) are explicitly `--ignore-vuln`-ed with justification: neither is exploitable here (no `CookieJar.load()`, no per-request cookies). Any *new* CVE now fails the build. aiohttp floor raised to 3.13.5 with a comment documenting the situation.
21+
- **Diagnostic prompt sanitizes container config** — volume mounts, env vars, and port mappings now pass through `sanitize_for_prompt()` like every other prompt input (defense-in-depth against a compromised container injecting instructions).
22+
23+
### Changed
24+
- **Explicit LLM timeouts** — Anthropic/OpenAI clients are constructed with a 120s timeout (SDK default is ~10 minutes), Ollama gets 300s (local models on CPU are legitimately slow), and startup model discovery is capped at 15s so a slow API can't stall boot.
25+
526
## [0.15.1] - 2026-06-08
627

728
### Changed

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,15 @@ src/unraid/monitors/array_monitor.py - ArrayMonitor for disk health and usage al
8585
# Utils
8686
src/utils/api_errors.py - LLM API error handling (Anthropic + OpenAI) with user-friendly messages
8787
src/utils/formatting.py - Bytes/uptime formatting, safe_reply/safe_edit, format_mute_expiry
88+
src/utils/heartbeat.py - heartbeat_loop() touching the liveness file the Docker HEALTHCHECK reads
8889
src/utils/rate_limiter.py - PerUserRateLimiter for per-user API rate limiting
8990
src/utils/sanitize.py - Prompt injection prevention, sensitive data redaction
9091
src/utils/telegram_retry.py - Telegram API retry logic for rate limit handling
9192
src/utils/version_store.py - read/write data/announced_version.json for startup What's new gate (atomic)
9293

9394
## Project Overview
9495

95-
Unraid Server Monitor Bot (v0.15.1) - A Docker-based Telegram bot for monitoring Unraid servers. Monitors Docker containers (events, logs, resources) and Unraid server health (CPU, memory, disks, array, UPS). Uses multi-provider LLM support (Anthropic, OpenAI, Ollama) for AI-powered diagnostics and natural language interaction. Sends alerts via Telegram with quick-action buttons.
96+
Unraid Server Monitor Bot (v0.16.0) - A Docker-based Telegram bot for monitoring Unraid servers. Monitors Docker containers (events, logs, resources) and Unraid server health (CPU, memory, disks, array, UPS). Uses multi-provider LLM support (Anthropic, OpenAI, Ollama) for AI-powered diagnostics and natural language interaction. Sends alerts via Telegram with quick-action buttons.
9697

9798
**Version string lives in TWO places**`pyproject.toml` (`version`) and `src/__init__.py` (`__version__`). Both must be bumped together on release; `tests/test_version.py` guards against drift. `BOT_VERSION` (in `src/bot/health_command.py`) resolves from installed package metadata when available, else falls back to `src.__version__` — the package is not installed in the Docker image, so the `__version__` fallback is what runs in production.
9899

Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ RUN useradd -m appuser && \
3434
COPY entrypoint.sh /entrypoint.sh
3535
RUN chmod +x /entrypoint.sh
3636

37+
# Liveness: the bot touches /tmp/unraidmonitor-heartbeat from its event loop
38+
# every 60s (src/utils/heartbeat.py); stale or missing file = unhealthy.
39+
# Surfaces in `docker ps` and the Unraid dashboard.
40+
HEALTHCHECK --interval=60s --timeout=10s --start-period=90s --retries=3 \
41+
CMD ["python", "-c", "import os,sys,time; sys.exit(0 if time.time() - os.stat('/tmp/unraidmonitor-heartbeat').st_mtime < 180 else 1)"]
42+
3743
# Container starts as root; entrypoint drops to PUID:PGID after fixing permissions
3844
ENTRYPOINT ["/entrypoint.sh"]
3945
CMD ["python", "-m", "src.main"]

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "unraid-monitor-bot"
3-
version = "0.15.1"
3+
version = "0.16.0"
44
requires-python = ">=3.11"
55
dependencies = [
66
"docker>=7.0.0,<8.0.0",
@@ -9,7 +9,10 @@ dependencies = [
99
"pydantic>=2.0,<3.0",
1010
"pydantic-settings>=2.0,<3.0",
1111
"psutil>=6.1.0,<8.0.0",
12-
"aiohttp>=3.9.0,<4.0.0",
12+
# aiogram caps aiohttp <3.14; CVE-2026-34993/CVE-2026-47265 (fixed in 3.14)
13+
# are not exploitable here (no CookieJar.load, no per-request cookies) —
14+
# ignored in CI pip-audit. Lift floor to >=3.14 when aiogram allows it.
15+
"aiohttp>=3.13.5,<4.0.0",
1316
"python-dotenv>=1.0.0,<2.0.0",
1417
]
1518

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# the Docker image so all features work out of the box.
44
docker>=7.0.0,<8.0.0
55
aiogram>=3.4.0,<4.0.0
6-
aiohttp>=3.9.0,<4.0.0
6+
aiohttp>=3.13.5,<4.0.0
77
pyyaml>=6.0,<7.0
88
pydantic>=2.0,<3.0
99
pydantic-settings>=2.0,<3.0

src/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.15.1"
1+
__version__ = "0.16.0"

src/alert_proxy.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@ def __init__(self, bot: Bot, chat_id_store: ChatIdStore, error_display_max_chars
3030
self.chat_id_store = chat_id_store
3131
self.error_display_max_chars = error_display_max_chars
3232
self._queued_alerts: list[tuple[str, dict[str, Any]]] = []
33+
# Queued alerts that failed every chat on their first flush get one
34+
# more attempt on the next flush before being dropped.
35+
self._retry_alerts: list[tuple[str, dict[str, Any]]] = []
3336
self._managers: dict[int, AlertManager] = {}
3437
self._send_lock = asyncio.Lock()
3538

3639
@property
3740
def queued_count(self) -> int:
38-
return len(self._queued_alerts)
41+
return len(self._queued_alerts) + len(self._retry_alerts)
3942

4043
def _get_manager(self, chat_id: int) -> AlertManager:
4144
"""Get or create a cached AlertManager for the given chat_id."""
@@ -56,7 +59,7 @@ async def _send_alert(self, method_name: str, **kwargs: Any) -> None:
5659
if chat_ids:
5760
async with self._send_lock:
5861
# Flush any queued alerts first
59-
if self._queued_alerts:
62+
if self._queued_alerts or self._retry_alerts:
6063
await self._flush_queue(chat_ids)
6164
for chat_id in chat_ids:
6265
try:
@@ -66,24 +69,42 @@ async def _send_alert(self, method_name: str, **kwargs: Any) -> None:
6669
logger.error(f"Failed to send alert to {chat_id}: {e}")
6770
await asyncio.sleep(self._SEND_DELAY)
6871
else:
69-
if len(self._queued_alerts) < self.MAX_QUEUED:
70-
self._queued_alerts.append((method_name, kwargs))
72+
entry = (method_name, kwargs)
73+
if self._queued_alerts and self._queued_alerts[-1] == entry:
74+
logger.info(f"Skipping duplicate queued {method_name.replace('_', ' ')}")
75+
elif len(self._queued_alerts) < self.MAX_QUEUED:
76+
self._queued_alerts.append(entry)
7177
logger.info(f"Queued {method_name.replace('_', ' ')} (no chat ID yet, {len(self._queued_alerts)} queued)")
7278
else:
7379
logger.warning(f"Alert queue full, dropping {method_name.replace('_', ' ')}")
7480

7581
async def _flush_queue(self, chat_ids: set[int]) -> None:
76-
"""Deliver all queued alerts to all chat IDs."""
77-
queued = self._queued_alerts[:]
78-
self._queued_alerts.clear()
79-
logger.info(f"Flushing {len(queued)} queued alerts to {len(chat_ids)} users")
80-
for method_name, kwargs in queued:
82+
"""Deliver all queued alerts to all chat IDs.
83+
84+
An alert counts as delivered once any chat receives it. Alerts that
85+
fail every chat (e.g. Telegram still flaky right after /start) are
86+
kept for one retry on the next flush, then dropped.
87+
"""
88+
retrying = self._retry_alerts
89+
fresh = self._queued_alerts
90+
self._retry_alerts = []
91+
self._queued_alerts = []
92+
logger.info(f"Flushing {len(retrying) + len(fresh)} queued alerts to {len(chat_ids)} users")
93+
for entry, is_retry in [(e, True) for e in retrying] + [(e, False) for e in fresh]:
94+
method_name, kwargs = entry
95+
delivered = False
8196
for chat_id in chat_ids:
8297
try:
8398
manager = self._get_manager(chat_id)
8499
await getattr(manager, method_name)(**kwargs)
100+
delivered = True
85101
except Exception as e:
86102
logger.error(f"Failed to send queued alert to {chat_id}: {e}")
103+
if not delivered:
104+
if is_retry:
105+
logger.warning(f"Dropping queued {method_name.replace('_', ' ')} after failed retry")
106+
else:
107+
self._retry_alerts.append(entry)
87108

88109
async def send_crash_alert(
89110
self,

src/bot/manage_command.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -702,14 +702,20 @@ def feat_heal_save_callback(
702702
async def handler(callback: CallbackQuery) -> None:
703703
user_id = callback.from_user.id if callback.from_user else 0
704704
selected = sorted(selection_state.get(user_id))
705+
saved = selected
705706
if auto_heal_config is not None:
706707
auto_heal_config.set_containers(selected)
708+
saved = list(auto_heal_config.containers)
707709
selection_state.clear(user_id)
708710

709-
count = len(selected)
710-
await callback.answer(
711-
f"Auto-heal {'on for ' + str(count) + ' container(s)' if count else 'disabled'}"
712-
)
711+
count = len(saved)
712+
answer = f"Auto-heal {'on for ' + str(count) + ' container(s)' if count else 'disabled'}"
713+
# set_containers drops names that fail validation — unreachable via the
714+
# picker, but if it ever happens the user should hear about it.
715+
dropped = len(selected) - len(saved)
716+
if dropped:
717+
answer += f" ({dropped} invalid name(s) skipped)"
718+
await callback.answer(answer)
713719
if callback.message:
714720
text, keyboard = _build_features_view(image_update_monitor, auto_heal_config)
715721
await safe_edit(callback.message, text, reply_markup=keyboard)

0 commit comments

Comments
 (0)