chore: remove docs/superpowers from repo
Add to .gitignore to keep local copies untracked. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,3 +14,4 @@ uv.lock
|
|||||||
.hb.yaml
|
.hb.yaml
|
||||||
.superpowers/
|
.superpowers/
|
||||||
rndc-key
|
rndc-key
|
||||||
|
docs/superpowers/
|
||||||
|
|||||||
@@ -1,602 +0,0 @@
|
|||||||
# Plugin Error Checking Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Improve plugin error checking in hbc, especially for nagios_runner, and fix logger messages silently discarded in daemon mode.
|
|
||||||
|
|
||||||
**Architecture:** Three focused changes across three files: (1) `hbd/client/plugin.py` gains a `skip_reason` attribute on Plugin and updated PluginLoader messaging; (2) `hbd/client/plugins/nagios_runner.py` gains async subprocess execution, stderr capture, signal-killed process handling, and init-time command path validation; (3) `hbd/client/main.py` gains proper post-fork logging reconfiguration to syslog.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.11+, asyncio, `logging.handlers.SysLogHandler`, pytest
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path | What changes |
|
|
||||||
|---|---|---|
|
|
||||||
| Modify | `hbd/client/plugin.py` | `Plugin.__init__` gains `skip_reason`; `PluginLoader` checks it |
|
|
||||||
| Modify | `hbd/client/plugins/nagios_runner.py` | async subprocess, stderr, signal codes, init validation, `skip_reason` |
|
|
||||||
| Modify | `hbd/client/main.py` | `_reconfigure_logging_for_daemon()` helper; remove redundant syslog calls |
|
|
||||||
| Create | `tests/test_plugin.py` | PluginLoader messaging tests |
|
|
||||||
| Create | `tests/test_nagios_runner.py` | NagiosRunnerPlugin behaviour tests |
|
|
||||||
|
|
||||||
Run tests throughout with:
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Plugin.skip_reason + PluginLoader messaging
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/plugin.py:40-48` (Plugin.__init__)
|
|
||||||
- Modify: `hbd/client/plugin.py:369-381` (PluginLoader.load_from_directory)
|
|
||||||
- Create: `tests/test_plugin.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Create `tests/test_plugin.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
from hbd.client.plugin import Plugin, PluginLoader, PluginRegistry
|
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_skip_reason_defaults_none(tmp_path):
|
|
||||||
plugin_code = textwrap.dedent("""
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
|
||||||
|
|
||||||
class MinimalPlugin(MonitorPlugin):
|
|
||||||
name = "minimal"
|
|
||||||
version = "1.0.0"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def _collect_metrics(self):
|
|
||||||
return {}
|
|
||||||
""")
|
|
||||||
(tmp_path / "minimal.py").write_text(plugin_code)
|
|
||||||
registry = PluginRegistry()
|
|
||||||
loader = PluginLoader(registry)
|
|
||||||
asyncio.run(loader.load_from_directory(tmp_path))
|
|
||||||
plugin = registry.get("minimal")
|
|
||||||
assert plugin is not None
|
|
||||||
assert plugin.skip_reason is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_loader_logs_info_when_skip_reason_set(tmp_path, caplog):
|
|
||||||
plugin_code = textwrap.dedent("""
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
|
||||||
|
|
||||||
class SkippablePlugin(MonitorPlugin):
|
|
||||||
name = "skippable"
|
|
||||||
version = "1.0.0"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
self.skip_reason = "not configured in yaml"
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _collect_metrics(self):
|
|
||||||
return {}
|
|
||||||
""")
|
|
||||||
(tmp_path / "skippable.py").write_text(plugin_code)
|
|
||||||
registry = PluginRegistry()
|
|
||||||
loader = PluginLoader(registry)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="plugin.loader"):
|
|
||||||
count = asyncio.run(loader.load_from_directory(tmp_path))
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
assert any("skipped: not configured in yaml" in r.message for r in caplog.records)
|
|
||||||
assert not any("failed initialization" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
|
|
||||||
def test_loader_logs_warning_when_no_skip_reason(tmp_path, caplog):
|
|
||||||
plugin_code = textwrap.dedent("""
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
|
||||||
|
|
||||||
class FailPlugin(MonitorPlugin):
|
|
||||||
name = "fail"
|
|
||||||
version = "1.0.0"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _collect_metrics(self):
|
|
||||||
return {}
|
|
||||||
""")
|
|
||||||
(tmp_path / "fail_plugin.py").write_text(plugin_code)
|
|
||||||
registry = PluginRegistry()
|
|
||||||
loader = PluginLoader(registry)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="plugin.loader"):
|
|
||||||
count = asyncio.run(loader.load_from_directory(tmp_path))
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
assert any("failed initialization" in r.message for r in caplog.records)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py -v
|
|
||||||
```
|
|
||||||
Expected: `test_plugin_skip_reason_defaults_none` FAILS (attribute missing), others may error.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `skip_reason` to `Plugin.__init__`**
|
|
||||||
|
|
||||||
In `hbd/client/plugin.py`, in `Plugin.__init__` (around line 46), add one line:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger(f"plugin.{self.name}")
|
|
||||||
self._initialized = False
|
|
||||||
self.skip_reason: Optional[str] = None
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update PluginLoader messaging**
|
|
||||||
|
|
||||||
In `hbd/client/plugin.py`, replace the `if not initialized:` block (around line 372):
|
|
||||||
|
|
||||||
```python
|
|
||||||
if not initialized:
|
|
||||||
if plugin.skip_reason:
|
|
||||||
self.logger.info(
|
|
||||||
f"Plugin {plugin.name} skipped: {plugin.skip_reason}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Plugin {plugin.name} failed initialization, skipping"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run tests to verify they pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py -v
|
|
||||||
```
|
|
||||||
Expected: all 3 tests PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/plugin.py tests/test_plugin.py
|
|
||||||
git commit -m "feat: add skip_reason to Plugin; improve PluginLoader init messaging"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: NagiosRunnerPlugin — skip_reason when no commands
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/plugins/nagios_runner.py:88-105` (initialize)
|
|
||||||
- Modify: `tests/test_nagios_runner.py` (create)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test**
|
|
||||||
|
|
||||||
Create `tests/test_nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from hbd.client.plugins.nagios_runner import (
|
|
||||||
NagiosRunnerPlugin,
|
|
||||||
NAGIOS_OK,
|
|
||||||
NAGIOS_WARNING,
|
|
||||||
NAGIOS_CRITICAL,
|
|
||||||
NAGIOS_UNKNOWN,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_commands_sets_skip_reason():
|
|
||||||
plugin = NagiosRunnerPlugin(config={"commands": []})
|
|
||||||
result = asyncio.run(plugin.initialize())
|
|
||||||
assert result is False
|
|
||||||
assert plugin.skip_reason is not None
|
|
||||||
assert "nagios_runner.commands" in plugin.skip_reason
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py::test_no_commands_sets_skip_reason -v
|
|
||||||
```
|
|
||||||
Expected: FAIL — `plugin.skip_reason` is `None`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Set skip_reason in NagiosRunnerPlugin.initialize()**
|
|
||||||
|
|
||||||
In `hbd/client/plugins/nagios_runner.py`, replace the early-return block in `initialize()` (around line 96):
|
|
||||||
|
|
||||||
```python
|
|
||||||
if not self.commands:
|
|
||||||
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
|
|
||||||
self.logger.info("No Nagios commands configured")
|
|
||||||
return False
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py::test_no_commands_sets_skip_reason -v
|
|
||||||
```
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
|
|
||||||
git commit -m "feat: set skip_reason on nagios_runner when no commands configured"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: NagiosRunnerPlugin — async subprocess, stderr capture, negative return codes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/plugins/nagios_runner.py` (imports + `_run_nagios_plugin`)
|
|
||||||
- Modify: `tests/test_nagios_runner.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_stderr_used_when_stdout_empty(tmp_path):
|
|
||||||
script = tmp_path / "check_err.sh"
|
|
||||||
script.write_text("#!/bin/sh\necho 'error from stderr' >&2\nexit 2\n")
|
|
||||||
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
|
||||||
|
|
||||||
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
data = asyncio.run(plugin._collect_metrics())
|
|
||||||
|
|
||||||
assert "error from stderr" in data["t_output"]
|
|
||||||
assert data["t_status_code"] == NAGIOS_CRITICAL
|
|
||||||
|
|
||||||
|
|
||||||
def test_stderr_appended_when_both_present(tmp_path):
|
|
||||||
script = tmp_path / "check_both.sh"
|
|
||||||
script.write_text("#!/bin/sh\necho 'OK - all good'\necho 'extra detail' >&2\nexit 0\n")
|
|
||||||
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
|
||||||
|
|
||||||
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
data = asyncio.run(plugin._collect_metrics())
|
|
||||||
|
|
||||||
assert "OK - all good" in data["t_output"]
|
|
||||||
assert "extra detail" in data["t_output"]
|
|
||||||
assert data["t_status_code"] == NAGIOS_OK
|
|
||||||
|
|
||||||
|
|
||||||
def test_negative_returncode_maps_to_unknown():
|
|
||||||
# kill -9 $$ kills the shell itself; asyncio sees returncode -9
|
|
||||||
config = {"commands": [{"name": "t", "command": "kill -9 $$"}], "timeout": 5}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
data = asyncio.run(plugin._collect_metrics())
|
|
||||||
|
|
||||||
assert data["t_status_code"] == NAGIOS_UNKNOWN
|
|
||||||
assert "signal" in data["t_output"].lower()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py::test_stderr_used_when_stdout_empty \
|
|
||||||
tests/test_nagios_runner.py::test_stderr_appended_when_both_present \
|
|
||||||
tests/test_nagios_runner.py::test_negative_returncode_maps_to_unknown -v
|
|
||||||
```
|
|
||||||
Expected: all FAIL — current implementation ignores stderr and doesn't handle negative codes.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update imports in nagios_runner.py**
|
|
||||||
|
|
||||||
Replace the import block at the top of `hbd/client/plugins/nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
|
||||||
```
|
|
||||||
|
|
||||||
(Remove `import subprocess`; add `import asyncio` and `import os`.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Upgrade collection log level from DEBUG to INFO**
|
|
||||||
|
|
||||||
In `hbd/client/plugins/nagios_runner.py`, in `_collect_metrics()`, change the debug log (around line 144) so results are visible at INFO level:
|
|
||||||
|
|
||||||
```python
|
|
||||||
self.logger.info(
|
|
||||||
f"Executed {name}: {STATUS_NAMES.get(status_code, 'UNKNOWN')} - {output[:50]}"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Replace `_run_nagios_plugin` with async implementation**
|
|
||||||
|
|
||||||
Replace the entire `_run_nagios_plugin` method in `hbd/client/plugins/nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def _run_nagios_plugin(
|
|
||||||
self,
|
|
||||||
command: str
|
|
||||||
) -> Tuple[int, str, Dict[str, Any]]:
|
|
||||||
"""Execute a Nagios plugin and parse its output."""
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_shell(
|
|
||||||
command,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
||||||
proc.communicate(), timeout=self.timeout
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
await proc.communicate()
|
|
||||||
self.logger.error(f"Command timed out: {command}")
|
|
||||||
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
|
|
||||||
|
|
||||||
status_code = proc.returncode
|
|
||||||
|
|
||||||
if status_code < 0:
|
|
||||||
return NAGIOS_UNKNOWN, f"Process killed by signal {-status_code}", {}
|
|
||||||
|
|
||||||
if status_code > 3:
|
|
||||||
status_code = NAGIOS_UNKNOWN
|
|
||||||
|
|
||||||
stdout = stdout_bytes.decode(errors="replace").strip()
|
|
||||||
stderr = stderr_bytes.decode(errors="replace").strip()
|
|
||||||
|
|
||||||
# Parse perfdata from stdout before mixing in stderr
|
|
||||||
perfdata = self._parse_perfdata(stdout)
|
|
||||||
|
|
||||||
# Build status message
|
|
||||||
status_part = stdout.split('|')[0].strip() if '|' in stdout else stdout
|
|
||||||
|
|
||||||
if not stdout and stderr:
|
|
||||||
output_msg = stderr
|
|
||||||
elif stdout and stderr:
|
|
||||||
output_msg = f"{status_part} [stderr: {stderr}]"
|
|
||||||
else:
|
|
||||||
output_msg = status_part
|
|
||||||
|
|
||||||
return status_code, output_msg, perfdata
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error executing command: {e}")
|
|
||||||
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also remove the now-unused `self.shell` line from `__init__` (the `shell` config key is no longer used since `create_subprocess_shell` always uses a shell):
|
|
||||||
|
|
||||||
In `NagiosRunnerPlugin.__init__`, remove:
|
|
||||||
```python
|
|
||||||
self.shell: bool = config.get("shell", True) if config else True
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run tests to verify they pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py -v
|
|
||||||
```
|
|
||||||
Expected: all tests PASS including the 3 new ones.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
|
|
||||||
git commit -m "feat: async subprocess in nagios_runner with stderr capture and signal handling"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: NagiosRunnerPlugin — command path validation at init
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/plugins/nagios_runner.py` (initialize)
|
|
||||||
- Modify: `tests/test_nagios_runner.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_absolute_path_not_found_warns(caplog):
|
|
||||||
fake_cmd = "/nonexistent_hbc_test_path/check_something"
|
|
||||||
config = {"commands": [{"name": "t", "command": fake_cmd}]}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
|
|
||||||
assert any("not found" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
|
|
||||||
def test_absolute_path_not_executable_warns(caplog, tmp_path):
|
|
||||||
non_exec = tmp_path / "check_test"
|
|
||||||
non_exec.write_text("#!/bin/sh\necho OK\n")
|
|
||||||
non_exec.chmod(0o644) # readable but not executable
|
|
||||||
|
|
||||||
config = {"commands": [{"name": "t", "command": str(non_exec)}]}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
|
|
||||||
assert any("not executable" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
|
|
||||||
def test_relative_path_not_checked(caplog):
|
|
||||||
# Relative paths (resolved via PATH) must not generate warnings
|
|
||||||
config = {"commands": [{"name": "t", "command": "echo OK"}]}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
|
|
||||||
assert not any(
|
|
||||||
"not found" in r.message or "not executable" in r.message
|
|
||||||
for r in caplog.records
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py::test_absolute_path_not_found_warns \
|
|
||||||
tests/test_nagios_runner.py::test_absolute_path_not_executable_warns \
|
|
||||||
tests/test_nagios_runner.py::test_relative_path_not_checked -v
|
|
||||||
```
|
|
||||||
Expected: `test_absolute_path_not_found_warns` and `test_absolute_path_not_executable_warns` FAIL (no warnings logged); `test_relative_path_not_checked` may pass.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add command path validation to `initialize()`**
|
|
||||||
|
|
||||||
In `hbd/client/plugins/nagios_runner.py`, extend `initialize()` by adding validation after the existing "log each command" loop (after line 103, before `return True`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Validate absolute command paths early
|
|
||||||
for cmd_config in self.commands:
|
|
||||||
name = cmd_config.get("name", "unnamed")
|
|
||||||
command = cmd_config.get("command", "")
|
|
||||||
if not command:
|
|
||||||
continue
|
|
||||||
exe = command.split()[0]
|
|
||||||
if os.path.isabs(exe):
|
|
||||||
if not os.path.isfile(exe):
|
|
||||||
self.logger.warning(
|
|
||||||
f"Command '{name}': executable not found: {exe}"
|
|
||||||
)
|
|
||||||
elif not os.access(exe, os.X_OK):
|
|
||||||
self.logger.warning(
|
|
||||||
f"Command '{name}': executable not executable: {exe}"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run full test suite to verify all pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
|
|
||||||
```
|
|
||||||
Expected: all tests PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
|
|
||||||
git commit -m "feat: validate absolute command paths at nagios_runner init"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Daemon mode logging — route to syslog after fork
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/main.py` (new helper + updated daemon block)
|
|
||||||
|
|
||||||
No automated test for daemonization itself (fork behaviour is hard to unit-test). Manual verification steps are provided below.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `_reconfigure_logging_for_daemon` helper**
|
|
||||||
|
|
||||||
In `hbd/client/main.py`, add this function just before `def build_parser()` (around line 589):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _reconfigure_logging_for_daemon(log_level: int) -> None:
|
|
||||||
"""Replace StreamHandlers (now writing to /dev/null) with a SysLogHandler."""
|
|
||||||
from logging.handlers import SysLogHandler
|
|
||||||
|
|
||||||
root = logging.getLogger()
|
|
||||||
for handler in root.handlers[:]:
|
|
||||||
root.removeHandler(handler)
|
|
||||||
handler.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
syslog_handler = SysLogHandler(
|
|
||||||
address="/dev/log",
|
|
||||||
facility=SysLogHandler.LOG_DAEMON,
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
syslog_handler = SysLogHandler(
|
|
||||||
address=("localhost", 514),
|
|
||||||
facility=SysLogHandler.LOG_DAEMON,
|
|
||||||
)
|
|
||||||
# Attach the fallback first so the warning reaches syslog
|
|
||||||
syslog_handler.setFormatter(
|
|
||||||
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
|
|
||||||
)
|
|
||||||
root.addHandler(syslog_handler)
|
|
||||||
root.setLevel(log_level)
|
|
||||||
logging.warning("/dev/log not found, using syslog UDP localhost:514")
|
|
||||||
return
|
|
||||||
|
|
||||||
syslog_handler.setFormatter(
|
|
||||||
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
|
|
||||||
)
|
|
||||||
root.addHandler(syslog_handler)
|
|
||||||
root.setLevel(log_level)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update the daemon block in `main()`**
|
|
||||||
|
|
||||||
In `hbd/client/main.py`, replace the entire `if args.daemon:` block (lines 664–675):
|
|
||||||
|
|
||||||
```python
|
|
||||||
if args.daemon:
|
|
||||||
print("Daemonizing...")
|
|
||||||
daemonize()
|
|
||||||
_reconfigure_logging_for_daemon(log_level)
|
|
||||||
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
|
|
||||||
```
|
|
||||||
|
|
||||||
This removes the `import syslog`, `syslog.openlog()`, and `syslog.syslog()` calls (now handled by the logging system) and removes the no-op second `logging.basicConfig()` call.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run existing test suite to confirm no regressions**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
|
|
||||||
```
|
|
||||||
Expected: all tests still PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual smoke test — verify syslog output in daemon mode**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In one terminal, tail syslog
|
|
||||||
sudo journalctl -f -t hbc
|
|
||||||
|
|
||||||
# In another terminal, start hbc in daemon mode (replace HOST with a real or dummy host)
|
|
||||||
python -m hbd.client.main -d -v localhost
|
|
||||||
|
|
||||||
# Expected in journalctl output:
|
|
||||||
# hbc[<pid>]: hbc.main INFO: Starting hbc for <hostname> -> ['localhost']
|
|
||||||
# hbc[<pid>]: hbc.main INFO: hbc starting, sending heartbeat to localhost
|
|
||||||
# hbc[<pid>]: plugin.loader INFO: ...
|
|
||||||
|
|
||||||
# Stop the daemon
|
|
||||||
pkill -f "hbd.client.main"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/main.py
|
|
||||||
git commit -m "fix: reconfigure logging to syslog after daemonize() instead of no-op basicConfig"
|
|
||||||
```
|
|
||||||
@@ -1,781 +0,0 @@
|
|||||||
# Gitea OAuth2 Authentication Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add Gitea as an OAuth2 login provider that coexists with password auth, auto-provisioning new users on first login.
|
|
||||||
|
|
||||||
**Architecture:** A new `oauth.py` module owns all Gitea-specific logic (CSRF state, URL building, token exchange, user-info fetch). `users.py` gains one function to upsert an OAuth-sourced user. `http.py` gets two new route handlers and a small login-page change. No new dependencies — `aiohttp.ClientSession` is already used in the codebase.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.12, aiohttp 3.x, pytest, pytest-asyncio
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path | Responsibility |
|
|
||||||
|--------|------|----------------|
|
|
||||||
| Modify | `hbd/server/config.py` | Add `"oauth": {}` default |
|
|
||||||
| Create | `hbd/server/oauth.py` | CSRF state, URL builder, token exchange, user-info fetch |
|
|
||||||
| Modify | `hbd/server/users.py` | Add `provision_oauth_user()` |
|
|
||||||
| Modify | `hbd/server/http.py` | Import oauth, two new routes, login page button |
|
|
||||||
| Create | `tests/test_oauth.py` | All new unit tests |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Add config default and `is_enabled()`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/config.py:34` (after the `"users"` line)
|
|
||||||
- Create: `hbd/server/oauth.py`
|
|
||||||
- Create: `tests/test_oauth.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `tests/test_oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import pytest
|
|
||||||
from hbd.server import oauth
|
|
||||||
|
|
||||||
|
|
||||||
CFG_OFF = {}
|
|
||||||
CFG_ON = {
|
|
||||||
"oauth": {
|
|
||||||
"gitea": {
|
|
||||||
"url": "https://git.example.com",
|
|
||||||
"client_id": "cid",
|
|
||||||
"client_secret": "csec",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CFG_PARTIAL = {"oauth": {"gitea": {"url": "https://git.example.com"}}}
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_enabled_when_all_keys_present():
|
|
||||||
assert oauth.is_enabled(CFG_ON) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_enabled_false_when_no_oauth_key():
|
|
||||||
assert oauth.is_enabled(CFG_OFF) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_enabled_false_when_partial_config():
|
|
||||||
assert oauth.is_enabled(CFG_PARTIAL) is False
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to confirm failure**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `ModuleNotFoundError: No module named 'hbd.server.oauth'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add config default**
|
|
||||||
|
|
||||||
In `hbd/server/config.py`, add after the `"default_owner"` line (currently line 35):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# OAuth2 providers
|
|
||||||
"oauth": {}, # oauth.gitea.{url,client_id,client_secret}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Create `hbd/server/oauth.py` with `is_enabled`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Gitea OAuth2 support.
|
|
||||||
|
|
||||||
Config shape (in ~/.hb.yaml):
|
|
||||||
|
|
||||||
oauth:
|
|
||||||
gitea:
|
|
||||||
url: https://git.example.com
|
|
||||||
client_id: <client-id>
|
|
||||||
client_secret: <client-secret>
|
|
||||||
|
|
||||||
Register a Gitea OAuth2 application at:
|
|
||||||
Gitea → Settings → Applications → OAuth2
|
|
||||||
Set the redirect URI to:
|
|
||||||
https://<hbd-host>/login/oauth/gitea/callback
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STATE_TTL = 600 # 10 minutes
|
|
||||||
|
|
||||||
# state_token -> expiry timestamp
|
|
||||||
_states: dict[str, float] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthError(Exception):
|
|
||||||
"""Raised when the OAuth2 flow fails for any reason."""
|
|
||||||
|
|
||||||
|
|
||||||
def _gitea_cfg(config: dict) -> dict:
|
|
||||||
"""Return the gitea sub-dict or {} if absent/incomplete."""
|
|
||||||
return config.get("oauth", {}).get("gitea", {})
|
|
||||||
|
|
||||||
|
|
||||||
def is_enabled(config: dict) -> bool:
|
|
||||||
"""Return True when all three required Gitea OAuth keys are present."""
|
|
||||||
g = _gitea_cfg(config)
|
|
||||||
return bool(g.get("url") and g.get("client_id") and g.get("client_secret"))
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run to confirm tests pass**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 3 passed
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/config.py hbd/server/oauth.py tests/test_oauth.py
|
|
||||||
git commit -m "feat: add oauth module skeleton and is_enabled()"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: CSRF state management
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/oauth.py` (add `make_state`, `validate_state`)
|
|
||||||
- Modify: `tests/test_oauth.py` (add state tests)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import time as time_mod
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_state_returns_unique_tokens():
|
|
||||||
s1 = oauth.make_state()
|
|
||||||
s2 = oauth.make_state()
|
|
||||||
assert s1 != s2
|
|
||||||
assert len(s1) == 64 # 32 bytes hex
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_state_valid():
|
|
||||||
state = oauth.make_state()
|
|
||||||
assert oauth.validate_state(state) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_state_consumed_on_use():
|
|
||||||
state = oauth.make_state()
|
|
||||||
oauth.validate_state(state)
|
|
||||||
assert oauth.validate_state(state) is False # replay rejected
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_state_unknown():
|
|
||||||
assert oauth.validate_state("notastate") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_state_expired(monkeypatch):
|
|
||||||
state = oauth.make_state()
|
|
||||||
# Wind expiry into the past
|
|
||||||
monkeypatch.setitem(oauth._states, state, time_mod.time() - 1)
|
|
||||||
assert oauth.validate_state(state) is False
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to confirm failure**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v -k "state"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `AttributeError: module 'hbd.server.oauth' has no attribute 'make_state'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement state functions**
|
|
||||||
|
|
||||||
Add to `hbd/server/oauth.py` after the `_states` dict definition:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def make_state() -> str:
|
|
||||||
"""Generate a CSRF state token, store it with TTL, and return it."""
|
|
||||||
_purge_states()
|
|
||||||
token = secrets.token_hex(32)
|
|
||||||
_states[token] = time.time() + STATE_TTL
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def validate_state(state: str) -> bool:
|
|
||||||
"""Return True if *state* is known and unexpired; always removes it."""
|
|
||||||
expiry = _states.pop(state, None)
|
|
||||||
if expiry is None:
|
|
||||||
return False
|
|
||||||
return time.time() < expiry
|
|
||||||
|
|
||||||
|
|
||||||
def _purge_states() -> None:
|
|
||||||
now = time.time()
|
|
||||||
expired = [k for k, exp in list(_states.items()) if exp < now]
|
|
||||||
for k in expired:
|
|
||||||
del _states[k]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run to confirm tests pass**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 8 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/oauth.py tests/test_oauth.py
|
|
||||||
git commit -m "feat: add OAuth2 CSRF state management"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: `provision_oauth_user` in users.py
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/users.py` (add `provision_oauth_user`)
|
|
||||||
- Modify: `tests/test_oauth.py` (add provisioning tests)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from hbd.server import users as users_mod
|
|
||||||
from hbd.server.users import User
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_users(entries=None):
|
|
||||||
users_mod.users = entries or {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_provision_oauth_user_new():
|
|
||||||
_reset_users()
|
|
||||||
user = users_mod.provision_oauth_user("gituser", "Git User", "https://example.com/avatar.png")
|
|
||||||
assert user.username == "gituser"
|
|
||||||
assert user.full_name == "Git User"
|
|
||||||
assert user.avatar == "https://example.com/avatar.png"
|
|
||||||
assert user.admin is False
|
|
||||||
assert user.password_hash == ""
|
|
||||||
assert "gituser" in users_mod.users
|
|
||||||
|
|
||||||
|
|
||||||
def test_provision_oauth_user_no_password_login():
|
|
||||||
_reset_users()
|
|
||||||
user = users_mod.provision_oauth_user("gituser", "Git User", "")
|
|
||||||
assert user.check_password("anything") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_provision_oauth_user_existing_updates_profile():
|
|
||||||
existing = User(
|
|
||||||
username="alice",
|
|
||||||
full_name="Old Name",
|
|
||||||
avatar="old.png",
|
|
||||||
password_hash="pbkdf2:sha256:1:salt:abc",
|
|
||||||
admin=True,
|
|
||||||
notification_channels=["chan1"],
|
|
||||||
)
|
|
||||||
_reset_users({"alice": existing})
|
|
||||||
user = users_mod.provision_oauth_user("alice", "New Name", "new.png")
|
|
||||||
assert user.full_name == "New Name"
|
|
||||||
assert user.avatar == "new.png"
|
|
||||||
# Preserved
|
|
||||||
assert user.admin is True
|
|
||||||
assert user.password_hash == "pbkdf2:sha256:1:salt:abc"
|
|
||||||
assert user.notification_channels == ["chan1"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_provision_oauth_user_does_not_overwrite_with_empty():
|
|
||||||
existing = User(username="bob", full_name="Bob", avatar="bob.png")
|
|
||||||
_reset_users({"bob": existing})
|
|
||||||
user = users_mod.provision_oauth_user("bob", "", "")
|
|
||||||
assert user.full_name == "Bob"
|
|
||||||
assert user.avatar == "bob.png"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to confirm failure**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v -k "provision"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `AttributeError: module 'hbd.server.users' has no attribute 'provision_oauth_user'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `provision_oauth_user`**
|
|
||||||
|
|
||||||
Add to `hbd/server/users.py` after the `authenticate()` function (after line 187):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def provision_oauth_user(username: str, full_name: str, avatar: str) -> "User":
|
|
||||||
"""Create or update a user sourced from an OAuth2 provider.
|
|
||||||
|
|
||||||
New users are inserted with no password_hash — they can only authenticate
|
|
||||||
via OAuth. Existing users (e.g. defined in config with a password) have
|
|
||||||
their display name and avatar refreshed; all other attributes are preserved.
|
|
||||||
"""
|
|
||||||
user = users.get(username)
|
|
||||||
if user is None:
|
|
||||||
user = User(username=username, full_name=full_name, avatar=avatar)
|
|
||||||
users[username] = user
|
|
||||||
logger.info("Provisioned OAuth user %r", username)
|
|
||||||
else:
|
|
||||||
if full_name:
|
|
||||||
user.full_name = full_name
|
|
||||||
if avatar:
|
|
||||||
user.avatar = avatar
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run to confirm tests pass**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 12 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/users.py tests/test_oauth.py
|
|
||||||
git commit -m "feat: add provision_oauth_user() to users module"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: URL builder, token exchange, and user-info fetch
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/oauth.py` (add `authorization_url`, `exchange_code`, `fetch_user`)
|
|
||||||
- Modify: `tests/test_oauth.py` (add async tests with mocked HTTP)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
|
|
||||||
|
|
||||||
def test_authorization_url_shape():
|
|
||||||
state = "teststate"
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
url = oauth.authorization_url(CFG_ON, state, redirect_uri)
|
|
||||||
parsed = urlparse(url)
|
|
||||||
qs = parse_qs(parsed.query)
|
|
||||||
assert parsed.scheme == "https"
|
|
||||||
assert parsed.netloc == "git.example.com"
|
|
||||||
assert parsed.path == "/login/oauth/authorize"
|
|
||||||
assert qs["client_id"] == ["cid"]
|
|
||||||
assert qs["state"] == ["teststate"]
|
|
||||||
assert qs["redirect_uri"] == [redirect_uri]
|
|
||||||
assert qs["scope"] == ["user:email"]
|
|
||||||
assert qs["response_type"] == ["code"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exchange_code_returns_token():
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 200
|
|
||||||
mock_response.json = AsyncMock(return_value={"access_token": "tok123"})
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.post = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
token = await oauth.exchange_code(CFG_ON, "mycode", redirect_uri)
|
|
||||||
assert token == "tok123"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exchange_code_raises_on_error_status():
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 401
|
|
||||||
mock_response.text = AsyncMock(return_value="unauthorized")
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.post = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
with pytest.raises(oauth.OAuthError):
|
|
||||||
await oauth.exchange_code(CFG_ON, "badcode", redirect_uri)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_user_returns_profile():
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 200
|
|
||||||
mock_response.json = AsyncMock(return_value={
|
|
||||||
"login": "alice",
|
|
||||||
"full_name": "Alice Smith",
|
|
||||||
"avatar_url": "https://git.example.com/avatars/alice.png",
|
|
||||||
})
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.get = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
profile = await oauth.fetch_user(CFG_ON, "tok123")
|
|
||||||
assert profile == {
|
|
||||||
"login": "alice",
|
|
||||||
"full_name": "Alice Smith",
|
|
||||||
"avatar_url": "https://git.example.com/avatars/alice.png",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to confirm failure**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v -k "url or exchange or fetch"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `AttributeError: module 'hbd.server.oauth' has no attribute 'authorization_url'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the three functions**
|
|
||||||
|
|
||||||
Add to `hbd/server/oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
|
|
||||||
def authorization_url(config: dict, state: str, redirect_uri: str) -> str:
|
|
||||||
"""Return the Gitea OAuth2 authorization URL to redirect the browser to."""
|
|
||||||
g = _gitea_cfg(config)
|
|
||||||
params = urllib.parse.urlencode({
|
|
||||||
"client_id": g["client_id"],
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"response_type": "code",
|
|
||||||
"scope": "user:email",
|
|
||||||
"state": state,
|
|
||||||
})
|
|
||||||
return f"{g['url'].rstrip('/')}/login/oauth/authorize?{params}"
|
|
||||||
|
|
||||||
|
|
||||||
async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
|
|
||||||
"""Exchange an authorization *code* for a Gitea access token.
|
|
||||||
|
|
||||||
Returns the access token string. Raises OAuthError on any failure.
|
|
||||||
"""
|
|
||||||
g = _gitea_cfg(config)
|
|
||||||
url = f"{g['url'].rstrip('/')}/login/oauth/access_token"
|
|
||||||
payload = {
|
|
||||||
"client_id": g["client_id"],
|
|
||||||
"client_secret": g["client_secret"],
|
|
||||||
"code": code,
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
}
|
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.post(url, json=payload, headers={"Accept": "application/json"}) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
raise OAuthError(f"Token exchange failed ({resp.status}): {text}")
|
|
||||||
data = await resp.json()
|
|
||||||
except aiohttp.ClientError as exc:
|
|
||||||
raise OAuthError(f"Token exchange network error: {exc}") from exc
|
|
||||||
token = data.get("access_token")
|
|
||||||
if not token:
|
|
||||||
raise OAuthError(f"No access_token in response: {data}")
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_user(config: dict, token: str) -> dict:
|
|
||||||
"""Fetch the authenticated user's profile from Gitea.
|
|
||||||
|
|
||||||
Returns a dict with keys: login, full_name, avatar_url.
|
|
||||||
Raises OAuthError on any failure.
|
|
||||||
"""
|
|
||||||
g = _gitea_cfg(config)
|
|
||||||
url = f"{g['url'].rstrip('/')}/api/v1/user"
|
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.get(url, headers={"Authorization": f"token {token}"}) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
raise OAuthError(f"User fetch failed ({resp.status}): {text}")
|
|
||||||
data = await resp.json()
|
|
||||||
except aiohttp.ClientError as exc:
|
|
||||||
raise OAuthError(f"User fetch network error: {exc}") from exc
|
|
||||||
return {
|
|
||||||
"login": data.get("login", ""),
|
|
||||||
"full_name": data.get("full_name", ""),
|
|
||||||
"avatar_url": data.get("avatar_url", ""),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also add `import urllib.parse` at the top of `oauth.py` (alongside the existing imports).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run to confirm tests pass**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 17 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/oauth.py tests/test_oauth.py
|
|
||||||
git commit -m "feat: add authorization_url, exchange_code, fetch_user to oauth module"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: HTTP routes — redirect and callback
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/http.py`
|
|
||||||
|
|
||||||
`http.py` defines all handlers inside `async def start(...)`. The two new handlers go in the same block, just before the `app = web.Application()` line (~line 900). The import goes at the top of the file.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the import**
|
|
||||||
|
|
||||||
In `hbd/server/http.py`, add after the existing local imports (after `from . import users as users_mod`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from . import oauth as oauth_mod
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the two route handlers**
|
|
||||||
|
|
||||||
In `hbd/server/http.py`, add the two handlers immediately before the `app = web.Application()` line:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def oauth_gitea_redirect(request):
|
|
||||||
"""GET /login/oauth/gitea — kick off the Gitea OAuth2 flow."""
|
|
||||||
if not oauth_mod.is_enabled(config):
|
|
||||||
return web.Response(status=404, text="OAuth not configured")
|
|
||||||
state = oauth_mod.make_state()
|
|
||||||
redirect_uri = f"{request.url.origin()}/login/oauth/gitea/callback"
|
|
||||||
raise web.HTTPFound(oauth_mod.authorization_url(config, state, redirect_uri))
|
|
||||||
|
|
||||||
async def oauth_gitea_callback(request):
|
|
||||||
"""GET /login/oauth/gitea/callback — handle Gitea's redirect back."""
|
|
||||||
if not oauth_mod.is_enabled(config):
|
|
||||||
return web.Response(status=404, text="OAuth not configured")
|
|
||||||
code = request.rel_url.query.get("code", "")
|
|
||||||
state = request.rel_url.query.get("state", "")
|
|
||||||
if not code or not state:
|
|
||||||
return web.Response(status=400, text="Missing code or state")
|
|
||||||
if not oauth_mod.validate_state(state):
|
|
||||||
raise web.HTTPFound("/login?error=1")
|
|
||||||
redirect_uri = f"{request.url.origin()}/login/oauth/gitea/callback"
|
|
||||||
try:
|
|
||||||
token = await oauth_mod.exchange_code(config, code, redirect_uri)
|
|
||||||
profile = await oauth_mod.fetch_user(config, token)
|
|
||||||
except oauth_mod.OAuthError as exc:
|
|
||||||
logger.warning("OAuth error: %s", exc)
|
|
||||||
raise web.HTTPFound("/login?error=1")
|
|
||||||
user = users_mod.provision_oauth_user(
|
|
||||||
profile["login"],
|
|
||||||
profile["full_name"],
|
|
||||||
profile["avatar_url"],
|
|
||||||
)
|
|
||||||
session_token = users_mod.create_session(user.username)
|
|
||||||
resp = web.HTTPFound("/")
|
|
||||||
resp.set_cookie(
|
|
||||||
SESSION_COOKIE,
|
|
||||||
session_token,
|
|
||||||
max_age=users_mod.SESSION_TTL,
|
|
||||||
httponly=True,
|
|
||||||
samesite="Lax",
|
|
||||||
)
|
|
||||||
raise resp
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Register the routes**
|
|
||||||
|
|
||||||
In `hbd/server/http.py`, add to the route list after the existing auth routes (after `web.post("/api/0/auth/logout", api_logout)`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
web.get("/login/oauth/gitea", oauth_gitea_redirect),
|
|
||||||
web.get("/login/oauth/gitea/callback", oauth_gitea_callback),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual smoke test**
|
|
||||||
|
|
||||||
Start the server locally with OAuth configured in `~/.hb.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
oauth:
|
|
||||||
gitea:
|
|
||||||
url: https://your-gitea-instance.example.com
|
|
||||||
client_id: your-client-id
|
|
||||||
client_secret: your-client-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit `http://localhost:50004/login/oauth/gitea` — confirm you are redirected to Gitea's authorization page.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/http.py
|
|
||||||
git commit -m "feat: add Gitea OAuth2 redirect and callback routes"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Login page — "Sign in with Gitea" button
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/http.py` (update `login_page` handler, ~line 625)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace the login page HTML**
|
|
||||||
|
|
||||||
In `hbd/server/http.py`, find the `html = f"""` block inside `login_page` and replace it with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
gitea_button = ""
|
|
||||||
if oauth_mod.is_enabled(config):
|
|
||||||
gitea_url = _gitea_cfg_url(config)
|
|
||||||
gitea_button = f"""
|
|
||||||
<div class="divider">or</div>
|
|
||||||
<a href="/login/oauth/gitea" class="gitea-btn">
|
|
||||||
Sign in with Gitea
|
|
||||||
</a>"""
|
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Heartbeat — Login</title>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: sans-serif; background: #f5f5f5; display: flex;
|
|
||||||
justify-content: center; align-items: center; height: 100vh; margin: 0; }}
|
|
||||||
.box {{ background: #fff; padding: 2em 2.5em; border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,.15); min-width: 300px; }}
|
|
||||||
h2 {{ margin: 0 0 1.2em; color: #333; font-size: 1.4em; }}
|
|
||||||
label {{ display: block; margin-bottom: .3em; font-size: .9em; color: #555; }}
|
|
||||||
input {{ width: 100%; padding: .5em .7em; border: 1px solid #ccc;
|
|
||||||
border-radius: 4px; font-size: 1em; box-sizing: border-box; }}
|
|
||||||
button {{ margin-top: 1.2em; width: 100%; padding: .6em; background: #0066cc;
|
|
||||||
color: #fff; border: none; border-radius: 4px; font-size: 1em; cursor: pointer; }}
|
|
||||||
button:hover {{ background: #0055aa; }}
|
|
||||||
.error {{ color: #c00; font-size: .9em; margin-bottom: .8em; }}
|
|
||||||
.field {{ margin-bottom: .9em; }}
|
|
||||||
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
|
|
||||||
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
|
|
||||||
.gitea-btn {{ display: block; width: 100%; padding: .6em; background: #609926;
|
|
||||||
color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
|
|
||||||
text-decoration: none; box-sizing: border-box; }}
|
|
||||||
.gitea-btn:hover {{ background: #4e7d1e; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="box">
|
|
||||||
<h2>Heartbeat</h2>
|
|
||||||
{'<p class="error">Invalid username, password, or OAuth error.</p>' if error else ''}
|
|
||||||
<form method="post">
|
|
||||||
<div class="field"><label>Username</label><input name="username" autofocus></div>
|
|
||||||
<div class="field"><label>Password</label><input name="password" type="password"></div>
|
|
||||||
<button type="submit">Sign in</button>
|
|
||||||
</form>{gitea_button}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the `_gitea_cfg_url` helper**
|
|
||||||
|
|
||||||
Add this small helper in `hbd/server/http.py` just before the `login_page` handler (around line 600) so the template can read the Gitea display URL without importing internal oauth details:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _gitea_cfg_url(config: dict) -> str:
|
|
||||||
return config.get("oauth", {}).get("gitea", {}).get("url", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update the `login_page` handler's `error` logic to show the error when the `?error=1` query param is present (set by the callback on OAuth failure):
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def login_page(request):
|
|
||||||
"""GET /login — show login form; POST /login — process and redirect."""
|
|
||||||
if not users_mod.users_enabled():
|
|
||||||
raise web.HTTPFound("/")
|
|
||||||
|
|
||||||
error = ""
|
|
||||||
if request.method == "POST":
|
|
||||||
form = await request.post()
|
|
||||||
username = form.get("username", "")
|
|
||||||
password = form.get("password", "")
|
|
||||||
user = users_mod.authenticate(username, password)
|
|
||||||
if user:
|
|
||||||
token = users_mod.create_session(username)
|
|
||||||
redirect_to = request.rel_url.query.get("next", "/")
|
|
||||||
resp = web.HTTPFound(redirect_to)
|
|
||||||
resp.set_cookie(
|
|
||||||
SESSION_COOKIE,
|
|
||||||
token,
|
|
||||||
max_age=users_mod.SESSION_TTL,
|
|
||||||
httponly=True,
|
|
||||||
samesite="Lax",
|
|
||||||
)
|
|
||||||
raise resp
|
|
||||||
error = "Invalid username or password."
|
|
||||||
elif request.rel_url.query.get("error"):
|
|
||||||
error = "Sign-in failed. Please try again."
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Manual verification**
|
|
||||||
|
|
||||||
Start the server with OAuth configured. Visit `/login`. Confirm:
|
|
||||||
- The "Sign in with Gitea" button appears (green, below a divider)
|
|
||||||
- Clicking it redirects to Gitea
|
|
||||||
- After authorising on Gitea, you are redirected back and land on `/` with a valid session cookie
|
|
||||||
|
|
||||||
Without OAuth configured, confirm the button does not appear.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/http.py
|
|
||||||
git commit -m "feat: add Sign in with Gitea button to login page"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review Notes
|
|
||||||
|
|
||||||
- All 5 spec requirements covered: coexist ✓, auto-provision ✓, regular user ✓, any Gitea user ✓, config-driven ✓
|
|
||||||
- `exchange_code` signature in Task 4 matches usage in Task 5 (`config, code, redirect_uri`) ✓
|
|
||||||
- `fetch_user` returns `{login, full_name, avatar_url}` — matched in callback handler ✓
|
|
||||||
- `validate_state` removes state on use (replay protection) ✓
|
|
||||||
- `provision_oauth_user` skips empty strings so existing avatar/name aren't erased ✓
|
|
||||||
- `_gitea_cfg_url` is a plain `def`, not `async` — safe to call in template prep ✓
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,539 +0,0 @@
|
|||||||
# Host Overview Info Section — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add an always-visible info section to each host card on `/plugins`, showing owner, managers, agent version/type, last packet timestamp, and effective thresholds; move hbc_version/hbc_type out of the os_info accordion.
|
|
||||||
|
|
||||||
**Architecture:** A new `_build_host_info` module-level helper in `http.py` assembles the info dict from the host object and threshold_checker. A new `GET /api/0/hosts/{hostname}/info` closure inside `serve()` calls it and returns JSON. The `plugins.html` template adds a static placeholder div per host; JS fetches the endpoint on first card expand, caches the result, and renders it.
|
|
||||||
|
|
||||||
**Tech Stack:** Python/aiohttp (backend), Jinja2 (template), vanilla JS/HTML/CSS (frontend). Tests with pytest and unittest.mock.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: `_build_host_info` helper — tests first
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/test_http_host_info.py`
|
|
||||||
- Modify: `hbd/server/http.py` (add module-level helper after `_mask_config_for_api`, around line 128)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Create `tests/test_http_host_info.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Tests for _build_host_info helper in http.py."""
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
from hbd.server.http import _build_host_info
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeConn:
|
|
||||||
def __init__(self, lastbeat):
|
|
||||||
self.lastbeat = lastbeat
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeHost:
|
|
||||||
def __init__(self, name="myhost", owner=None, managers=None,
|
|
||||||
connections=None, os_data=None):
|
|
||||||
self.name = name
|
|
||||||
self.owner = owner
|
|
||||||
self.managers = managers or []
|
|
||||||
self.connections = connections or {}
|
|
||||||
self._os_data = os_data
|
|
||||||
|
|
||||||
def get_latest_plugin_data(self, plugin_name):
|
|
||||||
if plugin_name == "os_info" and self._os_data is not None:
|
|
||||||
return (1234567890.0, self._os_data)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_basic_fields():
|
|
||||||
host = _FakeHost(owner="alice", managers=["bob", "carol"])
|
|
||||||
result = _build_host_info(host)
|
|
||||||
assert result["owner"] == "alice"
|
|
||||||
assert result["managers"] == ["bob", "carol"]
|
|
||||||
assert result["hbc_version"] is None
|
|
||||||
assert result["hbc_type"] is None
|
|
||||||
assert result["last_packet"] is None
|
|
||||||
assert result["thresholds"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_no_owner():
|
|
||||||
host = _FakeHost()
|
|
||||||
result = _build_host_info(host)
|
|
||||||
assert result["owner"] is None
|
|
||||||
assert result["managers"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_reads_hbc_from_os_info():
|
|
||||||
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
|
|
||||||
result = _build_host_info(host)
|
|
||||||
assert result["hbc_version"] == "5.3.0"
|
|
||||||
assert result["hbc_type"] == "full"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_hbc_none_when_no_os_info():
|
|
||||||
host = _FakeHost(os_data=None)
|
|
||||||
result = _build_host_info(host)
|
|
||||||
assert result["hbc_version"] is None
|
|
||||||
assert result["hbc_type"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_last_packet_is_max_lastbeat():
|
|
||||||
host = _FakeHost(connections={
|
|
||||||
"IPv4": _FakeConn(1000.0),
|
|
||||||
"IPv6": _FakeConn(2000.0),
|
|
||||||
})
|
|
||||||
result = _build_host_info(host)
|
|
||||||
assert result["last_packet"] == 2000.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_last_packet_none_when_no_connections():
|
|
||||||
host = _FakeHost(connections={})
|
|
||||||
result = _build_host_info(host)
|
|
||||||
assert result["last_packet"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_thresholds_none_without_checker():
|
|
||||||
host = _FakeHost()
|
|
||||||
result = _build_host_info(host, threshold_checker=None)
|
|
||||||
assert result["thresholds"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_thresholds_sorted_by_metric():
|
|
||||||
from hbd.server.threshold import ThresholdConfig
|
|
||||||
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
|
|
||||||
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
|
|
||||||
|
|
||||||
checker = MagicMock()
|
|
||||||
checker.get_thresholds_for_host.return_value = {
|
|
||||||
"memory_monitor.memory_percent": tc_mem,
|
|
||||||
"cpu_monitor.cpu_percent": tc_cpu,
|
|
||||||
}
|
|
||||||
|
|
||||||
host = _FakeHost()
|
|
||||||
result = _build_host_info(host, threshold_checker=checker)
|
|
||||||
|
|
||||||
assert result["thresholds"] is not None
|
|
||||||
assert len(result["thresholds"]) == 2
|
|
||||||
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
|
|
||||||
assert result["thresholds"][0]["warning"] == 80.0
|
|
||||||
assert result["thresholds"][0]["critical"] == 95.0
|
|
||||||
assert result["thresholds"][0]["operator"] == ">"
|
|
||||||
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
|
|
||||||
checker = MagicMock()
|
|
||||||
checker.get_thresholds_for_host.return_value = {}
|
|
||||||
host = _FakeHost()
|
|
||||||
result = _build_host_info(host, threshold_checker=checker)
|
|
||||||
assert result["thresholds"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_host_info_threshold_null_warning_critical():
|
|
||||||
from hbd.server.threshold import ThresholdConfig
|
|
||||||
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
|
|
||||||
checker = MagicMock()
|
|
||||||
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
|
|
||||||
host = _FakeHost()
|
|
||||||
result = _build_host_info(host, threshold_checker=checker)
|
|
||||||
assert result["thresholds"][0]["warning"] is None
|
|
||||||
assert result["thresholds"][0]["critical"] == 500.0
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to confirm they fail**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/test_http_host_info.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `ImportError` or `AttributeError` — `_build_host_info` does not exist yet.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `_build_host_info` in `hbd/server/http.py`**
|
|
||||||
|
|
||||||
Insert after `_mask_config_for_api` (around line 128, before `def serve(`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _build_host_info(host, threshold_checker=None):
|
|
||||||
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
|
||||||
hbc_version = None
|
|
||||||
hbc_type = None
|
|
||||||
latest_os = host.get_latest_plugin_data("os_info")
|
|
||||||
if latest_os:
|
|
||||||
_, os_data = latest_os
|
|
||||||
hbc_version = os_data.get("hbc_version")
|
|
||||||
hbc_type = os_data.get("hbc_type")
|
|
||||||
|
|
||||||
last_packet = None
|
|
||||||
if host.connections:
|
|
||||||
last_packet = max(conn.lastbeat for conn in host.connections.values())
|
|
||||||
|
|
||||||
thresholds = None
|
|
||||||
if threshold_checker is not None:
|
|
||||||
raw = threshold_checker.get_thresholds_for_host(host.name)
|
|
||||||
thresholds = sorted(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"metric": tc.metric_path,
|
|
||||||
"warning": tc.warning,
|
|
||||||
"critical": tc.critical,
|
|
||||||
"operator": tc.operator.value,
|
|
||||||
}
|
|
||||||
for tc in raw.values()
|
|
||||||
],
|
|
||||||
key=lambda x: x["metric"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"owner": getattr(host, "owner", None),
|
|
||||||
"managers": list(getattr(host, "managers", [])),
|
|
||||||
"hbc_version": hbc_version,
|
|
||||||
"hbc_type": hbc_type,
|
|
||||||
"last_packet": last_packet,
|
|
||||||
"thresholds": thresholds,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to confirm they pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/test_http_host_info.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all 11 tests PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/test_http_host_info.py hbd/server/http.py
|
|
||||||
git commit -m "feat: add _build_host_info helper for host info endpoint"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: `api_host_info` route handler
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/http.py`
|
|
||||||
- Add `api_host_info` closure inside `serve()` (after `api_host_access_put`, around line 829)
|
|
||||||
- Register route (around line 1271)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `api_host_info` closure inside `serve()`**
|
|
||||||
|
|
||||||
Insert after `api_host_access_put` (after line 829, before the comment `# User profile page`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Host info endpoint
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def api_host_info(request):
|
|
||||||
"""GET /api/0/hosts/{hostname}/info"""
|
|
||||||
user, err = _require_auth(request)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
hostname = request.match_info.get("hostname")
|
|
||||||
if hostname not in hbdclass.Host.hosts:
|
|
||||||
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
|
||||||
host = hbdclass.Host.hosts[hostname]
|
|
||||||
if not _can_view_host(user, host):
|
|
||||||
return web.json_response({"error": "Forbidden"}, status=403)
|
|
||||||
return web.json_response(_build_host_info(host, threshold_checker=threshold_checker))
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Register the route**
|
|
||||||
|
|
||||||
In the route list (around line 1271, after the existing `/api/0/hosts/{hostname}/access` routes):
|
|
||||||
|
|
||||||
```python
|
|
||||||
web.get("/api/0/hosts/{hostname}/info", api_host_info),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify the full test suite still passes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/ -q
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all tests PASS (no regressions).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Smoke-test the endpoint manually** (if a dev server is running)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s http://localhost:50004/api/0/hosts/<hostname>/info | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: JSON with `owner`, `managers`, `hbc_version`, `hbc_type`, `last_packet`, `thresholds` keys.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/http.py
|
|
||||||
git commit -m "feat: add GET /api/0/hosts/{hostname}/info endpoint"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Info section HTML and CSS in `plugins.html`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/templates/plugins.html`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add CSS for the info section**
|
|
||||||
|
|
||||||
In the `<style>` block (find the closing `</style>` tag around line 391 and insert before it):
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* ── Host info section ──────────────────────────────────────────────────── */
|
|
||||||
.host-info-section {
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
.info-meta {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: max-content 1fr;
|
|
||||||
gap: 3px 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
|
|
||||||
.info-value { color: #222; }
|
|
||||||
.info-thresholds-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #555;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.info-note { color: #888; font-style: italic; }
|
|
||||||
.info-loading { color: #bbb; font-style: italic; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add info section placeholder to each host card**
|
|
||||||
|
|
||||||
Inside the host loop, at the very start of `.host-body` (before the `{% set plugin_order %}` line, around line 438):
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="host-body">
|
|
||||||
<div class="host-info-section" id="info-{{ host.name }}">
|
|
||||||
<div class="info-loading">Loading…</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
The existing `{% set plugin_order %}` line and everything after stays unchanged. Only add the two new lines between `<div class="host-body">` and `{% set plugin_order %}`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify the page still renders without JS errors**
|
|
||||||
|
|
||||||
Start the dev server and open `/plugins` in a browser. Expand any host card — you should see the "Loading…" italic line above the plugin accordions (it will not be replaced yet, that comes in Task 4).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/templates/plugins.html
|
|
||||||
git commit -m "feat: add host info section placeholder and CSS to plugins.html"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: JS — `infoCache`, `fetchHostInfo`, `renderInfoSection`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/templates/plugins.html` (JS `<script>` block)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `infoCache` constant**
|
|
||||||
|
|
||||||
After the `pluginCache` declaration (after `const pluginCache = {};`, around line 489), add:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
|
|
||||||
const infoCache = {};
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `fetchHostInfo` function**
|
|
||||||
|
|
||||||
After the existing `fetchPlugin` function (around line 522, before `fetchHostGlance`), add:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function fetchHostInfo(hostname) {
|
|
||||||
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
|
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
||||||
return r.json();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `renderInfoSection` function**
|
|
||||||
|
|
||||||
After `fetchHostInfo` (before `fetchHostGlance`), add:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function renderInfoSection(hostname, data) {
|
|
||||||
const el = document.getElementById(`info-${hostname}`);
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const owner = data.owner ? escHtml(data.owner) : '—';
|
|
||||||
const managers = data.managers && data.managers.length
|
|
||||||
? data.managers.map(escHtml).join(', ') : '—';
|
|
||||||
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
|
|
||||||
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
|
|
||||||
const lastPkt = data.last_packet
|
|
||||||
? new Date(data.last_packet * 1000).toLocaleString() : '—';
|
|
||||||
|
|
||||||
let html = `<div class="info-meta">
|
|
||||||
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
|
|
||||||
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
|
|
||||||
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
|
|
||||||
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
|
|
||||||
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
if (data.thresholds === null) {
|
|
||||||
html += `<div class="info-note">Threshold alerting not configured.</div>`;
|
|
||||||
} else if (data.thresholds.length === 0) {
|
|
||||||
html += `<div class="info-note">No thresholds defined.</div>`;
|
|
||||||
} else {
|
|
||||||
html += `<div class="info-thresholds-title">Effective Thresholds</div>
|
|
||||||
<table class="data-table"><thead><tr>
|
|
||||||
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
|
|
||||||
</tr></thead><tbody>`;
|
|
||||||
for (const t of data.thresholds) {
|
|
||||||
const w = t.warning !== null && t.warning !== undefined ? t.warning : '—';
|
|
||||||
const c = t.critical !== null && t.critical !== undefined ? t.critical : '—';
|
|
||||||
html += `<tr>
|
|
||||||
<td class="key">${escHtml(t.metric)}</td>
|
|
||||||
<td>${escHtml(t.operator)}</td>
|
|
||||||
<td>${w}</td>
|
|
||||||
<td>${c}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
html += `</tbody></table>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.innerHTML = html;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/templates/plugins.html
|
|
||||||
git commit -m "feat: add fetchHostInfo and renderInfoSection JS functions"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Wire `fetchHostInfo` into `toggleHost`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/templates/plugins.html` (the `toggleHost` function, around line 643)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace `toggleHost` with the updated version**
|
|
||||||
|
|
||||||
Find the existing `toggleHost` function:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function toggleHost(hostname) {
|
|
||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
|
||||||
const wasCollapsed = card.classList.contains('collapsed');
|
|
||||||
card.classList.toggle('collapsed');
|
|
||||||
if (wasCollapsed && !pluginCache[hostname]) {
|
|
||||||
fetchHostGlance(hostname);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function toggleHost(hostname) {
|
|
||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
|
||||||
const wasCollapsed = card.classList.contains('collapsed');
|
|
||||||
card.classList.toggle('collapsed');
|
|
||||||
if (wasCollapsed) {
|
|
||||||
if (!pluginCache[hostname]) {
|
|
||||||
fetchHostGlance(hostname);
|
|
||||||
}
|
|
||||||
if (!infoCache[hostname]) {
|
|
||||||
const infoEl = document.getElementById(`info-${hostname}`);
|
|
||||||
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
|
|
||||||
fetchHostInfo(hostname).then(data => {
|
|
||||||
infoCache[hostname] = data;
|
|
||||||
renderInfoSection(hostname, data);
|
|
||||||
}).catch(() => {
|
|
||||||
const el = document.getElementById(`info-${hostname}`);
|
|
||||||
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Test in browser**
|
|
||||||
|
|
||||||
Open `/plugins`, expand a host card. Verify:
|
|
||||||
- The info section appears above the plugin accordions.
|
|
||||||
- Owner, managers (or "—"), agent version, agent type, last packet render correctly.
|
|
||||||
- Threshold table renders (or the appropriate "not configured" / "none defined" message).
|
|
||||||
- Collapsing and re-expanding does not re-fetch (no second network request).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/templates/plugins.html
|
|
||||||
git commit -m "feat: fetch and render host info section on card expand"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Remove `hbc_version` and `hbc_type` from `renderOsInfoTable`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/templates/plugins.html` (the `renderOsInfoTable` function, around line 794)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update `renderOsInfoTable`**
|
|
||||||
|
|
||||||
Find the existing function:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function renderOsInfoTable(d) {
|
|
||||||
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
|
||||||
'processor','architecture','node','python_version',
|
|
||||||
'python_implementation','hbc_version',
|
|
||||||
'distro_name','distro_version','distro_id','distro_version_id'];
|
|
||||||
const shown = new Set(ORDER);
|
|
||||||
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function renderOsInfoTable(d) {
|
|
||||||
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
|
||||||
'processor','architecture','node','python_version',
|
|
||||||
'python_implementation',
|
|
||||||
'distro_name','distro_version','distro_id','distro_version_id'];
|
|
||||||
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
|
|
||||||
const shown = new Set(ORDER);
|
|
||||||
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify in browser**
|
|
||||||
|
|
||||||
Expand a host card, then expand the "Os Info" accordion. Confirm:
|
|
||||||
- `hbc_version` no longer appears in the os_info table.
|
|
||||||
- `hbc_type` no longer appears in the os_info table.
|
|
||||||
- Both values are shown correctly in the info section at the top.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run the full test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/ -q
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all tests PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/templates/plugins.html
|
|
||||||
git commit -m "feat: move hbc_version and hbc_type out of os_info into host info section"
|
|
||||||
```
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# Plugin Error Checking & Daemon Logging — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-04-25
|
|
||||||
**Scope:** hbc client — daemon mode logging, nagios_runner plugin robustness, PluginLoader messaging
|
|
||||||
**Files affected:** `hbd/client/main.py`, `hbd/client/plugins/nagios_runner.py`, `hbd/client/plugin.py`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Daemon Mode Logging
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
In `main()`, `logging.basicConfig()` is called before `daemonize()` (establishing a StreamHandler to stderr), then called again after `daemonize()`. The second call is a no-op — Python ignores `basicConfig()` when handlers are already configured. After daemonization, stderr is redirected to `/dev/null`, so all subsequent log output is silently discarded.
|
|
||||||
|
|
||||||
The existing `syslog.openlog()` / `syslog.syslog()` calls (lines 666–668) write a single startup message but do not integrate with the `logging` system, so plugin and connection log messages never reach syslog.
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
After `daemonize()`, explicitly reconfigure the root logger:
|
|
||||||
|
|
||||||
1. Remove all existing handlers (they now write to `/dev/null`).
|
|
||||||
2. Add `logging.handlers.SysLogHandler(address='/dev/log', facility=LOG_DAEMON)`.
|
|
||||||
3. Set formatter: `hbc[%(process)d]: %(name)s %(levelname)s: %(message)s`
|
|
||||||
4. Preserve the `log_level` already determined from `-v`/`-x` CLI flags.
|
|
||||||
|
|
||||||
Remove the redundant `syslog.openlog()` / `syslog.syslog()` calls — the logging system handles routing.
|
|
||||||
|
|
||||||
**Fallback:** If `/dev/log` does not exist (containers, some BSDs), fall back to `SysLogHandler(address=('localhost', 514))`. Log one warning (to stderr, before handlers are replaced) so the operator knows.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Nagios Runner Improvements
|
|
||||||
|
|
||||||
### 2a — Async Subprocess
|
|
||||||
`_run_nagios_plugin()` is declared `async def` but calls `subprocess.run()` synchronously, blocking the event loop for the full command duration.
|
|
||||||
|
|
||||||
**Fix:** Replace with `asyncio.create_subprocess_shell()` + `await proc.communicate()`. Enforce timeout with `asyncio.wait_for(..., timeout=self.timeout)` and catch `asyncio.TimeoutError`.
|
|
||||||
|
|
||||||
### 2b — Stderr Capture
|
|
||||||
Subprocess stderr is currently discarded (`capture_output=True` only captures stdout in the sync call; stderr content is lost).
|
|
||||||
|
|
||||||
**Fix:** Pass `stderr=asyncio.subprocess.PIPE` to `create_subprocess_shell`. After `communicate()`, if stdout is empty but stderr has content, use stderr as the output message. If both have content, append stderr to the output for visibility.
|
|
||||||
|
|
||||||
### 2c — Negative Return Codes
|
|
||||||
A negative `returncode` means the process was killed by a signal (SIGKILL, OOM, etc.). The current code treats these as-is, which may produce unexpected status values.
|
|
||||||
|
|
||||||
**Fix:** If `returncode < 0`, map to `NAGIOS_UNKNOWN` with message `"Process killed by signal {-returncode}"`.
|
|
||||||
|
|
||||||
### 2d — Command Path Validation at Init
|
|
||||||
`initialize()` currently only checks that the commands list is non-empty.
|
|
||||||
|
|
||||||
**Fix:** For each command entry during `initialize()`:
|
|
||||||
- Warn and skip the entry if `name` or `command` is missing.
|
|
||||||
- Extract the executable (first whitespace-delimited token of the command string).
|
|
||||||
- If the executable is an absolute path, check `os.path.isfile()` and `os.access(..., os.X_OK)`. Log a `WARNING` if either check fails.
|
|
||||||
- Commands with relative paths or shell builtins are not checked (they may be on PATH) — just noted.
|
|
||||||
- Validation warns only; all original entries in `self.commands` are retained and still attempted at collection time (where the existing missing-name/command guard already skips them). The plugin initializes successfully as long as the commands list is non-empty.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. PluginLoader Messaging
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
When `initialize()` returns `False`, the loader always logs:
|
|
||||||
> `WARNING: Plugin X failed initialization, skipping`
|
|
||||||
|
|
||||||
This is alarming when the real reason is simply "no commands configured". There is no API to distinguish "not configured" from "genuinely broken".
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
Add an optional `skip_reason` attribute to `Plugin.__init__()` (defaults to `None`).
|
|
||||||
|
|
||||||
In `PluginLoader.load_from_directory()`, after `initialize()` returns `False`:
|
|
||||||
- If `plugin.skip_reason` is set → `logger.info(f"Plugin {plugin.name} skipped: {plugin.skip_reason}")`
|
|
||||||
- If `plugin.skip_reason` is `None` → `logger.warning(f"Plugin {plugin.name} failed initialization, skipping")` (existing behaviour)
|
|
||||||
|
|
||||||
In `NagiosRunnerPlugin.initialize()`, when no commands are configured:
|
|
||||||
```python
|
|
||||||
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
|
|
||||||
return False
|
|
||||||
```
|
|
||||||
|
|
||||||
Genuine failures (exceptions) continue to go through the existing `except` block in the loader, logging at `ERROR` with traceback — unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
| Topic | Decision |
|
|
||||||
|---|---|
|
|
||||||
| Daemon log destination | syslog only (LOG_DAEMON facility) |
|
|
||||||
| Syslog fallback | localhost:514 UDP if `/dev/log` absent |
|
|
||||||
| Nagios result log level | INFO for all statuses (OK/WARNING/CRITICAL/UNKNOWN) |
|
|
||||||
| Invalid command handling at init | Warn and continue; still attempt at collection time |
|
|
||||||
| PluginLoader API change | `skip_reason` attribute on Plugin base class, checked by loader |
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
# Gitea OAuth2 Authentication — Design Spec
|
|
||||||
|
|
||||||
Date: 2026-05-08
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Add Gitea as an OAuth2 login provider alongside the existing username/password
|
|
||||||
authentication. Any user on the configured Gitea instance can sign in; their
|
|
||||||
local account is auto-provisioned on first login as a regular (non-admin) user.
|
|
||||||
Password login continues to work unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
A new optional `oauth.gitea` block in `~/.hb.yaml`. OAuth is disabled when the
|
|
||||||
block is absent or any of the three required keys is missing.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
oauth:
|
|
||||||
gitea:
|
|
||||||
url: https://git.example.com # Gitea base URL, no trailing slash
|
|
||||||
client_id: <gitea-app-client-id>
|
|
||||||
client_secret: <gitea-app-client-secret>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Gitea setup:** Create an OAuth2 application in Gitea under
|
|
||||||
*Settings → Applications → OAuth2*. Set the redirect URI to
|
|
||||||
`https://<hbd-host>/login/oauth/gitea/callback`.
|
|
||||||
|
|
||||||
`config.py` default:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"oauth": {},
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New module: `hbd/server/oauth.py`
|
|
||||||
|
|
||||||
Owns all OAuth2 logic. No new dependencies — uses `aiohttp.ClientSession`
|
|
||||||
already present in the codebase.
|
|
||||||
|
|
||||||
### CSRF state store
|
|
||||||
|
|
||||||
```python
|
|
||||||
# state -> expires (float)
|
|
||||||
_states: dict[str, float] = {}
|
|
||||||
STATE_TTL = 600 # 10 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
`_states` is an in-memory dict. Entries are created on redirect and deleted on
|
|
||||||
use or expiry. A purge runs on every new state generation.
|
|
||||||
|
|
||||||
### Public API
|
|
||||||
|
|
||||||
| Function | Description |
|
|
||||||
|---|---|
|
|
||||||
| `is_enabled(config)` | Returns `True` when url, client_id, and client_secret are all set |
|
|
||||||
| `make_state()` | Generates a random state token, stores it with TTL, returns it |
|
|
||||||
| `validate_state(state)` | Returns `True` and removes the state if valid and unexpired |
|
|
||||||
| `authorization_url(config, state, redirect_uri)` | Builds the Gitea `/login/oauth/authorize` redirect URL with `client_id`, `redirect_uri`, `scope=user:email`, `state` |
|
|
||||||
| `exchange_code(config, code, redirect_uri)` async | POSTs to Gitea `/login/oauth/access_token` with code and redirect_uri, returns the access token string or raises `OAuthError` |
|
|
||||||
| `fetch_user(config, token)` async | GETs Gitea `/api/v1/user` with Bearer token, returns `{"login", "full_name", "avatar_url"}` or raises `OAuthError` |
|
|
||||||
|
|
||||||
### Error handling
|
|
||||||
|
|
||||||
`OAuthError(message)` is a module-level exception. The callback route catches it
|
|
||||||
and renders the login page with an error message — identical to an invalid
|
|
||||||
password error in UX terms.
|
|
||||||
|
|
||||||
Network timeouts use a 10-second `aiohttp` timeout. Any non-2xx response from
|
|
||||||
Gitea raises `OAuthError`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Change: `hbd/server/users.py`
|
|
||||||
|
|
||||||
One new function added to the public API:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def provision_oauth_user(username: str, full_name: str, avatar: str) -> User:
|
|
||||||
```
|
|
||||||
|
|
||||||
- If the username does not exist in the live `users` dict, creates a `User`
|
|
||||||
with no `password_hash` (so password login is impossible for this account)
|
|
||||||
and inserts it.
|
|
||||||
- If the username already exists (e.g. was defined in config with a password),
|
|
||||||
updates `full_name` and `avatar` from the OAuth profile and returns the
|
|
||||||
existing user unchanged in all other respects (preserving admin flag,
|
|
||||||
notification channels, etc.).
|
|
||||||
- Logs a one-line INFO message on first provision.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changes: `hbd/server/http.py`
|
|
||||||
|
|
||||||
### Two new route handlers
|
|
||||||
|
|
||||||
**`GET /login/oauth/gitea`**
|
|
||||||
|
|
||||||
1. Checks `oauth.is_enabled(config)` — returns 404 if not.
|
|
||||||
2. Calls `oauth.make_state()`.
|
|
||||||
3. Constructs `redirect_uri` as `{request.url.origin()}/login/oauth/gitea/callback` using aiohttp's `request.url.origin()`.
|
|
||||||
4. Redirects the browser to `oauth.authorization_url(config, state, redirect_uri)`.
|
|
||||||
|
|
||||||
**`GET /login/oauth/gitea/callback`**
|
|
||||||
|
|
||||||
1. Reads `code` and `state` query params; returns 400 if either is missing.
|
|
||||||
2. Calls `oauth.validate_state(state)` — redirects to `/login` with error if
|
|
||||||
invalid (CSRF or replay protection).
|
|
||||||
3. Reconstructs the same `redirect_uri` as the redirect handler (required by OAuth2 spec for token exchange).
|
|
||||||
4. Calls `await oauth.exchange_code(config, code, redirect_uri)` to get the access token.
|
|
||||||
4. Calls `await oauth.fetch_user(config, token)` to get the Gitea user profile.
|
|
||||||
5. Calls `users_mod.provision_oauth_user(login, full_name, avatar_url)`.
|
|
||||||
6. Calls `users_mod.create_session(username)` to get a session token.
|
|
||||||
7. Sets `hbd_session` cookie (same flags as password login: httponly, Lax,
|
|
||||||
24h TTL).
|
|
||||||
8. Redirects to `/`.
|
|
||||||
9. Any `OAuthError` re-renders the login page with a generic error message.
|
|
||||||
|
|
||||||
### Login page change
|
|
||||||
|
|
||||||
When `oauth.is_enabled(config)` is `True`, the existing login form gains a
|
|
||||||
separator and a "Sign in with Gitea" link button pointing to
|
|
||||||
`/login/oauth/gitea`. The password form is always rendered regardless.
|
|
||||||
|
|
||||||
### Route registration
|
|
||||||
|
|
||||||
```python
|
|
||||||
web.get("/login/oauth/gitea", oauth_redirect),
|
|
||||||
web.get("/login/oauth/gitea/callback", oauth_callback),
|
|
||||||
```
|
|
||||||
|
|
||||||
Added alongside the existing `/login` and `/logout` routes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Browser hbd Gitea
|
|
||||||
| | |
|
|
||||||
|-- GET /login ----------->| |
|
|
||||||
|<- login page (+ button) -| |
|
|
||||||
| | |
|
|
||||||
|-- GET /login/oauth/gitea>| |
|
|
||||||
|<- 302 Gitea /authorize --| |
|
|
||||||
| | |
|
|
||||||
|-- GET /login/oauth/authorize ----------------------->|
|
|
||||||
|<- 302 /login/oauth/gitea/callback?code=..&state=.. --|
|
|
||||||
| | |
|
|
||||||
|-- GET /callback -------->| |
|
|
||||||
| |-- POST /access_token ---->|
|
|
||||||
| |<- {access_token} ---------|
|
|
||||||
| |-- GET /api/v1/user ------>|
|
|
||||||
| |<- {login, name, avatar} --|
|
|
||||||
| | provision_oauth_user() |
|
|
||||||
| | create_session() |
|
|
||||||
|<- 302 / (set cookie) ----| |
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `test_oauth_state`: `make_state` + `validate_state` happy path; expired state
|
|
||||||
returns False; replay (double-use) returns False.
|
|
||||||
- `test_provision_oauth_user_new`: new username creates User with no password.
|
|
||||||
- `test_provision_oauth_user_existing`: existing config user updates name/avatar,
|
|
||||||
preserves admin flag and notification_channels.
|
|
||||||
- `test_oauth_callback_invalid_state`: callback with bad state redirects to login.
|
|
||||||
- Integration: mock Gitea endpoints with `aiohttp_client` fixture; full
|
|
||||||
redirect → callback → session cookie flow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Restricting login to specific Gitea organisations or teams.
|
|
||||||
- Making OAuth users admin automatically.
|
|
||||||
- Multiple OAuth providers.
|
|
||||||
- Token refresh (Gitea access tokens are long-lived; the hbd session TTL governs
|
|
||||||
re-authentication).
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
# Config Editor — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-05-09
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Allow admins to edit the full `.hb.yaml` config through the Settings page UI, and allow regular users to manage their own notification channels and profile fields through the Profile page. The YAML file remains the single authoritative source; comments are preserved on every write.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
Browser (admin) Browser (user)
|
|
||||||
staged edits (JS state) form fields
|
|
||||||
│ │
|
|
||||||
│ POST /api/0/config │ PUT /api/0/users/me
|
|
||||||
▼ ▼
|
|
||||||
http.py handlers ────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
configio.py ←── ruamel.yaml (round-trip, comment-preserving)
|
|
||||||
│
|
|
||||||
├── backup .hb.yaml.bak.YYYYMMDD-HHMMSS (keep last 10)
|
|
||||||
├── write atomically (temp file → os.replace)
|
|
||||||
└── ReloadableConfig.reload()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Dependency
|
|
||||||
|
|
||||||
Add `ruamel.yaml>=0.18` to `[project.optional-dependencies] server` in `pyproject.toml`. `PyYAML` stays (used by the client and config loader for reads); `ruamel.yaml` is used only for write-back.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Module: `hbd/server/configio.py`
|
|
||||||
|
|
||||||
Single responsibility: all YAML read/write for `.hb.yaml`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
_write_lock = threading.Lock()
|
|
||||||
|
|
||||||
def read_roundtrip(path: str) -> CommentedMap:
|
|
||||||
"""Load .hb.yaml with ruamel.yaml, preserving comments and ordering."""
|
|
||||||
|
|
||||||
def write_config(path: str, data: CommentedMap) -> None:
|
|
||||||
"""Backup current file, then atomically write data.
|
|
||||||
|
|
||||||
Backup naming: {path}.bak.YYYYMMDD-HHMMSS
|
|
||||||
Rotation: keep the 10 most recent backups, delete older ones.
|
|
||||||
Atomic write: write to {path}.tmp, then os.replace({path}.tmp, path).
|
|
||||||
Acquires _write_lock for the full backup+write sequence.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def list_backups(path: str) -> list[str]:
|
|
||||||
"""Return backup paths sorted newest-first."""
|
|
||||||
|
|
||||||
def apply_structured_section(data: CommentedMap, section: str, values: dict) -> None:
|
|
||||||
"""Merge a dict of scalar/list values into data[section], key by key.
|
|
||||||
Preserves comments on unmodified keys.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def apply_yaml_section(data: CommentedMap, section: str, yaml_text: str) -> None:
|
|
||||||
"""Replace data[section] entirely by parsing yaml_text.
|
|
||||||
Used for YAML-editor sections (notification_channels, thresholds, hosts, dns).
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
All endpoints require authentication. Admin-only endpoints return 403 for non-admins.
|
|
||||||
|
|
||||||
| Method | Path | Auth | Purpose |
|
|
||||||
|--------|------|------|---------|
|
|
||||||
| GET | `/api/0/config` | admin | Full config as JSON (secrets masked) |
|
|
||||||
| POST | `/api/0/config` | admin | Publish staged changes to `.hb.yaml` |
|
|
||||||
| GET | `/api/0/config/section/{name}` | admin | Raw YAML text for one section (for YAML editors) |
|
|
||||||
| GET | `/api/0/config/backups` | admin | List of backup timestamps, newest first |
|
|
||||||
| POST | `/api/0/config/rollback` | admin | `{"backup": "…"}` → restore backup and reload |
|
|
||||||
| PUT | `/api/0/users/me` | any user | Update own `full_name`, `avatar`, `notification_channels`, `password` |
|
|
||||||
|
|
||||||
### `POST /api/0/config` payload
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server": { "hbd_port": 50004, "interval": 20, ... },
|
|
||||||
"users": { "alice": { "full_name": "Alice", "admin": true, ... }, ... },
|
|
||||||
"oauth": { "gitea": { "type": "gitea", "url": "...", ... }, ... },
|
|
||||||
"notification_channels": "<raw yaml text>",
|
|
||||||
"thresholds": "<raw yaml text>",
|
|
||||||
"hosts": "<raw yaml text>",
|
|
||||||
"dns": "<raw yaml text>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Only sections present in the payload are updated; omitted sections are left unchanged in the file.
|
|
||||||
|
|
||||||
**Section-to-key mapping:** Most config fields are top-level keys in `.hb.yaml` (not nested under a section key). The API uses logical section names that map to specific top-level keys:
|
|
||||||
|
|
||||||
| Logical section | Top-level YAML keys covered |
|
|
||||||
|---|---|
|
|
||||||
| `server` | `hbd_port`, `hbd_host`, `ws_port`, `wss_port`, `hb_port`, `interval`, `grace`, `base_url`, `threshold_renotify_interval`, `logfile`, `pidfile`, `pickfile`, `journal_enabled`, `journal_dir`, `journal_max_size`, `journal_max_backups`, `default_owner` |
|
|
||||||
| `users` | `users` (top-level dict) |
|
|
||||||
| `oauth` | `oauth` (top-level dict) |
|
|
||||||
| `notification_channels` | `notification_channels` (top-level dict, YAML text) |
|
|
||||||
| `thresholds` | `threshold_configs` (top-level dict if present, YAML text) |
|
|
||||||
| `hosts` | `hosts` (top-level dict, YAML text) |
|
|
||||||
| `dns` | `nsupdate_bin`, `dyndomains`, `dyndnshosts`, `drophosts` (YAML text of just these keys) |
|
|
||||||
|
|
||||||
`apply_structured_section` for `server` iterates the known key list and updates each present key individually, preserving comments on unchanged keys. `apply_yaml_section` for dict-valued sections (notification_channels, hosts, oauth) replaces the entire subtree. For `dns`, it replaces each of the four top-level keys listed.
|
|
||||||
|
|
||||||
### `PUT /api/0/users/me` payload
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"full_name": "Alice Smith",
|
|
||||||
"avatar": "/avatars/alice.png",
|
|
||||||
"notification_channels": ["pushover_ops", "matrix_alerts"],
|
|
||||||
"password": { "current": "oldpass", "new": "newpass" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All fields are optional. `password` change requires `current` to match; server re-hashes with PBKDF2-HMAC-SHA256 before writing. Both `full_name`/`avatar`/`notification_channels` and password can be sent in one request or separately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings Page Changes (`/settings`)
|
|
||||||
|
|
||||||
### Section split
|
|
||||||
|
|
||||||
| Section | Edit mode | Notes |
|
|
||||||
|---------|-----------|-------|
|
|
||||||
| Server settings | Form | Scalar fields: ports, intervals, base_url, grace, renotify interval, log/pid/pickle paths, journal settings |
|
|
||||||
| Users | Form | CRUD list: add/edit/delete users; fields: username, full_name, avatar, admin toggle, notification_channels multiselect. Password field: leave blank to keep existing hash; enter a new plain-text password to replace it (server hashes before writing). New users require a password. |
|
|
||||||
| OAuth providers | Form | CRUD list: add/edit/delete providers; fields: name (slug), type, url, client_id, client_secret, label, logo |
|
|
||||||
| Notification channels | YAML editor | Too many provider-specific credential shapes for typed forms |
|
|
||||||
| Thresholds | YAML editor | Complex nested rules |
|
|
||||||
| Hosts | YAML editor | Complex per-host config |
|
|
||||||
| DNS / DynDNS | YAML editor | nsupdate settings, dyndomains, drophosts |
|
|
||||||
|
|
||||||
### Publish flow
|
|
||||||
|
|
||||||
1. Each section has a **"Stage changes"** button. Clicking it stores that section's current form/editor values in browser JS state. A banner appears: *"N pending changes — not yet saved to .hb.yaml"*.
|
|
||||||
2. **"Publish to .hb.yaml"** sends `POST /api/0/config` with all staged sections.
|
|
||||||
3. On success: banner clears, page reloads to show current saved state.
|
|
||||||
4. **"Discard all"** clears JS state and reloads from server without writing.
|
|
||||||
|
|
||||||
### Rollback UI
|
|
||||||
|
|
||||||
A "View backups / rollback" link at the bottom of the settings sidebar opens a modal listing available backups (timestamp + approximate age). Clicking a backup shows a confirmation prompt before calling `POST /api/0/config/rollback`.
|
|
||||||
|
|
||||||
### `settings.py` changes
|
|
||||||
|
|
||||||
- Set `"editable": True` on all fields that now have form inputs.
|
|
||||||
- The existing field descriptor structure (`key`, `type`, `label`, `value`, `sensitive`) is already designed for this — no structural changes needed.
|
|
||||||
- Add `"section_mode": "form" | "yaml"` per section, used by the template to render the appropriate editor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Profile Page Changes (`/profile`)
|
|
||||||
|
|
||||||
New editable fields alongside the existing read-only display:
|
|
||||||
|
|
||||||
**Identity card** (saves via `PUT /api/0/users/me`):
|
|
||||||
- Display name — text input, current `full_name`
|
|
||||||
- Avatar — text input, current `avatar` URL or path
|
|
||||||
- Save button → immediate write, no publish step
|
|
||||||
|
|
||||||
**Change password** (saves via `PUT /api/0/users/me`):
|
|
||||||
- Current password, new password inputs
|
|
||||||
- Save button → validates current password server-side, re-hashes new password, writes
|
|
||||||
|
|
||||||
**Notification channels** (saves via `PUT /api/0/users/me`):
|
|
||||||
- Checkbox list of all globally-defined channels (from `config["notification_channels"]`)
|
|
||||||
- Shows channel type and `min_level` as secondary text
|
|
||||||
- Pre-checked based on user's current `notification_channels` list
|
|
||||||
- Save button → writes user's channel list immediately
|
|
||||||
|
|
||||||
Host access list remains read-only (existing behaviour).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Write Safety
|
|
||||||
|
|
||||||
- `configio._write_lock` serializes all writes (admin publish and user self-service can race if multiple requests arrive simultaneously).
|
|
||||||
- All writes are atomic: temp file written in same directory as `.hb.yaml`, then `os.replace()`. A crash mid-write leaves the backup intact and the original file unchanged.
|
|
||||||
- If `.hb.yaml` cannot be written (permissions, disk full), the API returns `500` with an error message; no partial write occurs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Secrets Handling
|
|
||||||
|
|
||||||
- `GET /api/0/config` masks sensitive fields (passwords, tokens, API keys) with `"•••"` — same logic as the existing read-only settings page.
|
|
||||||
- `GET /api/0/config/section/{name}` for YAML-editor sections returns the raw YAML text including real credential values, since the admin needs to edit them. This endpoint requires admin auth and must only be served over HTTPS in production.
|
|
||||||
- Secrets in backups are unmasked (they are copies of the real file). Backup directory should have the same file permissions as `.hb.yaml` itself.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Conflict detection if `.hb.yaml` is modified externally between page load and publish (the last write wins; the previous state is always recoverable from a backup)
|
|
||||||
- Multi-admin concurrent edit awareness
|
|
||||||
- Config validation UI beyond what the server returns as errors
|
|
||||||
- Diff view before publish
|
|
||||||
- Audit log of who published what (beyond the event log entry already added for login/logout)
|
|
||||||
- Per-host threshold editing via UI (thresholds section uses YAML editor)
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# Multi-Provider OAuth2 — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-05-09
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Allow multiple OAuth2 providers to be configured simultaneously. All enabled providers appear as login buttons on the login panel. Supported provider types: Gitea, GitHub, Nextcloud. Existing single-Gitea configs continue to work without changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Config Format
|
|
||||||
|
|
||||||
Each entry in the `oauth` dict is a named provider instance. The dict key becomes the route slug.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
oauth:
|
|
||||||
work-gitea: # /login/oauth/work-gitea
|
|
||||||
type: gitea # optional — defaults to "gitea" when absent (backward compat)
|
|
||||||
url: https://git.example.com
|
|
||||||
client_id: xxx
|
|
||||||
client_secret: yyy
|
|
||||||
label: "Work Gitea" # optional display name; falls back to provider default
|
|
||||||
logo: https://… # optional logo URL for button
|
|
||||||
github:
|
|
||||||
type: github # no url needed — fixed SaaS endpoints
|
|
||||||
client_id: xxx
|
|
||||||
client_secret: yyy
|
|
||||||
nextcloud:
|
|
||||||
type: nextcloud
|
|
||||||
url: https://cloud.example.com
|
|
||||||
client_id: xxx
|
|
||||||
client_secret: yyy
|
|
||||||
```
|
|
||||||
|
|
||||||
**Backward compatibility:** The existing `oauth.gitea.{url,client_id,client_secret}` config (no `type` field) is treated as `type: gitea`. No migration required.
|
|
||||||
|
|
||||||
**Validation:** Entries missing `client_id`, `client_secret`, or `url` (when the provider type requires it) are skipped with a warning log. This prevents a misconfigured entry from disabling all OAuth.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Provider Registry (`oauth.py`)
|
|
||||||
|
|
||||||
A `PROVIDER_DEFS` dict holds static knowledge about each supported provider type:
|
|
||||||
|
|
||||||
| | gitea | github | nextcloud |
|
|
||||||
|---|---|---|---|
|
|
||||||
| authorize URL | `{url}/login/oauth/authorize` | `https://github.com/login/oauth/authorize` | `{url}/apps/oauth2/authorize` |
|
|
||||||
| token URL | `{url}/login/oauth/access_token` | `https://github.com/login/oauth/access_token` | `{url}/apps/oauth2/api/v1/token` |
|
|
||||||
| profile URL | `{url}/api/v1/user` | `https://api.github.com/user` | `{url}/ocs/v2.php/cloud/user?format=json` |
|
|
||||||
| scope | `user:email` | `read:user` | *(empty)* |
|
|
||||||
| username field | `login` | `login` | nested: `ocs.data.id` |
|
|
||||||
| display name field | `full_name` | `name` | nested: `ocs.data.display-name` |
|
|
||||||
| avatar field | `avatar_url` | `avatar_url` | *(absent — left empty)* |
|
|
||||||
| requires `url` | yes | no | yes |
|
|
||||||
| default label | `Gitea` | `GitHub` | `Nextcloud` |
|
|
||||||
|
|
||||||
Nextcloud's profile response is nested (`ocs → data`). The registry entry includes a `profile_data_path: ["ocs", "data"]` that is navigated before field extraction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New / Changed API in `oauth.py`
|
|
||||||
|
|
||||||
### `ResolvedProvider` (new dataclass)
|
|
||||||
|
|
||||||
All endpoint URLs are pre-computed strings (no more template substitution at call time):
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class ResolvedProvider:
|
|
||||||
name: str # route slug (dict key)
|
|
||||||
type: str # "gitea" | "github" | "nextcloud"
|
|
||||||
label: str # display name for login button
|
|
||||||
logo: str # URL or ""
|
|
||||||
authorize_url: str
|
|
||||||
token_url: str
|
|
||||||
profile_url: str
|
|
||||||
scope: str
|
|
||||||
client_id: str
|
|
||||||
client_secret: str
|
|
||||||
field_map: dict # {"username": "<provider_field>", "full_name": ..., "avatar": ...}
|
|
||||||
profile_data_path: list[str] # e.g. ["ocs", "data"] or []
|
|
||||||
```
|
|
||||||
|
|
||||||
### `get_providers(config) → list[ResolvedProvider]` (new)
|
|
||||||
|
|
||||||
Iterates `config.get("oauth", {})`, resolves each valid entry against `PROVIDER_DEFS`, skips invalid entries. Returns providers in config declaration order (determines button order on login page).
|
|
||||||
|
|
||||||
### `build_auth_url(provider, state, redirect_uri)` (updated signature)
|
|
||||||
|
|
||||||
Takes a `ResolvedProvider`. Uses `provider.authorize_url`, `provider.scope`, `provider.client_id`.
|
|
||||||
|
|
||||||
### `exchange_code(provider, code, redirect_uri)` (updated signature)
|
|
||||||
|
|
||||||
Takes a `ResolvedProvider`. Sets `Accept: application/json` on all token requests (required for GitHub, harmless for others).
|
|
||||||
|
|
||||||
### `fetch_user(provider, access_token)` (updated signature)
|
|
||||||
|
|
||||||
Takes a `ResolvedProvider`. After fetching the profile JSON, navigates `provider.profile_data_path` before applying `provider.field_map`. Missing fields (e.g., Nextcloud avatar) are mapped to `""`.
|
|
||||||
|
|
||||||
### `is_enabled(config)` (updated)
|
|
||||||
|
|
||||||
Returns `True` if `get_providers(config)` returns at least one provider.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routes (`http.py`)
|
|
||||||
|
|
||||||
Replace the two hardcoded Gitea routes with generic ones:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /login/oauth/{name} initiate OAuth flow
|
|
||||||
GET /login/oauth/{name}/callback receive code, provision user, set session
|
|
||||||
```
|
|
||||||
|
|
||||||
Both handlers resolve `{name}` via `get_providers(config)`. If the name is not found, return 404. Existing `/login/oauth/gitea` URLs continue to work as long as the config has a `gitea` key.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Login Page (`http.py`)
|
|
||||||
|
|
||||||
The "or" divider appears once if any providers are configured. Below it, one button per provider stacks vertically. Button appearance mirrors the current Gitea button (same CSS class, optional logo img). Button `href` is `/login/oauth/{provider.name}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tests (`tests/test_oauth.py`)
|
|
||||||
|
|
||||||
**Updated:** Existing tests for `build_auth_url`, `exchange_code`, `fetch_user`, `is_enabled` ported to new `ResolvedProvider`-based signatures.
|
|
||||||
|
|
||||||
**New:**
|
|
||||||
- `get_providers()` with old single-Gitea config (no `type`) → one provider, backward compat confirmed
|
|
||||||
- `get_providers()` with Gitea + GitHub + Nextcloud → correct count, types, and labels
|
|
||||||
- `get_providers()` skips entry missing `client_id` or `client_secret`
|
|
||||||
- `get_providers()` skips Gitea/Nextcloud entry missing `url`
|
|
||||||
- `get_providers()` skips entry with unknown `type` (logs warning)
|
|
||||||
- `build_auth_url` for each provider type → correct authorize URL
|
|
||||||
- `exchange_code` for GitHub → `Accept: application/json` header present
|
|
||||||
- `fetch_user` for Nextcloud → `ocs.data` navigation, missing avatar handled as `""`
|
|
||||||
- Login page HTML → one button per provider; no buttons when `oauth` is empty
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Generic/custom provider with user-specified endpoints
|
|
||||||
- OIDC / token introspection
|
|
||||||
- Restricting login to specific GitHub orgs or Nextcloud groups
|
|
||||||
- Automatic admin promotion from OAuth
|
|
||||||
- Token refresh
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# Host Overview Info Section
|
|
||||||
|
|
||||||
**Date:** 2026-05-10
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add an always-visible info section to each host card on the Host Overview (`/plugins`) page. The section shows owner, managers, agent version/type, last packet timestamp, and the host's effective alert thresholds. The fields `hbc_version` and `hbc_type` are moved out of the `os_info` plugin accordion into this section.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend: New API Endpoint
|
|
||||||
|
|
||||||
**Route:** `GET /api/0/hosts/{hostname}/info`
|
|
||||||
|
|
||||||
**Auth:** Same as other per-host endpoints (`_can_view_host`).
|
|
||||||
|
|
||||||
**Response schema:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"owner": "alice",
|
|
||||||
"managers": ["bob", "carol"],
|
|
||||||
"hbc_version": "5.3.0",
|
|
||||||
"hbc_type": "full",
|
|
||||||
"last_packet": 1746894000.0,
|
|
||||||
"thresholds": [
|
|
||||||
{
|
|
||||||
"metric": "cpu_monitor.cpu_percent",
|
|
||||||
"warning": 80.0,
|
|
||||||
"critical": 95.0,
|
|
||||||
"operator": ">"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Field details:**
|
|
||||||
|
|
||||||
- `owner` — `host.owner`, or `null` if unset.
|
|
||||||
- `managers` — `host.managers` list (may be empty).
|
|
||||||
- `hbc_version` — from `host.get_latest_plugin_data("os_info")`, key `hbc_version`; `null` if no os_info data.
|
|
||||||
- `hbc_type` — same source, key `hbc_type`; `null` if unavailable.
|
|
||||||
- `last_packet` — `max(conn.lastbeat for conn in host.connections.values())`, or `null` if no connections.
|
|
||||||
- `thresholds` — list derived from `threshold_checker.get_thresholds_for_host(hostname)`, sorted by `metric` ascending. Each entry includes `metric`, `warning` (null if unset), `critical` (null if unset), `operator`. Returns `null` (not `[]`) if no `threshold_checker` is configured, so the frontend can distinguish "not configured" from "configured but empty".
|
|
||||||
|
|
||||||
**Location:** `hbd/server/http.py`, added alongside the other `api_host_*` functions. Registered as `web.get("/api/0/hosts/{hostname}/info", api_host_info)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend: Info Section
|
|
||||||
|
|
||||||
### HTML structure
|
|
||||||
|
|
||||||
Inserted as the first child of `.host-body`, before the plugin accordions. It is not a collapsible accordion — it is always visible when the host card is expanded.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="host-info-section" id="info-{hostname}">
|
|
||||||
<div class="loading">Loading…</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fetch lifecycle
|
|
||||||
|
|
||||||
- Fetched once per host on the first expansion of the host card (same trigger as the glance/plugin data).
|
|
||||||
- Result cached in a new per-host `infoCache` object (parallel to `pluginCache`).
|
|
||||||
- On subsequent expansions the cached data is rendered immediately without a new request.
|
|
||||||
|
|
||||||
### Rendered layout
|
|
||||||
|
|
||||||
Two logical areas rendered client-side from the JSON:
|
|
||||||
|
|
||||||
**Meta row** — a CSS-grid or simple `<dl>` showing:
|
|
||||||
|
|
||||||
| Label | Value |
|
|
||||||
|---------------|------------------------------|
|
|
||||||
| Owner | alice (or "—" if null) |
|
|
||||||
| Managers | bob, carol (or "—" if empty) |
|
|
||||||
| Agent Version | 5.3.0 (or "—") |
|
|
||||||
| Agent Type | full (or "—") |
|
|
||||||
| Last Packet | localized datetime string (or "—") |
|
|
||||||
|
|
||||||
**Threshold table** — rendered with the existing `data-table` CSS class:
|
|
||||||
|
|
||||||
| Metric | Operator | Warning | Critical |
|
|
||||||
|--------|----------|---------|----------|
|
|
||||||
| cpu_monitor.cpu_percent | > | 80 | 95 |
|
|
||||||
| … | … | … | … |
|
|
||||||
|
|
||||||
- If `thresholds` is `null`: show "Threshold alerting not configured."
|
|
||||||
- If `thresholds` is `[]`: show "No thresholds defined."
|
|
||||||
- Numeric threshold values rendered as-is (no units); `null` warning/critical shown as "—".
|
|
||||||
|
|
||||||
### CSS
|
|
||||||
|
|
||||||
New `.host-info-section` styles added in the `<style>` block of `plugins.html`. The section gets a subtle background (e.g. `#fafafa`) and a bottom border to separate it visually from the plugin accordions below. The meta row uses a two-column grid layout for compactness.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changes to `renderOsInfoTable()`
|
|
||||||
|
|
||||||
- Remove `hbc_version` from the `ORDER` array.
|
|
||||||
- Add `hbc_type` to the `SKIP_FIELDS` set (or the local `shown` set) so it is excluded from the os_info table.
|
|
||||||
|
|
||||||
Both fields will now appear only in the info section.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Flow Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
User expands host card
|
|
||||||
→ toggleHost()
|
|
||||||
→ fetchGlanceData(hostname) [existing, unchanged]
|
|
||||||
→ fetchInfoData(hostname) [new]
|
|
||||||
GET /api/0/hosts/{hostname}/info
|
|
||||||
→ renderInfoSection(hostname, data)
|
|
||||||
→ writes into #info-{hostname}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- If the info fetch fails (non-200), show a one-line error message in the info section ("Could not load host info.").
|
|
||||||
- If `hbc_version`/`hbc_type` are null (host has never sent os_info), display "—".
|
|
||||||
- If `last_packet` is null (no connections recorded), display "—".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Editing owner/managers from this section (covered by existing profile/access UI).
|
|
||||||
- Editing thresholds from this section.
|
|
||||||
- Monitors list (not shown — monitors are operational, not informational in this context).
|
|
||||||
Reference in New Issue
Block a user