Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e3099fc6d | |||
| c9f15a3f1c | |||
| 6e396ad760 | |||
| 2800de0b4a | |||
| 15f7e6a64d | |||
| 9768d13b88 | |||
| 8640d731aa | |||
| de81751e59 | |||
| 60c692cefc | |||
| 9a0baf3c78 | |||
| 55bdb9593a | |||
| 2009626fb4 | |||
| 18769afd37 | |||
| 31db5cf35e | |||
| 326f53f23d | |||
| 4f9bc8c868 | |||
| 259b4a3594 | |||
| 8646f68957 | |||
| a4a6c1e3d9 | |||
| 0e8250362e | |||
| 2f5da9fc5e | |||
| 87aeec5999 | |||
| f24500a6b5 | |||
| a7bb183222 | |||
| 8207cd7b5f | |||
| 11f1eefa8c | |||
| 62f496e9f8 | |||
| aef9e7769b | |||
| 58c2b9d996 | |||
| 2e8bcb630d | |||
| 338711181b | |||
| 43487f17e7 | |||
| 40205bf5c7 | |||
| b95f1a5bb7 | |||
| 12f7eb722b | |||
| 217bba1b76 | |||
| 967e05ed74 | |||
| c20245b0ab | |||
| b9db0c552e | |||
| 05045bafa2 | |||
| 39f1b5de30 | |||
| b06de6fdd3 | |||
| 940d0af35e | |||
| d6d31aa2e3 | |||
| 76edfe7577 | |||
| d190029728 | |||
| b8307e7a9d | |||
| a2fdf091f5 | |||
| 1914e6f28e | |||
| 82cbce9615 | |||
| dbb779b013 | |||
| ca908ee967 | |||
| 73c697b6c5 | |||
| 3e2357380b | |||
| cc4a103bae | |||
| 53fb10fdf5 | |||
| 2df2ad18c9 | |||
| b81a0d2a6c | |||
| 1a19088cfe | |||
| 172f6e950f | |||
| 4349ae217a | |||
| b3aa7b585f | |||
| 88a3c09b51 | |||
| 0504402a8a | |||
| ca58c18802 | |||
| 1ddc4b8132 | |||
| 5e1720ed32 | |||
| 77f127fe60 | |||
| 54fbd8d73d | |||
| 7ab17e26e2 | |||
| 28f5fa951c | |||
| 37f1c58969 | |||
| f006077a71 | |||
| d9fc8d632f | |||
| f640574e4f | |||
| 9a19424279 | |||
| ca8ba84e65 | |||
| f3d08d1c9e | |||
| 1e4263b793 | |||
| e931acb9f5 | |||
| 018409e71d | |||
| 1824f637b4 | |||
| a534c06b26 | |||
| d7b5c97a4e | |||
| ae447ac4a6 | |||
| d44ce3d124 | |||
| b1985d0eb2 | |||
| de778f680f | |||
| d7b368c7c6 | |||
| e790663f9f | |||
| 475319e248 | |||
| ca5ef384a8 | |||
| c93dbdc0f4 | |||
| 3a546a1e5c | |||
| 74c89d098c | |||
| 3301dbfe34 | |||
| d00d903e7d | |||
| babb5d61aa | |||
| 11d1c718b3 | |||
| a99b6b54c7 | |||
| 8da3d550eb | |||
| a76d0fc840 | |||
| 94cbb31c48 | |||
| ae60844a8a | |||
| 49fa310361 | |||
| 28e2180f7b | |||
| ce0590f015 | |||
| f50acca509 | |||
| 72fc82b91f | |||
| 46f8c32c0b | |||
| 691f62aa69 | |||
| cffc9805f9 | |||
| 917d6a401b | |||
| 2bd3a9beb6 | |||
| 5523c60866 | |||
| ab37ac7194 | |||
| f811a19d80 | |||
| 6239825f43 | |||
| b56245bb23 | |||
| 331c4e804d | |||
| 9fd945a481 | |||
| 26df08eeff | |||
| 5819dd6b25 | |||
| 6fb67f8615 | |||
| e70ae6f176 | |||
| a77f6d380c | |||
| 6aae2a1dab | |||
| 85ee0e1040 | |||
| c4f09e9ced | |||
| 64710fd4cd | |||
| 1f5e7465a3 | |||
| b290b21e23 | |||
| 65c4267847 | |||
| 462a445235 | |||
| 368e178f93 | |||
| 6905bf266a | |||
| b6dcce4f35 | |||
| e6436fc236 | |||
| c5ce41762e | |||
| 26ca0c095f | |||
| 1eecd67594 | |||
| caf3c2c0ac | |||
| 9af4006097 | |||
| ddf7067d13 | |||
| 505353a8a8 | |||
| 0402d33c71 | |||
| 7d8ca5d8db | |||
| 56037a036d | |||
| 65ceb31d8d | |||
| 1c9b6c1ca9 | |||
| d7e6b478e1 | |||
| 535dbda47d | |||
| c9567dddae | |||
| b5963badd6 | |||
| a76a39b4a0 | |||
| 94e1597978 | |||
| c9c2ed772f | |||
| aeb78dcb8e | |||
| 77b337e4dd | |||
| 293461f3f6 | |||
| c70a4807dc | |||
| 1a470e7cfa | |||
| 990c658e65 | |||
| b78d6ac0fe | |||
| afd5060f59 | |||
| f61f7aebc2 | |||
| 5c382d2b8d | |||
| 35bba451f5 | |||
| 80edfba0c0 | |||
| 6bc8de192e | |||
| 2d8166d04a | |||
| ab33d81b30 | |||
| 2c0328f36d | |||
| fb8e27825d | |||
| 1366c69cdc | |||
| d0c8c186f4 | |||
| 19f7c8312e | |||
| 24b0e362fb | |||
| 3a030548c0 | |||
| 094cb7ed9d | |||
| 0199ca4693 | |||
| 75344ebbbd | |||
| 7f049a4e26 | |||
| 6559f5462c | |||
| 6556d35f97 | |||
| dec96a0da6 | |||
| 8d3de01117 | |||
| 5bedf026b1 | |||
| daf5277507 | |||
| ee3b72878f | |||
| 6217f7a124 | |||
| 2468386f24 | |||
| 2015195112 | |||
| 3426185383 | |||
| 9eedbafe97 | |||
| a5f31c5cb5 | |||
| 2f72cf0118 | |||
| c56e77c2c1 | |||
| e9aa7a6f8b | |||
| a75a8a4087 | |||
| ba27d2e300 | |||
| 381e37efce | |||
| 97dfc08f4d | |||
| d281ac5a70 | |||
| 812bbf8555 | |||
| e6b7a1aa27 | |||
| 90f47ad018 | |||
| cc458e8972 | |||
| 79bf00abfd | |||
| d77277857f | |||
| 3232239a85 | |||
| 014781de5e | |||
| 68b1c65384 | |||
| e8bb553349 | |||
| e4ecb8723f | |||
| 5edbaacf81 | |||
| 8421f472f2 | |||
| 51f9bdc2b5 | |||
| 02bc42fbf0 | |||
| 832a8b0bda |
@@ -24,16 +24,23 @@ jobs:
|
|||||||
|
|
||||||
- name: Install build tools
|
- name: Install build tools
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python3 -m pip install --upgrade pip
|
||||||
# pip install build twine
|
python3 -m pip install build twine
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python3 -m build
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: get_version
|
id: get_version
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload to Gitea PyPI registry
|
||||||
|
env:
|
||||||
|
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
python3 -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/*
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: actions/gitea-release-action@v1
|
uses: actions/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ dist/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
ssl/
|
ssl/
|
||||||
uv.lock
|
uv.lock
|
||||||
|
.hb.yaml
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
#name: "w02"
|
|
||||||
hb_port: 50003
|
|
||||||
hbd_host: ''
|
|
||||||
#logfile: "/home/andreas/public_html/messages/andreas"
|
|
||||||
logfile: "/home/andreas/logs/heartbeat/heartbeat.log"
|
|
||||||
#logfile: "/Users/andreas/public_html/messages/andreas"
|
|
||||||
logfmt: "msg"
|
|
||||||
grace: 40
|
|
||||||
interval: 10
|
|
||||||
|
|
||||||
# Notification Channels - Define notification providers centrally
|
|
||||||
# Each channel has a type (pushover, email, signal, mattermost) and type-specific configuration
|
|
||||||
notification_channels:
|
|
||||||
|
|
||||||
pushover_standard:
|
|
||||||
type: pushover
|
|
||||||
token: ac7NLX2rPjXFareeDgLpXNoDf4iFmf
|
|
||||||
user: uDhH33UjQQDYtNzJb1ThRiWb9ingGK
|
|
||||||
|
|
||||||
signal_andreas:
|
|
||||||
type: signal
|
|
||||||
cli_path: /usr/local/bin/signal-cli
|
|
||||||
user: +14168226179
|
|
||||||
recipient: +14168226179
|
|
||||||
|
|
||||||
email_andreas:
|
|
||||||
type: email
|
|
||||||
recipients: [aew.hbd.notify@wrede.ca]
|
|
||||||
sender: aew.hbd@wrede.ca
|
|
||||||
smtp_server: smtp.fastmail.com
|
|
||||||
smtp_port: 587
|
|
||||||
smtp_user: andreas@wrede.ca
|
|
||||||
smtp_password: pvtvefyp5gbhnch2
|
|
||||||
|
|
||||||
# Example additional channels (commented out)
|
|
||||||
# pushover_urgent:
|
|
||||||
# type: pushover
|
|
||||||
# token: your-app-token
|
|
||||||
# user: your-user-key
|
|
||||||
#
|
|
||||||
mattermost_devops:
|
|
||||||
type: mattermost
|
|
||||||
host: mattermost.example.com
|
|
||||||
token: webhook-token
|
|
||||||
channel: devops-alerts
|
|
||||||
username: heartbeat-bot
|
|
||||||
icon: https://example.com/heartbeat-icon.png
|
|
||||||
|
|
||||||
# Default notification channels (used if host doesn't specify channels)
|
|
||||||
default_notification_channels: [pushover_standard]
|
|
||||||
|
|
||||||
# Host definitions - combines threshold mapping, watch status, DNS updates, and notifications
|
|
||||||
hosts:
|
|
||||||
wentworth:
|
|
||||||
threshold_config: default
|
|
||||||
watch: true
|
|
||||||
notification_channels: [pushover_standard]
|
|
||||||
dyndns: false
|
|
||||||
|
|
||||||
y:
|
|
||||||
threshold_config: default
|
|
||||||
watch: true
|
|
||||||
notification_channels: [pushover_standard]
|
|
||||||
dyndns: false
|
|
||||||
|
|
||||||
winter:
|
|
||||||
threshold_config: default
|
|
||||||
watch: true
|
|
||||||
notification_channels: [pushover_standard]
|
|
||||||
dyndns: false
|
|
||||||
|
|
||||||
wally:
|
|
||||||
threshold_config: freebsd_server
|
|
||||||
watch: false
|
|
||||||
notification_channels: [pushover_standard]
|
|
||||||
dyndns: false
|
|
||||||
|
|
||||||
eris:
|
|
||||||
threshold_config: truenas_server
|
|
||||||
watch: false
|
|
||||||
notification_channels: [pushover_standard]
|
|
||||||
dyndns: false
|
|
||||||
|
|
||||||
haschloss:
|
|
||||||
threshold_config: default
|
|
||||||
watch: false
|
|
||||||
dyndns: true
|
|
||||||
|
|
||||||
wayback:
|
|
||||||
threshold_config: default
|
|
||||||
watch: false
|
|
||||||
notification_channels: [pushover_standard]
|
|
||||||
dyndns: true
|
|
||||||
|
|
||||||
wertvoll:
|
|
||||||
threshold_config: default
|
|
||||||
watch: false
|
|
||||||
notification_channels: [pushover_standard]
|
|
||||||
dyndns: true
|
|
||||||
|
|
||||||
weekend:
|
|
||||||
threshold_config: freebsd_server
|
|
||||||
watch: false
|
|
||||||
notification_channels: [pushover_standard]
|
|
||||||
dyndns: true
|
|
||||||
|
|
||||||
cotgate:
|
|
||||||
threshold_config: default
|
|
||||||
watch: false
|
|
||||||
dyndns: true
|
|
||||||
|
|
||||||
rvgate:
|
|
||||||
threshold_config: default
|
|
||||||
watch: false
|
|
||||||
dyndns: true
|
|
||||||
|
|
||||||
draper:
|
|
||||||
threshold_config: default
|
|
||||||
watch: false
|
|
||||||
notification_channels: [pushover_standard]
|
|
||||||
dyndns: true
|
|
||||||
|
|
||||||
# Hosts to drop/ignore
|
|
||||||
drophosts: {"unknown", "wookie15", "wort"}
|
|
||||||
|
|
||||||
nsupdate_bin: "/usr/local/bin/nsupdate"
|
|
||||||
|
|
||||||
dyndomains: {"wrede.org"}
|
|
||||||
|
|
||||||
ws_port: 50005
|
|
||||||
# wss_port: 50006 # Commented out - use plain WebSocket instead of secure WSS
|
|
||||||
# cert_path: "/usr/local/etc/letsencrypt/live/hbd.wrede.ca/"
|
|
||||||
# cert_path: "test/"
|
|
||||||
# CERT_PATH = "./test/"
|
|
||||||
# wss_pem: "fullchain.pem"
|
|
||||||
# wss_key: "privkey.pem"
|
|
||||||
|
|
||||||
journal_enabled: true # Enable/disable journaling
|
|
||||||
journal_dir: /home/andreas/logs/heartbeat # Journal directory
|
|
||||||
journal_file: messages.journal # Base filename
|
|
||||||
journal_max_size: 104857600 # Max size (100MB default)
|
|
||||||
journal_max_backups: 10 # Number of backups to keep
|
|
||||||
|
|
||||||
threshold_configs:
|
|
||||||
default:
|
|
||||||
thresholds:
|
|
||||||
cpu_monitor:
|
|
||||||
cpu_percent:
|
|
||||||
warning: 80.0
|
|
||||||
critical: 90.0
|
|
||||||
memory_monitor:
|
|
||||||
percent:
|
|
||||||
warning: 85.0
|
|
||||||
critical: 95.0
|
|
||||||
disk_monitor:
|
|
||||||
partitions:
|
|
||||||
/:
|
|
||||||
percent:
|
|
||||||
warning: 85.0
|
|
||||||
critical: 90.0
|
|
||||||
rtt:
|
|
||||||
warning: 50
|
|
||||||
critical: 250.0
|
|
||||||
|
|
||||||
|
|
||||||
freebsd_server:
|
|
||||||
thresholds:
|
|
||||||
cpu_monitor:
|
|
||||||
cpu_percent:
|
|
||||||
warning: 80.0
|
|
||||||
critical: 90.0
|
|
||||||
memory_monitor:
|
|
||||||
memory_percent:
|
|
||||||
warning: 97.0
|
|
||||||
critical: 100.0
|
|
||||||
disk_monitor:
|
|
||||||
partitions:
|
|
||||||
/:
|
|
||||||
percent:
|
|
||||||
warning: 85.0
|
|
||||||
critical: 90.0
|
|
||||||
nagios_runner:
|
|
||||||
# overall_status_code:
|
|
||||||
# warning: 1
|
|
||||||
# critical: 2
|
|
||||||
# operator: ">="
|
|
||||||
load_status:
|
|
||||||
warning: WARNING
|
|
||||||
critical: CRITICAL
|
|
||||||
operator: "=="
|
|
||||||
ups_load:
|
|
||||||
display: "load to high: {ups_output}"
|
|
||||||
warning: 70
|
|
||||||
critical: 80
|
|
||||||
operator: ">="
|
|
||||||
ups_status_code:
|
|
||||||
display: "{ups_output}"
|
|
||||||
warning: 1
|
|
||||||
critical: 2
|
|
||||||
operator: ">="
|
|
||||||
nextcloud_apps_status_code:
|
|
||||||
display: "{nextcloud_apps_output}"
|
|
||||||
warning: 1
|
|
||||||
critical: 2
|
|
||||||
operator: ">="
|
|
||||||
rtt:
|
|
||||||
warning: 50
|
|
||||||
critical: 250.0
|
|
||||||
|
|
||||||
truenas_server:
|
|
||||||
thresholds:
|
|
||||||
cpu_monitor:
|
|
||||||
cpu_percent:
|
|
||||||
warning: 80.0
|
|
||||||
critical: 90.0
|
|
||||||
memory_monitor:
|
|
||||||
percent:
|
|
||||||
warning: 3.0
|
|
||||||
critical: 95.0
|
|
||||||
disk_monitor:
|
|
||||||
partitions:
|
|
||||||
/:
|
|
||||||
percent:
|
|
||||||
warning: 85.0
|
|
||||||
critical: 90.0
|
|
||||||
nagios_runner:
|
|
||||||
# overall_status_code:
|
|
||||||
# warning: 1
|
|
||||||
# critical: 2
|
|
||||||
# operator: ">="
|
|
||||||
load_status:
|
|
||||||
warning: WARNING
|
|
||||||
critical: CRITICAL
|
|
||||||
operator: "=="
|
|
||||||
ups_load:
|
|
||||||
display: "load to high: {ups_output}"
|
|
||||||
WARNING: 70
|
|
||||||
CRITICAL: 80
|
|
||||||
OPERATOR: ">="
|
|
||||||
ups_status_code:
|
|
||||||
DISPLAY: "{ups_output}"
|
|
||||||
warning: 1
|
|
||||||
critical: 2
|
|
||||||
operator: ">="
|
|
||||||
nextcloud_apps_status_code:
|
|
||||||
display: "{nextcloud_apps_output}"
|
|
||||||
warning: 1
|
|
||||||
critical: 2
|
|
||||||
operator: ">="
|
|
||||||
rtt:
|
|
||||||
warning: 120
|
|
||||||
critical: 250.0
|
|
||||||
|
|
||||||
|
|
||||||
Vendored
+6
-5
@@ -4,12 +4,13 @@
|
|||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "Python: Run hbd (module)",
|
"name": "Python: Run hbd (module)",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "hbd.server.cli",
|
"module": "hbd.server.cli",
|
||||||
"args": ["-c", "/home/andreas/git/heartbeat/.hb.yaml", "-f", "-v", "-x", "-x", "-x", "-x"],
|
"args": ["-c", "~/.hb.yaml", "-f", "-v"],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"env": {
|
"env": {
|
||||||
"PYTHONPATH": "${workspaceFolder}"
|
"PYTHONPATH": "${workspaceFolder}"
|
||||||
@@ -28,14 +29,14 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Python: Run hbd with debugpy (listen)",
|
"name": "Python: Run hbc (module)",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "debugpy",
|
"module": "hbd.client.main",
|
||||||
"args": ["--listen", "5678", "--wait-for-client", "-m", "hbd.server.cli", "-c", ".hb.yaml", "-f", "-v"],
|
"args": ["-c", "~/.hbc.yaml", "-v", "winter"],
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
"env": { "PYTHONPATH": "${workspaceFolder}" },
|
"env": { "PYTHONPATH": "${workspaceFolder}" },
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1. Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||||
|
2. Minimum code that solves the problem. Nothing speculative.
|
||||||
|
3. Touch only what you must. Clean up only your own mess.
|
||||||
|
4. Define success criteria. Loop until verified.
|
||||||
@@ -11,8 +11,13 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k
|
|||||||
- Queue DNS updates via `nsupdate` and run them in a background thread ✅
|
- Queue DNS updates via `nsupdate` and run them in a background thread ✅
|
||||||
- WebSocket API for live updates (hosts & messages) ✅
|
- WebSocket API for live updates (hosts & messages) ✅
|
||||||
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
|
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
|
||||||
|
- **User management & access control** ✅
|
||||||
|
- Optional user accounts with bcrypt-style password hashing (stdlib only)
|
||||||
|
- Per-host roles: owner, manager, monitor
|
||||||
|
- Session-based auth with cookie support (browser login page included)
|
||||||
|
- Backwards compatible: no auth required when no users are configured
|
||||||
- **HTTP API & Web UI** ✅
|
- **HTTP API & Web UI** ✅
|
||||||
- REST API for plugin data, alerts, and host information
|
- REST API for plugin data, alerts, host information, and user management
|
||||||
- Live dashboard with WebSocket updates
|
- Live dashboard with WebSocket updates
|
||||||
- Interactive plugin metrics visualization
|
- Interactive plugin metrics visualization
|
||||||
- Alerts dashboard with filtering and summaries
|
- Alerts dashboard with filtering and summaries
|
||||||
@@ -22,6 +27,7 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k
|
|||||||
- Configurable retention and backup management
|
- Configurable retention and backup management
|
||||||
- **Plugin system for extensible monitoring** ✅
|
- **Plugin system for extensible monitoring** ✅
|
||||||
- Collect system metrics (CPU, memory, disk, network)
|
- Collect system metrics (CPU, memory, disk, network)
|
||||||
|
- Monitor ZFS pool health, capacity, and I/O via `zpool(8)`
|
||||||
- Execute existing Nagios monitoring plugins
|
- Execute existing Nagios monitoring plugins
|
||||||
- Create custom plugins with simple Python classes
|
- Create custom plugins with simple Python classes
|
||||||
- **Threshold alerting system** ✅
|
- **Threshold alerting system** ✅
|
||||||
@@ -29,6 +35,8 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k
|
|||||||
- Hysteresis to prevent alert flapping
|
- Hysteresis to prevent alert flapping
|
||||||
- Automatic notifications on state changes
|
- Automatic notifications on state changes
|
||||||
- Re-notification for ongoing alerts
|
- Re-notification for ongoing alerts
|
||||||
|
- **Per-host watch flag** — set `watch: false` on any host to silence all notifications for that host without removing its configuration ✅
|
||||||
|
- **Role-filtered dashboards** — Live Dashboard and Host Overview show only hosts where the logged-in user is owner or manager (admins see all) ✅
|
||||||
- Modular codebase suitable for unit testing and CI ✅
|
- Modular codebase suitable for unit testing and CI ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -50,28 +58,33 @@ Heartbeat includes a comprehensive plugin architecture that extends monitoring b
|
|||||||
### Built-in Plugins
|
### Built-in Plugins
|
||||||
|
|
||||||
- `os_info`: Collects OS, kernel, distribution, and architecture information
|
- `os_info`: Collects OS, kernel, distribution, and architecture information
|
||||||
- `cpu_monitor`: Monitors CPU usage, load average, frequency, and process counts
|
- `cpu_monitor`: Monitors CPU usage, load average, frequency, process counts, and uptime
|
||||||
- `memory_monitor`: Monitors RAM and swap usage, available memory
|
- `memory_monitor`: Monitors RAM and swap usage, available memory (ZFS ARC-aware)
|
||||||
- `disk_monitor`: Monitors disk usage, I/O statistics, and filesystem metrics
|
- `disk_monitor`: Monitors disk usage, I/O statistics, and filesystem metrics
|
||||||
- `network_monitor`: Monitors network interface statistics, bandwidth, and connections
|
- `network_monitor`: Monitors network interface statistics, bandwidth, and connections
|
||||||
|
- `ping_monitor`: Measures round-trip latency to configured hosts
|
||||||
- `filesystem_info`: Collects mounted filesystem information (physical filesystems only by default)
|
- `filesystem_info`: Collects mounted filesystem information (physical filesystems only by default)
|
||||||
- `nagios_runner`: Executes Nagios monitoring plugins (check_disk, check_load, check_http, etc.)
|
- `nagios_runner`: Executes Nagios monitoring plugins (check_disk, check_load, check_http, etc.)
|
||||||
|
- `zfs_monitor`: Monitors ZFS pool health, capacity, fragmentation, dedup ratio, and cumulative I/O via `zpool(8)`
|
||||||
|
|
||||||
### Nagios Integration
|
### Nagios Integration
|
||||||
|
|
||||||
The `nagios_runner` plugin provides seamless integration with the vast Nagios plugin ecosystem. You can run any Nagios-compatible plugin and have the results automatically parsed and stored:
|
The `nagios_runner` plugin provides seamless integration with the vast Nagios plugin ecosystem. You can run any Nagios-compatible plugin and have the results automatically parsed and stored:
|
||||||
|
|
||||||
- Executes plugins via subprocess with timeout protection
|
- Executes plugins asynchronously (non-blocking) with timeout protection
|
||||||
|
- Captures both stdout and stderr; if stdout is empty, stderr is used as the status message
|
||||||
|
- Handles signal-killed processes (negative exit code → UNKNOWN status)
|
||||||
|
- Validates absolute command paths at startup and warns on missing or non-executable files
|
||||||
- Parses exit codes (OK/WARNING/CRITICAL/UNKNOWN)
|
- Parses exit codes (OK/WARNING/CRITICAL/UNKNOWN)
|
||||||
- Extracts performance data with thresholds
|
- Extracts performance data with thresholds
|
||||||
- Reports aggregated status across all configured checks
|
- Reports per-check status, exit code, and output; no aggregate rollup field
|
||||||
|
|
||||||
See [docs/NAGIOS_INTEGRATION.md](docs/NAGIOS_INTEGRATION.md) for complete integration guide including configuration examples and custom plugin development.
|
See [docs/NAGIOS_INTEGRATION.md](docs/NAGIOS_INTEGRATION.md) for complete integration guide including configuration examples and custom plugin development.
|
||||||
|
|
||||||
### Creating Custom Plugins
|
### Creating Custom Plugins
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hbd.plugin import MonitorPlugin
|
from hbd.client.plugin import MonitorPlugin
|
||||||
|
|
||||||
class DiskMonitorPlugin(MonitorPlugin):
|
class DiskMonitorPlugin(MonitorPlugin):
|
||||||
name = "disk_monitor"
|
name = "disk_monitor"
|
||||||
@@ -84,7 +97,7 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Place plugins in `hbd/plugins/` and they'll be automatically discovered and loaded by the client.
|
Place plugins in `hbd/client/plugins/` and they'll be automatically discovered and loaded by the client.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -142,9 +155,11 @@ Heartbeat includes a sophisticated threshold alerting system that monitors plugi
|
|||||||
- **Multi-level alerts**: WARNING and CRITICAL severity levels
|
- **Multi-level alerts**: WARNING and CRITICAL severity levels
|
||||||
- **Flexible operators**: Support for >, >=, <, <=, ==, != comparisons
|
- **Flexible operators**: Support for >, >=, <, <=, ==, != comparisons
|
||||||
- **Hysteresis**: Prevents alert flapping with configurable recovery thresholds
|
- **Hysteresis**: Prevents alert flapping with configurable recovery thresholds
|
||||||
- **Smart notifications**: Alerts only on state changes, not every check
|
- **Smart notifications**: Alerts only on state changes, not every check; de-escalations (e.g. CRITICAL → WARNING) do not generate a notification
|
||||||
- **Re-notifications**: Periodic reminders for ongoing alerts
|
- **Re-notifications**: Periodic reminders for ongoing alerts
|
||||||
|
- **Short-duration suppression**: Recovery notifications are suppressed for down events under 4 seconds (avoids noise from transient blips)
|
||||||
- **Journal integration**: All threshold events logged for audit trail
|
- **Journal integration**: All threshold events logged for audit trail
|
||||||
|
- **`ping_monitor` thresholds**: Latency and packet-loss thresholds use the same format as all other plugin metrics
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@@ -167,7 +182,8 @@ thresholds:
|
|||||||
warning: 80.0 # Warn when CPU > 80%
|
warning: 80.0 # Warn when CPU > 80%
|
||||||
critical: 90.0 # Critical when CPU > 90%
|
critical: 90.0 # Critical when CPU > 90%
|
||||||
operator: ">"
|
operator: ">"
|
||||||
hysteresis: 0.1 # 10% hysteresis to prevent flapping
|
hysteresis: 0.02 # 2% hysteresis to prevent flapping
|
||||||
|
display: "(threshold: {op_symbol} {threshold_value}%)" # optional
|
||||||
|
|
||||||
memory_monitor:
|
memory_monitor:
|
||||||
percent:
|
percent:
|
||||||
@@ -209,7 +225,7 @@ thresholds:
|
|||||||
<hostname>:
|
<hostname>:
|
||||||
warning: <milliseconds> # Warn when RTT > this value
|
warning: <milliseconds> # Warn when RTT > this value
|
||||||
critical: <milliseconds> # Critical when RTT > this value
|
critical: <milliseconds> # Critical when RTT > this value
|
||||||
hysteresis: 0.1 # Optional: 10% hysteresis (default)
|
hysteresis: 0.02 # Optional: 2% hysteresis (default)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example alerts:**
|
**Example alerts:**
|
||||||
@@ -260,83 +276,187 @@ All plugin metrics can be thresholded:
|
|||||||
- **Memory**: percent, available_mb, swap_percent
|
- **Memory**: percent, available_mb, swap_percent
|
||||||
- **Disk**: Per-partition percent, free_gb, free_mb
|
- **Disk**: Per-partition percent, free_gb, free_mb
|
||||||
- **Network**: errors_total, dropped packets, connection counts
|
- **Network**: errors_total, dropped packets, connection counts
|
||||||
- **Nagios**: exit_code mapping (0=OK, 1=WARNING, 2=CRITICAL)
|
- **Nagios**: Any field emitted by `nagios_runner` (`<name>_status_code`, `<name>_status`, `<name>_output`, performance data fields)
|
||||||
|
|
||||||
|
### Display Format Templates
|
||||||
|
|
||||||
|
Each threshold entry accepts an optional `display` field — a Python format string shown in notifications and on the Alerts dashboard:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nagios_runner:
|
||||||
|
status_code:
|
||||||
|
warning: 1
|
||||||
|
critical: 2
|
||||||
|
operator: ">="
|
||||||
|
display: "{check_name}: exit {value} (expected < {threshold_value})"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `{value}` | Current metric value |
|
||||||
|
| `{threshold_value}` | Threshold that was crossed |
|
||||||
|
| `{op_symbol}` | Comparison operator (`>`, `<`, `>=`, …); `"nagios"` for the nagios operator |
|
||||||
|
| `{check_name}` | Prefix stripped by generic matching (see below) |
|
||||||
|
| `{metric_name}` | Full field name within the plugin data |
|
||||||
|
| `{output}` | For `nagios_runner` generic matches: the matched check's status text (alias for `{check_name}_output`) |
|
||||||
|
| `{status}` | For `nagios_runner` generic matches: the matched check's status name — OK/WARNING/CRITICAL/UNKNOWN (alias for `{check_name}_status`) |
|
||||||
|
| any plugin field | Any other field present in the plugin's data |
|
||||||
|
|
||||||
|
### Generic Threshold Matching
|
||||||
|
|
||||||
|
When a metric name has no exact threshold entry, the server progressively strips leading underscore-separated segments and re-tries the lookup. This lets a single generic entry cover an entire family of metrics.
|
||||||
|
|
||||||
|
The classic use case is `nagios_runner`, which names each metric after the command that produced it:
|
||||||
|
|
||||||
|
```
|
||||||
|
nagios_runner.check_disk_root_status_code → no exact match
|
||||||
|
nagios_runner.disk_root_status_code → no match
|
||||||
|
nagios_runner.root_status_code → no match
|
||||||
|
nagios_runner.status_code → matched ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure the generic threshold once using the `nagios` operator, which maps exit codes directly to alert severity without requiring numeric warning/critical values:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nagios_runner:
|
||||||
|
status_code:
|
||||||
|
operator: "nagios" # 0=OK 1=WARNING 2=CRITICAL 3=UNKNOWN
|
||||||
|
display: "{check_name}: {output}"
|
||||||
|
```
|
||||||
|
|
||||||
|
The stripped prefix (`check_disk_root` in the example above) is available as `{check_name}` in the display template, so you can identify which check triggered the alert without writing a separate threshold entry per command.
|
||||||
|
|
||||||
|
Exact matches always take priority. A generic entry only applies when no specific one is defined.
|
||||||
|
|
||||||
|
### Per-Host Threshold Profiles
|
||||||
|
|
||||||
|
Named threshold configurations let different hosts use different limits. A host's `threshold_config` can be a single name or a **list** — lists are applied left-to-right so profiles compose without duplication:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
threshold_configs:
|
||||||
|
default:
|
||||||
|
thresholds:
|
||||||
|
cpu_monitor:
|
||||||
|
cpu_percent: {warning: 80, critical: 90}
|
||||||
|
memory_monitor:
|
||||||
|
memory_percent: {warning: 85, critical: 95}
|
||||||
|
|
||||||
|
tight_cpu: # override CPU limits only
|
||||||
|
thresholds:
|
||||||
|
cpu_monitor:
|
||||||
|
cpu_percent: {warning: 60, critical: 75}
|
||||||
|
|
||||||
|
db_disk: # add a database partition check
|
||||||
|
thresholds:
|
||||||
|
disk_monitor:
|
||||||
|
partitions:
|
||||||
|
/var/lib/postgresql:
|
||||||
|
percent: {warning: 75, critical: 88}
|
||||||
|
|
||||||
|
hosts:
|
||||||
|
web-01:
|
||||||
|
threshold_config: default # single profile
|
||||||
|
|
||||||
|
db-01:
|
||||||
|
threshold_config: [tight_cpu, db_disk] # layered: CPU override + extra disk check
|
||||||
|
```
|
||||||
|
|
||||||
|
Each named config's overrides are applied in order on top of the defaults. Metrics not mentioned in a profile are inherited unchanged.
|
||||||
|
|
||||||
See [docs/THRESHOLD_ALERTING.md](docs/THRESHOLD_ALERTING.md) for comprehensive documentation including best practices, troubleshooting, and advanced configuration.
|
See [docs/THRESHOLD_ALERTING.md](docs/THRESHOLD_ALERTING.md) for comprehensive documentation including best practices, troubleshooting, and advanced configuration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 👥 User Management
|
||||||
|
|
||||||
|
Heartbeat supports optional user accounts with role-based access control per host.
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
- **monitor** — view status, plugin data, alerts
|
||||||
|
- **manager** — monitor + queue commands, trigger DNS, queue upgrades
|
||||||
|
- **owner** — manager + drop host, transfer ownership, update access
|
||||||
|
- **admin** (user flag) — owner-level access on every host
|
||||||
|
|
||||||
|
When no users are configured the server runs in **unauthenticated mode** — all existing behaviour is unchanged.
|
||||||
|
|
||||||
|
### Quick setup
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
users:
|
||||||
|
alice:
|
||||||
|
full_name: Alice Smith
|
||||||
|
password: pbkdf2:sha256:... # hbd passwd alice
|
||||||
|
admin: true
|
||||||
|
|
||||||
|
default_owner: alice
|
||||||
|
|
||||||
|
hosts:
|
||||||
|
webserver01:
|
||||||
|
owner: alice
|
||||||
|
managers: [bob]
|
||||||
|
monitors: [carol]
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a password hash
|
||||||
|
hbd passwd alice
|
||||||
|
```
|
||||||
|
|
||||||
|
Browser users are redirected to `/login` automatically. The session cookie is set on login, so `fetch()` calls from dashboards work without any JavaScript changes.
|
||||||
|
|
||||||
|
See [docs/USERS.md](docs/USERS.md) for complete user management documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🌐 HTTP API & Web UI
|
## 🌐 HTTP API & Web UI
|
||||||
|
|
||||||
Heartbeat includes a built-in HTTP/WebSocket server that provides both a REST API and web-based dashboards for monitoring and visualization.
|
Heartbeat includes a built-in HTTP/WebSocket server that provides both a REST API and web-based dashboards for monitoring and visualization.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **REST API**: JSON endpoints for accessing plugin data, alerts, and host information
|
- **User auth**: Optional session-based authentication with per-host role enforcement
|
||||||
|
- **REST API**: JSON endpoints for accessing plugin data, alerts, host information, and user management
|
||||||
- **Live Dashboard**: Real-time WebSocket-powered host status view
|
- **Live Dashboard**: Real-time WebSocket-powered host status view
|
||||||
- **Plugin Metrics**: Interactive visualization of all plugin data with auto-refresh
|
- **Plugin Metrics**: Interactive visualization of all plugin data with auto-refresh
|
||||||
- **Alerts Dashboard**: Comprehensive alert monitoring with filtering and summaries
|
- **Alerts Dashboard**: Comprehensive alert monitoring with filtering and summaries
|
||||||
- **CORS Support**: Configurable for integration with external applications
|
|
||||||
|
|
||||||
### Web Dashboards
|
### Web Dashboards
|
||||||
|
|
||||||
- **Live View** (`/live`): Real-time host connectivity, latency, and messages
|
- **Login** (`/login`): Browser login form (shown automatically when auth is configured)
|
||||||
- **Plugin Metrics** (`/plugins`): Browse and visualize metrics from all plugins
|
- **Live View** (`/live`): Real-time host connectivity, latency, and messages; hostnames link directly to the Host Overview page
|
||||||
- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering
|
- **Host Overview** (`/plugins/<host>`): Per-host plugin metrics with ZFS pool visualization; filtered to hosts where the logged-in user is owner or manager (admins see all)
|
||||||
|
- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering; alert count pie chart shown in the navigation bar
|
||||||
|
- **Settings** (`/settings`): Server configuration, user management, and threshold configuration viewer
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Log in (when auth is configured)
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"alice","password":"secret"}' | jq -r .token)
|
||||||
|
AUTH="-H \"Authorization: Bearer $TOKEN\""
|
||||||
|
|
||||||
# List all monitored hosts
|
# List all monitored hosts
|
||||||
curl http://localhost:50004/api/0/hosts
|
curl $AUTH http://localhost:50004/api/0/hosts
|
||||||
|
|
||||||
# Get all plugin data for a host
|
# Get all plugin data for a host
|
||||||
curl http://localhost:50004/api/0/hosts/webserver01/plugins
|
curl $AUTH http://localhost:50004/api/0/hosts/webserver01/plugins
|
||||||
|
|
||||||
# Get detailed plugin history (last 50 samples)
|
# Get detailed plugin history (last 50 samples)
|
||||||
curl http://localhost:50004/api/0/hosts/webserver01/plugins/cpu_monitor?limit=50
|
curl $AUTH "http://localhost:50004/api/0/hosts/webserver01/plugins/cpu_monitor?limit=50"
|
||||||
|
|
||||||
# Get alert states for a specific host
|
# Get alert states for a specific host
|
||||||
curl http://localhost:50004/api/0/hosts/webserver01/alerts
|
curl $AUTH http://localhost:50004/api/0/hosts/webserver01/alerts
|
||||||
|
|
||||||
# Get all active alerts across all hosts
|
# Get all active alerts across all hosts
|
||||||
curl http://localhost:50004/api/0/alerts
|
curl $AUTH http://localhost:50004/api/0/alerts
|
||||||
```
|
|
||||||
|
|
||||||
### Integration Examples
|
# View/update host access roles
|
||||||
|
curl $AUTH http://localhost:50004/api/0/hosts/webserver01/access
|
||||||
**Python Client:**
|
|
||||||
```python
|
|
||||||
import requests
|
|
||||||
|
|
||||||
# Monitor for critical alerts
|
|
||||||
response = requests.get('http://localhost:50004/api/0/alerts')
|
|
||||||
alerts = response.json()
|
|
||||||
|
|
||||||
if alerts['summary']['critical'] > 0:
|
|
||||||
print(f"⚠️ {alerts['summary']['critical']} CRITICAL alerts!")
|
|
||||||
for alert in alerts['alerts']:
|
|
||||||
if alert['level'] == 'CRITICAL':
|
|
||||||
print(f" {alert['hostname']}: {alert['metric_path']} = {alert['last_value']}")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bash Monitoring Script:**
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# Check for critical alerts
|
|
||||||
CRITICAL=$(curl -s http://localhost:50004/api/0/alerts | jq '.summary.critical')
|
|
||||||
if [ "$CRITICAL" -gt 0 ]; then
|
|
||||||
echo "CRITICAL: $CRITICAL critical alerts detected!"
|
|
||||||
# Send notification
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Demo & Testing
|
|
||||||
|
|
||||||
Run the API demo script to test all endpoints:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/demo_http_api.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See [docs/HTTP_API.md](docs/HTTP_API.md) for complete API documentation including response formats, error handling, and integration examples.
|
See [docs/HTTP_API.md](docs/HTTP_API.md) for complete API documentation including response formats, error handling, and integration examples.
|
||||||
@@ -347,7 +467,7 @@ See [docs/HTTP_API.md](docs/HTTP_API.md) for complete API documentation includin
|
|||||||
|
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
|
|
||||||
- Python 3.10+ (project uses language features from recent Python)
|
- Python 3.11+ (project uses language features from recent Python)
|
||||||
- `nsupdate` (for DNS updates) if using dynamic DNS
|
- `nsupdate` (for DNS updates) if using dynamic DNS
|
||||||
|
|
||||||
Install dependencies (recommended into a venv):
|
Install dependencies (recommended into a venv):
|
||||||
@@ -356,7 +476,7 @@ This project now declares its dependencies in `pyproject.toml`. Instead
|
|||||||
of the old `requirements.txt` flow, install the package into a virtualenv
|
of the old `requirements.txt` flow, install the package into a virtualenv
|
||||||
using `pip`:
|
using `pip`:
|
||||||
|
|
||||||
See `scripts/install.sh` for a way to install.
|
See `scripts/hb_install.sh` for a way to install.
|
||||||
|
|
||||||
Run the daemon (example):
|
Run the daemon (example):
|
||||||
|
|
||||||
@@ -368,7 +488,7 @@ hbd -c .hb.yaml -f -v
|
|||||||
You can also run it directly via the package entrypoint after installation:
|
You can also run it directly via the package entrypoint after installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m hbd.cli -c /path/to/config.yaml
|
python -m hbd.server.cli -c /path/to/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the Client
|
### Running the Client
|
||||||
@@ -376,22 +496,33 @@ python -m hbd.cli -c /path/to/config.yaml
|
|||||||
The heartbeat client (`hbc`) sends periodic heartbeats and plugin data to the server:
|
The heartbeat client (`hbc`) sends periodic heartbeats and plugin data to the server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Basic usage pointing to server
|
# Basic usage pointing to server (host is a positional argument)
|
||||||
python -m hbd.hbc --server your-server.example.com
|
hbc your-server.example.com
|
||||||
|
|
||||||
# With custom configuration
|
# Run as daemon with a config file
|
||||||
python -m hbd.hbc --server 192.168.1.100 --port 50003 --interval 30
|
hbc -d -c /etc/hbc.yaml your-server.example.com
|
||||||
|
|
||||||
# Run with specific plugins enabled/disabled
|
# Send a one-off boot message
|
||||||
python -m hbd.hbc --server hbd.local --disable-plugin os_info
|
hbc --boot your-server.example.com
|
||||||
|
|
||||||
|
# Verbose output
|
||||||
|
hbc -v your-server.example.com
|
||||||
|
|
||||||
|
# Send 'boot' and 'shutdown' messages on start and exit
|
||||||
|
hbc -b your-server.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Client configuration can also be specified in YAML:
|
You can also run it via the module entrypoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m hbd.client.main your-server.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Client configuration can also be specified in YAML (`~/.hbc.yaml`):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server: hbd.example.com
|
hb_port: 50003 # Server port (default: 50003)
|
||||||
port: 50003
|
interval: 30 # Heartbeat interval in seconds
|
||||||
interval: 30
|
|
||||||
plugins:
|
plugins:
|
||||||
cpu_monitor:
|
cpu_monitor:
|
||||||
interval: 300 # Check every 5 minutes (default)
|
interval: 300 # Check every 5 minutes (default)
|
||||||
@@ -405,42 +536,113 @@ plugins:
|
|||||||
nagios_runner:
|
nagios_runner:
|
||||||
interval: 300 # Check every 5 minutes (default)
|
interval: 300 # Check every 5 minutes (default)
|
||||||
commands:
|
commands:
|
||||||
- /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6
|
- name: check_load
|
||||||
- /usr/lib/nagios/plugins/check_disk -w 20% -c 10% -p /
|
command: /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6
|
||||||
|
- name: check_disk
|
||||||
|
command: /usr/lib/nagios/plugins/check_disk -w 20% -c 10% -p /
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The server hostname is always passed as a positional command-line argument; there is no `server:` config key.
|
||||||
|
|
||||||
All monitoring plugins default to 5-minute (300 second) intervals, but can be customized as needed.
|
All monitoring plugins default to 5-minute (300 second) intervals, but can be customized as needed.
|
||||||
|
|
||||||
|
**Connection retry:** If a server is temporarily unreachable, `hbc` retries `open()` indefinitely on every heartbeat interval. IPv6 connections that never succeeded during early startup are dropped after 3 consecutive failures (to handle hosts without IPv6 routing), while IPv4 connections always retry.
|
||||||
|
|
||||||
|
**Daemon logging:** When running with `-d`, `hbc` routes all log output to syslog (`LOG_DAEMON` facility) after daemonizing. Without `-d`, logs go to stderr as usual.
|
||||||
|
|
||||||
|
### hbc_mini — single-file client (no external dependencies)
|
||||||
|
|
||||||
|
`scripts/hbc_mini.py` is a self-contained version of the heartbeat client that requires only Python 3.8+ and no external packages. Copy it to any host and run it directly — no virtualenv, no `pip install`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic usage
|
||||||
|
python3 hbc_mini.py your-server.example.com
|
||||||
|
|
||||||
|
# Run as daemon
|
||||||
|
python3 hbc_mini.py -d your-server.example.com
|
||||||
|
|
||||||
|
# Send a boot message
|
||||||
|
python3 hbc_mini.py -b your-server.example.com
|
||||||
|
|
||||||
|
# Send a one-off message
|
||||||
|
python3 hbc_mini.py -m "maintenance starting" your-server.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config:** `~/.hbc.json` (same keys as `~/.hbc.yaml`, JSON format). Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hb_port": 50003,
|
||||||
|
"interval": 30,
|
||||||
|
"plugins": {
|
||||||
|
"ping_monitor": {
|
||||||
|
"interval": 60,
|
||||||
|
"hosts": ["8.8.8.8", "192.168.1.1"]
|
||||||
|
},
|
||||||
|
"nagios_runner": {
|
||||||
|
"interval": 300,
|
||||||
|
"commands": [
|
||||||
|
{"name": "check_load", "command": "/usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Plugin availability:**
|
||||||
|
|
||||||
|
| Plugin | Platform | Data source |
|
||||||
|
|---|---|---|
|
||||||
|
| `os_info` | all | `platform` stdlib |
|
||||||
|
| `ping_monitor` | all | `ping` subprocess |
|
||||||
|
| `nagios_runner` | all (not Windows) | subprocess |
|
||||||
|
| `cpu_monitor` | Linux | `/proc/stat` |
|
||||||
|
| `memory_monitor` | Linux | `/proc/meminfo` |
|
||||||
|
| `disk_monitor` | Linux, macOS, BSD | `df -P` subprocess |
|
||||||
|
| `network_monitor` | Linux | `/proc/net/dev` |
|
||||||
|
|
||||||
|
**What is not available compared to the full `hbc`:**
|
||||||
|
|
||||||
|
- No YAML config (use JSON instead)
|
||||||
|
- No `filesystem_info` plugin
|
||||||
|
- No `zfs_monitor` plugin (requires `zpool(8)` and the full plugin loader)
|
||||||
|
- `cpu_monitor` does not report per-core usage or CPU frequency (no psutil)
|
||||||
|
- Plugins cannot be loaded from external `.py` files — all plugins are compiled in
|
||||||
|
- No IPv6 early-fail protection — connections that fail to open at startup are silently skipped rather than retried
|
||||||
|
|
||||||
|
Everything else — heartbeat protocol, ACK/CMD/UPD handling, `hb_install.sh`-based self-update, daemonize, syslog — is identical to the full client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🐞 Debugging in VS Code
|
## 🐞 Debugging in VS Code
|
||||||
|
|
||||||
This repository includes a ready-to-use `.vscode/launch.json` with configurations to run or attach the VS Code debugger to `hbd`.
|
This repository includes a ready-to-use `.vscode/launch.json` with configurations to run or attach the VS Code debugger to `hbd`.
|
||||||
|
|
||||||
- Ensure the **Python** extension is installed and select the project `.venv` as the interpreter (bottom-left of VS Code).
|
- Ensure the **Python** extension is installed and select the project `.venv` as the interpreter (bottom-left of VS Code).
|
||||||
- Use **F5** and pick one of these configurations from the Run view:
|
- Use **F5** and pick one of these configurations from the Run view:
|
||||||
- **Python: Run hbd (module)** — runs `hbd.cli` as a module and sets `PYTHONPATH` to the workspace root (recommended).
|
- **Python: Run hbd (module)** — runs `hbd.server.cli` as a module and sets `PYTHONPATH` to the workspace root (recommended).
|
||||||
- **Python: Run hbd with debugpy (listen)** — launches `debugpy` and `hbd` together; useful when you want the process to listen for a debugger.
|
- **Python: Run hbd with debugpy (listen)** — launches `debugpy` and `hbd` together; useful when you want the process to listen for a debugger.
|
||||||
- **Python: Attach (localhost:5678)** — attach the debugger to a running process started with `debugpy`.
|
- **Python: Attach (localhost:5678)** — attach the debugger to a running process started with `debugpy`.
|
||||||
|
|
||||||
To start `hbd` manually and wait for the debugger to attach, run:
|
To start `hbd` manually and wait for the debugger to attach, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.cli -c .hb.yaml -f -v
|
PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.server.cli -c .hb.yaml -f -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py`, and use the **Attach** configuration to connect. Use `justMyCode: false` if you need to step into third-party code.
|
Set breakpoints in modules such as `hbd/server/udp.py`, `hbd/server/dns.py`, or `hbd/server/main.py`, and use the **Attach** configuration to connect. Use `justMyCode: false` if you need to step into third-party code.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠 Configuration
|
## 🛠 Configuration
|
||||||
|
|
||||||
`hbd` reads YAML configuration (optional). If `PyYAML` is not installed, built-in defaults are used. Example configuration keys (see `hbd/config.py`):
|
`hbd` reads YAML configuration (optional). If `PyYAML` is not installed, built-in defaults are used. Example configuration keys (see `hbd/server/config.py`):
|
||||||
|
|
||||||
- `hb_port`: UDP port to listen for heartbeats (default: 50003)
|
- `hb_port`: UDP port to listen for heartbeats (default: 50003)
|
||||||
- `hbd_port`: internal control port (default: 50004)
|
- `hbd_port`: internal control port (default: 50004)
|
||||||
- `hbd_host`: bind address for HTTP/WSS
|
- `hbd_host`: bind address for HTTP/WSS
|
||||||
- `pickfile`: path for persisted state
|
- `pickfile`: path for persisted state
|
||||||
- `logfile`: path to log file
|
- `logfile`: path to log file
|
||||||
- `logfmt`: `text` or `msg`
|
|
||||||
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
|
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
|
||||||
- `interval` / `grace`: heartbeat timing configuration
|
- `interval` / `grace`: heartbeat timing configuration
|
||||||
- `dyndomains`: list of dyndomains to update via `nsupdate`
|
- `dyndomains`: list of dyndomains to update via `nsupdate`
|
||||||
@@ -452,6 +654,8 @@ Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py
|
|||||||
- `cert_path`: directory where TLS certificate and key are looked up (default: /usr/local/etc/ssl/)
|
- `cert_path`: directory where TLS certificate and key are looked up (default: /usr/local/etc/ssl/)
|
||||||
- `wss_pem`: filename for the certificate chain (default: fullchain.pem)
|
- `wss_pem`: filename for the certificate chain (default: fullchain.pem)
|
||||||
- `wss_key`: filename for the private key (default: privkey.pem)
|
- `wss_key`: filename for the private key (default: privkey.pem)
|
||||||
|
- `users`: mapping of username → user attributes (full_name, avatar, password, admin, notification_channels)
|
||||||
|
- `default_owner`: username that owns hosts with no explicit owner (falls back to first admin user)
|
||||||
|
|
||||||
Example `.hb.yaml` (minimal):
|
Example `.hb.yaml` (minimal):
|
||||||
|
|
||||||
@@ -464,29 +668,39 @@ nsupdate_bin: /usr/bin/nsupdate
|
|||||||
pushsrv: pushover
|
pushsrv: pushover
|
||||||
```
|
```
|
||||||
|
|
||||||
> Tip: `config.DEFAULTS` in `hbd/config.py` contains the canonical defaults and accepted configuration keys.
|
> Tip: `SERVER_DEFAULTS` in `hbd/server/config.py` contains the canonical defaults and accepted configuration keys.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Architecture & Modules
|
## 🔧 Architecture & Modules
|
||||||
|
|
||||||
- `hbd.proto` — serialization/deserialization of heartbeat messages (supports compressed payloads and plugin data)
|
The package is organized into three subpackages:
|
||||||
- `hbd.udp` — UDP parsing and `handle_datagram` implementation (main state machine)
|
|
||||||
- `hbd.dns` — `create_nsupdate_payload`, `nsupdate`, and an asyncio DNS worker (`start_dns_worker`).
|
**`hbd.common`** — shared code used by both client and server:
|
||||||
The DNS worker now runs as an `asyncio` task and the package exposes a
|
- `hbd.common.proto` — serialization/deserialization of heartbeat messages (supports compressed payloads and plugin data)
|
||||||
small thread-safe bridge so legacy synchronous code can `put()` updates
|
- `hbd.common.utils` — small utility helpers (`shortname`, `dur`, `initlog`)
|
||||||
into the queue; there is no longer a permanently-blocking background
|
|
||||||
`threading.Thread`.
|
**`hbd.server`** — the heartbeat daemon (`hbd`):
|
||||||
- `hbd.notify` — email and push notification helpers
|
- `hbd.server.cli` — CLI entrypoint and argument parsing
|
||||||
- `hbd.ws` — WebSocket server and thread-safe broadcast helpers
|
- `hbd.server.main` — async orchestration to run UDP/HTTP/WSS components
|
||||||
- `hbd.http` — HTTP handler factory for the status UI/API
|
- `hbd.server.udp` — UDP parsing and `handle_datagram` implementation (main state machine)
|
||||||
- `hbd.journal` — message journal with size-based log rotation and backup management
|
- `hbd.server.dns` — `create_nsupdate_payload`, `nsupdate`, and an asyncio DNS worker (`start_dns_worker`).
|
||||||
- `hbd.plugin` — plugin framework with base classes, registry, and dynamic loader
|
The DNS worker runs as an `asyncio` task and the package exposes a small thread-safe bridge
|
||||||
- `hbd.plugins/` — built-in plugins (os_info, cpu_monitor, memory_monitor, disk_monitor, network_monitor, filesystem_info, nagios_runner)
|
so legacy synchronous code can `put()` updates into the queue.
|
||||||
- `hbd.hbc` — heartbeat client that sends heartbeats and plugin data to server
|
- `hbd.server.notify` — email and push notification helpers
|
||||||
- `hbd.utils` — small utility helpers (`shortname`, `dur`, `initlog`)
|
- `hbd.server.ws` — WebSocket server and thread-safe broadcast helpers
|
||||||
- `hbd.cli` — CLI entrypoint and argument parsing
|
- `hbd.server.http` — HTTP handler factory for the status UI/API
|
||||||
- `hbd.server` — async orchestration to run UDP/HTTP/WSS components
|
- `hbd.server.journal` — message journal with size-based log rotation and backup management
|
||||||
|
- `hbd.server.threshold` — threshold alerting engine
|
||||||
|
- `hbd.server.monitor` — host state monitoring
|
||||||
|
- `hbd.server.hbdclass` — `Host` class and shared server state
|
||||||
|
- `hbd.server.config` — configuration loader and defaults
|
||||||
|
|
||||||
|
**`hbd.client`** — the heartbeat client (`hbc`):
|
||||||
|
- `hbd.client.main` — client entrypoint; sends heartbeats and plugin data to the server
|
||||||
|
- `hbd.client.plugin` — plugin framework with base classes, registry, and dynamic loader
|
||||||
|
- `hbd.client.plugins/` — built-in plugins (os_info, cpu_monitor, memory_monitor, disk_monitor, network_monitor, filesystem_info, nagios_runner)
|
||||||
|
- `hbd.client.config` — client configuration loader
|
||||||
|
|
||||||
This modular layout makes the code easier to test and maintain.
|
This modular layout makes the code easier to test and maintain.
|
||||||
|
|
||||||
@@ -494,12 +708,12 @@ This modular layout makes the code easier to test and maintain.
|
|||||||
|
|
||||||
- The main runtime is asyncio-based. Services (UDP listener, HTTP server, WebSocket server, monitor, and DNS worker) run as asyncio tasks.
|
- The main runtime is asyncio-based. Services (UDP listener, HTTP server, WebSocket server, monitor, and DNS worker) run as asyncio tasks.
|
||||||
- On SIGINT/SIGTERM the server triggers a graceful shutdown: it cancels active tasks, signals the DNS worker via a sentinel, and cleans up resources before exit.
|
- On SIGINT/SIGTERM the server triggers a graceful shutdown: it cancels active tasks, signals the DNS worker via a sentinel, and cleans up resources before exit.
|
||||||
- The DNS update worker is implemented as an `asyncio` task; synchronous producers can still enqueue DNS updates via a small thread-safe bridge available at `hbd.hbdclass.Host.dnsQ`.
|
- The DNS update worker is implemented as an `asyncio` task; synchronous producers can still enqueue DNS updates via a small thread-safe bridge available at `hbd.server.hbdclass.Host.dnsQ`.
|
||||||
|
|
||||||
**Templates & Static Files**
|
**Templates & Static Files**
|
||||||
|
|
||||||
- Template files are located under `hbd/templates` by default. The HTTP server resolves templates relative to the `hbd` package but the path can be overridden with the `templates_dir` config key.
|
- Template files are located under `hbd/server/templates`. The HTTP server resolves templates relative to the `hbd.server` package but the path can be overridden with the `templates_dir` config key.
|
||||||
- Static assets (CSS/JS/images) are served from `hbd/static` via the `/static/<path>` HTTP route. Place your static files in that directory or configure the HTTP server as needed.
|
- Static assets (CSS/JS/images) are served from `hbd/server/static` via the `/static/<path>` HTTP route.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
-234
@@ -1,234 +0,0 @@
|
|||||||
# HBD/HBC Separation Refactoring
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The heartbeat monitoring system has been refactored into a modular package structure with separate client and server components. This allows users to install only what they need and provides clear separation of concerns.
|
|
||||||
|
|
||||||
## New Package Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
hbd/
|
|
||||||
├── __init__.py # Main package (minimal)
|
|
||||||
├── client/ # HBC - System monitoring client
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── main.py # Entry point (was hbc.py)
|
|
||||||
│ ├── config.py # Client-specific configuration
|
|
||||||
│ ├── plugin.py # Plugin framework
|
|
||||||
│ ├── threshold.py # Threshold checking
|
|
||||||
│ └── plugins/ # Monitoring plugins
|
|
||||||
│ ├── cpu_monitor.py
|
|
||||||
│ ├── disk_monitor.py
|
|
||||||
│ ├── memory_monitor.py
|
|
||||||
│ ├── network_monitor.py
|
|
||||||
│ ├── filesystem_info.py
|
|
||||||
│ ├── os_info.py
|
|
||||||
│ └── nagios_runner.py
|
|
||||||
├── server/ # HBD - Heartbeat daemon/server
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── main.py # Server runtime (was server.py)
|
|
||||||
│ ├── cli.py # Command-line interface
|
|
||||||
│ ├── config.py # Server-specific configuration
|
|
||||||
│ ├── http.py # HTTP/REST API
|
|
||||||
│ ├── ws.py # WebSocket server
|
|
||||||
│ ├── udp.py # UDP heartbeat listener
|
|
||||||
│ ├── dns.py # DNS update functionality
|
|
||||||
│ ├── notify.py # Notification handlers
|
|
||||||
│ ├── monitor.py # Host monitoring
|
|
||||||
│ ├── hbdclass.py # Host class definitions
|
|
||||||
│ ├── journal.py # Message journaling
|
|
||||||
│ ├── templates/ # Jinja2 web templates
|
|
||||||
│ └── static/ # Web UI assets
|
|
||||||
└── common/ # Shared utilities
|
|
||||||
├── __init__.py
|
|
||||||
├── proto.py # Protocol encoding/decoding
|
|
||||||
└── utils.py # Common utilities
|
|
||||||
|
|
||||||
## Configuration Files
|
|
||||||
|
|
||||||
### Client Configuration (hbd/client/config.py)
|
|
||||||
|
|
||||||
Client-specific defaults:
|
|
||||||
- `hb_port`: Port where hbd servers listen (default: 50003)
|
|
||||||
- `interval`: Heartbeat interval in seconds (default: 10)
|
|
||||||
- `plugins`: Per-plugin configuration
|
|
||||||
- `thresholds`: Threshold configuration for monitoring
|
|
||||||
|
|
||||||
### Server Configuration (hbd/server/config.py)
|
|
||||||
|
|
||||||
Server-specific defaults:
|
|
||||||
- `hb_port`: Port to listen for heartbeats (default: 50003)
|
|
||||||
- `hbd_port`: HTTP API port (default: 50004)
|
|
||||||
- `ws_port`: WebSocket port (default: 50005)
|
|
||||||
- `logfile`, `logfmt`: Logging configuration
|
|
||||||
- `pushsrv`, `pushover_token`, etc.: Notification settings
|
|
||||||
- `watchhosts`, `dyndnshosts`: Host monitoring
|
|
||||||
- `smtpserver`, etc.: Email settings
|
|
||||||
- `journal_*`: Message journaling settings
|
|
||||||
|
|
||||||
## Installation Options
|
|
||||||
|
|
||||||
### Install Core Only (minimal, PyYAML only)
|
|
||||||
```bash
|
|
||||||
pip install hbd
|
|
||||||
```
|
|
||||||
|
|
||||||
### Install Client Only (for monitoring)
|
|
||||||
```bash
|
|
||||||
pip install hbd[client]
|
|
||||||
# Installs: PyYAML, psutil
|
|
||||||
```
|
|
||||||
|
|
||||||
### Install Server Only (for daemon)
|
|
||||||
```bash
|
|
||||||
pip install hbd[server]
|
|
||||||
# Installs: PyYAML, websockets, mattermostdriver, aiohttp, Jinja2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Install Everything
|
|
||||||
```bash
|
|
||||||
pip install hbd[all]
|
|
||||||
# Installs all dependencies for both client and server
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Installation
|
|
||||||
```bash
|
|
||||||
pip install -e ".[dev]"
|
|
||||||
# Includes all dependencies plus testing/linting tools
|
|
||||||
```
|
|
||||||
|
|
||||||
## Command-Line Interfaces
|
|
||||||
|
|
||||||
### HBC (Client)
|
|
||||||
```bash
|
|
||||||
hbc [options] host1 [host2 ...]
|
|
||||||
|
|
||||||
# Entry point: hbd.client.main:main
|
|
||||||
# Location: hbd/client/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### HBD (Server)
|
|
||||||
```bash
|
|
||||||
hbd [options]
|
|
||||||
|
|
||||||
# Entry point: hbd.server.cli:main
|
|
||||||
# Location: hbd/server/cli.py → hbd/server/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Import Changes
|
|
||||||
|
|
||||||
### Client Code
|
|
||||||
```python
|
|
||||||
# Old imports
|
|
||||||
from .config import load_config
|
|
||||||
from .proto import dicttos, stodict
|
|
||||||
from .plugin import PluginRegistry
|
|
||||||
|
|
||||||
# New imports
|
|
||||||
from .config import load_config # Still in client/
|
|
||||||
from ..common.proto import dicttos # Moved to common/
|
|
||||||
from .plugin import PluginRegistry # Still in client/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server Code
|
|
||||||
```python
|
|
||||||
# Old imports
|
|
||||||
from .config import load_config
|
|
||||||
from .proto import stodict
|
|
||||||
from .threshold import AlertLevel
|
|
||||||
|
|
||||||
# New imports
|
|
||||||
from .config import load_config # Server-specific config
|
|
||||||
from ..common.proto import stodict # Moved to common/
|
|
||||||
from ..client.threshold import AlertLevel # Client module
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plugin Code
|
|
||||||
```python
|
|
||||||
# Old import
|
|
||||||
from hbd.plugin import MonitorPlugin
|
|
||||||
|
|
||||||
# New import
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Modular Installation**: Install only what you need
|
|
||||||
- Client-only systems don't need web server dependencies
|
|
||||||
- Server-only systems don't need psutil
|
|
||||||
|
|
||||||
2. **Clearer Architecture**: Explicit separation of concerns
|
|
||||||
- Client: System monitoring and data collection
|
|
||||||
- Server: Heartbeat reception, web UI, notifications
|
|
||||||
- Common: Shared protocol and utilities
|
|
||||||
|
|
||||||
3. **Independent Evolution**: Client and server can evolve separately
|
|
||||||
- Different release cycles possible
|
|
||||||
- Clear API boundaries via common/
|
|
||||||
|
|
||||||
4. **Smaller Footprint**: Reduced dependency installation
|
|
||||||
- Client: ~1 dependency (psutil)
|
|
||||||
- Server: ~4 dependencies (websockets, aiohttp, Jinja2, mattermostdriver)
|
|
||||||
|
|
||||||
## Migration Guide
|
|
||||||
|
|
||||||
### For Existing Installations
|
|
||||||
|
|
||||||
1. **Reinstall the package**:
|
|
||||||
```bash
|
|
||||||
pip install -e ".[all]" # For development
|
|
||||||
# or
|
|
||||||
pip install hbd[all] # For production
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Configuration files remain unchanged**:
|
|
||||||
- Both client and server read from `~/.hb.yaml`
|
|
||||||
- All existing config keys are supported in both configs
|
|
||||||
- Server has additional keys (journal, websocket, email, etc.)
|
|
||||||
- Client has minimal keys (interval, plugins, thresholds)
|
|
||||||
|
|
||||||
3. **Commands remain the same**:
|
|
||||||
- `hbc` command works identically
|
|
||||||
- `hbd` command works identically
|
|
||||||
|
|
||||||
### For New Deployments
|
|
||||||
|
|
||||||
1. **Client-only system** (monitoring host):
|
|
||||||
```bash
|
|
||||||
pip install hbd[client]
|
|
||||||
hbc server1.example.com server2.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Server-only system** (monitoring daemon):
|
|
||||||
```bash
|
|
||||||
pip install hbd[server]
|
|
||||||
hbd -c /etc/hbd.yaml -f
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Combined system** (dev/test):
|
|
||||||
```bash
|
|
||||||
pip install hbd[all]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
All imports and entry points have been tested and validated:
|
|
||||||
- ✅ Package imports work correctly
|
|
||||||
- ✅ `hbc` command entry point functional
|
|
||||||
- ✅ `hbd` command entry point functional
|
|
||||||
- ✅ Optional dependencies properly configured
|
|
||||||
- ✅ All internal imports updated
|
|
||||||
|
|
||||||
## Files Archived
|
|
||||||
|
|
||||||
The following files were renamed to avoid conflicts:
|
|
||||||
- `hbd/config.py` → `hbd/config.py.old` (split into client/server configs)
|
|
||||||
- `hbd/hbc_old.py` → `hbd/hbc_old.py.bak` (backup file)
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Test client functionality with a monitoring host
|
|
||||||
2. Test server functionality with web UI and notifications
|
|
||||||
3. Update documentation (README.md) with new structure
|
|
||||||
4. Consider publishing to PyPI with new structure
|
|
||||||
5. Update any deployment scripts/Dockerfiles to use optional dependencies
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
async def send_sms(hass, user, password, sender_did, call):
|
||||||
|
"""Send SMS message using multipart form-data like MMS."""
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
recipient = call.data.get("recipient")
|
||||||
|
message = call.data.get("message")
|
||||||
|
|
||||||
|
if not recipient or not message:
|
||||||
|
_LOGGER.error("Recipient or message missing.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build form data dictionary
|
||||||
|
form_data = {
|
||||||
|
'api_username': str(user),
|
||||||
|
'api_password': str(password),
|
||||||
|
'did': str(sender_did),
|
||||||
|
'dst': str(recipient),
|
||||||
|
'message': str(message),
|
||||||
|
'method': 'sendSMS'
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
with aiohttp.MultipartWriter("form-data") as mp:
|
||||||
|
for key, value in form_data.items():
|
||||||
|
part = mp.append(value)
|
||||||
|
part.set_content_disposition('form-data', name=key)
|
||||||
|
|
||||||
|
_LOGGER.error("voipms_sms: sending SMS: %s", mp)
|
||||||
|
async with session.post(REST_ENDPOINT, data=mp) as response:
|
||||||
|
response_text = await response.text()
|
||||||
|
if response.status == 200:
|
||||||
|
response_json = json.loads(response_text)
|
||||||
|
if response_json['status'] == "success":
|
||||||
|
_LOGGER.info("voipms_sms: SMS sent successfully: %s", response_text)
|
||||||
|
else:
|
||||||
|
_LOGGER.error("voipms_sms: SMS not sent: %s", response_text)
|
||||||
|
else:
|
||||||
|
_LOGGER.error("voipms_sms: Failed to send SMS. Status: %s, Response: %s", response.status, response_text)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -81,7 +81,6 @@ The following settings **cannot** be reloaded and require a service restart:
|
|||||||
|
|
||||||
- **Logging**
|
- **Logging**
|
||||||
- `logfile` - Log file path
|
- `logfile` - Log file path
|
||||||
- `logfmt` - Log format
|
|
||||||
|
|
||||||
- **Journal Settings**
|
- **Journal Settings**
|
||||||
- `journal_enabled` - Enable/disable journaling
|
- `journal_enabled` - Enable/disable journaling
|
||||||
|
|||||||
+105
-4
@@ -15,12 +15,49 @@ Default port is `50004` (configurable via `hbd_port` in configuration).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
When [user accounts are configured](USERS.md), every request must be authenticated.
|
||||||
|
|
||||||
|
- **Browser requests** to HTML pages are redirected to `/login` automatically. JavaScript `fetch()` calls on the dashboards send the session cookie automatically — no JS changes are needed.
|
||||||
|
- **API / programmatic requests** must include the token in an `Authorization: Bearer <token>` header or an `X-Auth-Token` header.
|
||||||
|
|
||||||
|
Unauthenticated API requests receive `401 Unauthorized`. When no users are configured the server runs in unauthenticated mode and all endpoints are open.
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"alice","password":"secret"}' | jq -r .token)
|
||||||
|
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://localhost:50004/api/0/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
See [User Management](USERS.md) for full authentication documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Method | Path | Description | Auth required |
|
||||||
|
|--------|------|-------------|---------------|
|
||||||
|
| `POST` | `/api/0/auth/login` | Obtain session token | No |
|
||||||
|
| `POST` | `/api/0/auth/logout` | Invalidate session | Token |
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
| Method | Path | Description | Role |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| `GET` | `/api/0/users` | List all users | Admin |
|
||||||
|
| `GET` | `/api/0/users/me` | Own profile | Authenticated |
|
||||||
|
|
||||||
### Host Management
|
### Host Management
|
||||||
|
|
||||||
#### GET /api/0/hosts
|
#### GET /api/0/hosts
|
||||||
Get list of all monitored hosts with their state information.
|
Get list of all monitored hosts with their state information. When auth is enabled, only hosts the caller has at least **monitor** access to are returned.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
@@ -28,6 +65,9 @@ Get list of all monitored hosts with their state information.
|
|||||||
{
|
{
|
||||||
"name": "webserver01",
|
"name": "webserver01",
|
||||||
"dyn": false,
|
"dyn": false,
|
||||||
|
"owner": "alice",
|
||||||
|
"managers": ["bob"],
|
||||||
|
"monitors": ["carol"],
|
||||||
"connections": [...]
|
"connections": [...]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -137,6 +177,32 @@ curl http://localhost:50004/api/0/hosts/database01/plugins/disk_monitor
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Host Access
|
||||||
|
|
||||||
|
#### GET /api/0/hosts/{hostname}/access
|
||||||
|
Get owner/managers/monitors for a host. Requires **monitor** role or higher.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "alice",
|
||||||
|
"managers": ["bob"],
|
||||||
|
"monitors": ["carol"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PUT /api/0/hosts/{hostname}/access
|
||||||
|
Update owner/managers/monitors. Requires **owner** role or admin.
|
||||||
|
|
||||||
|
**Request body** (all fields optional):
|
||||||
|
```json
|
||||||
|
{ "owner": "bob", "managers": ["carol"], "monitors": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes take effect immediately but are not written back to the config file. Update the config file and send `SIGHUP` to make them permanent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Alert Endpoints
|
### Alert Endpoints
|
||||||
|
|
||||||
#### GET /api/0/hosts/{hostname}/alerts
|
#### GET /api/0/hosts/{hostname}/alerts
|
||||||
@@ -226,6 +292,16 @@ curl http://localhost:50004/api/0/alerts | jq .
|
|||||||
|
|
||||||
## Web UI Pages
|
## Web UI Pages
|
||||||
|
|
||||||
|
### Login
|
||||||
|
**URL:** `/login`
|
||||||
|
|
||||||
|
Shown automatically when a browser request is made without a valid session (when users are configured). After successful login the browser is redirected to the originally requested page.
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
**URL:** `/logout`
|
||||||
|
|
||||||
|
Clears the session cookie and redirects to `/login`.
|
||||||
|
|
||||||
### Live Dashboard
|
### Live Dashboard
|
||||||
**URL:** `/live`
|
**URL:** `/live`
|
||||||
|
|
||||||
@@ -288,7 +364,13 @@ Comprehensive alert monitoring:
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Check for critical alerts and send notification
|
# Check for critical alerts and send notification
|
||||||
|
|
||||||
RESPONSE=$(curl -s http://localhost:50004/api/0/alerts)
|
# Log in first (when auth is configured)
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"monitor","password":"secret"}' | jq -r .token)
|
||||||
|
AUTH="-H \"Authorization: Bearer $TOKEN\""
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s $AUTH http://localhost:50004/api/0/alerts)
|
||||||
CRITICAL_COUNT=$(echo "$RESPONSE" | jq '.summary.critical')
|
CRITICAL_COUNT=$(echo "$RESPONSE" | jq '.summary.critical')
|
||||||
|
|
||||||
if [ "$CRITICAL_COUNT" -gt 0 ]; then
|
if [ "$CRITICAL_COUNT" -gt 0 ]; then
|
||||||
@@ -305,8 +387,16 @@ fi
|
|||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
BASE = 'http://localhost:50004'
|
||||||
|
|
||||||
|
# Log in (skip if auth not configured)
|
||||||
|
resp = requests.post(f'{BASE}/api/0/auth/login',
|
||||||
|
json={"username": "alice", "password": "secret"})
|
||||||
|
token = resp.json().get("token")
|
||||||
|
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||||
|
|
||||||
# Get all plugin data for a host
|
# Get all plugin data for a host
|
||||||
response = requests.get('http://localhost:50004/api/0/hosts/webserver01/plugins')
|
response = requests.get(f'{BASE}/api/0/hosts/webserver01/plugins', headers=headers)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
print(f"Host: {data['hostname']}")
|
print(f"Host: {data['hostname']}")
|
||||||
@@ -318,7 +408,7 @@ for plugin, info in data['plugins'].items():
|
|||||||
print(f" {metric}: {value}")
|
print(f" {metric}: {value}")
|
||||||
|
|
||||||
# Check for alerts
|
# Check for alerts
|
||||||
response = requests.get('http://localhost:50004/api/0/alerts')
|
response = requests.get(f'{BASE}/api/0/alerts', headers=headers)
|
||||||
alerts = response.json()
|
alerts = response.json()
|
||||||
|
|
||||||
if alerts['summary']['critical'] > 0:
|
if alerts['summary']['critical'] > 0:
|
||||||
@@ -389,6 +479,8 @@ API errors return appropriate HTTP status codes with JSON:
|
|||||||
**Common Status Codes:**
|
**Common Status Codes:**
|
||||||
- `200 OK` - Success
|
- `200 OK` - Success
|
||||||
- `400 Bad Request` - Invalid parameters
|
- `400 Bad Request` - Invalid parameters
|
||||||
|
- `401 Unauthorized` - Missing or invalid session token
|
||||||
|
- `403 Forbidden` - Authenticated but insufficient role
|
||||||
- `404 Not Found` - Resource not found
|
- `404 Not Found` - Resource not found
|
||||||
- `500 Internal Server Error` - Server error
|
- `500 Internal Server Error` - Server error
|
||||||
|
|
||||||
@@ -506,6 +598,14 @@ for route in list(app.router.routes()):
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### API Returns 401
|
||||||
|
- Auth is configured — include `Authorization: Bearer <token>` header
|
||||||
|
- Token may have expired (24 h TTL) — log in again
|
||||||
|
|
||||||
|
### API Returns 403
|
||||||
|
- Authenticated user lacks the required role for this host/action
|
||||||
|
- Check host's `owner`, `managers`, `monitors` config
|
||||||
|
|
||||||
### API Returns 404
|
### API Returns 404
|
||||||
- Verify hostname in URL matches actual host name
|
- Verify hostname in URL matches actual host name
|
||||||
- Check host is sending heartbeats: `curl http://localhost:50004/api/0/hosts`
|
- Check host is sending heartbeats: `curl http://localhost:50004/api/0/hosts`
|
||||||
@@ -525,6 +625,7 @@ for route in list(app.router.routes()):
|
|||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
|
- [User Management](USERS.md)
|
||||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT.md)
|
- [Plugin Development Guide](PLUGIN_DEVELOPMENT.md)
|
||||||
- [Threshold Alerting Documentation](THRESHOLD_ALERTING.md)
|
- [Threshold Alerting Documentation](THRESHOLD_ALERTING.md)
|
||||||
- [Message Journal Documentation](MESSAGE_JOURNAL.md)
|
- [Message Journal Documentation](MESSAGE_JOURNAL.md)
|
||||||
|
|||||||
@@ -104,11 +104,6 @@ The `nagios_runner` plugin collects:
|
|||||||
- `{name}_{metric}_min` - Minimum value (if present)
|
- `{name}_{metric}_min` - Minimum value (if present)
|
||||||
- `{name}_{metric}_max` - Maximum value (if present)
|
- `{name}_{metric}_max` - Maximum value (if present)
|
||||||
|
|
||||||
**Overall:**
|
|
||||||
- `overall_status` - Worst status from all commands
|
|
||||||
- `overall_status_code` - Worst status code
|
|
||||||
- `plugin_count` - Number of Nagios plugins executed
|
|
||||||
|
|
||||||
## Configuration Options
|
## Configuration Options
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+237
-475
@@ -2,532 +2,294 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Heartbeat Monitoring System includes a flexible notification system that can send alerts through multiple channels including Email, Pushover, Signal, and Mattermost. The system supports centralized channel definitions with per-host routing, allowing fine-grained control over notification delivery.
|
Notifications are dispatched to the **owner and managers** of a host, each via their own configured notification channels. Channel definitions are global; users reference them by name. No users configured → no notifications sent.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Components
|
```
|
||||||
|
Alert event (udp.py / threshold.py)
|
||||||
|
└─ notify.send_notification(host_name, Notification)
|
||||||
|
├─ look up host.owner + host.managers
|
||||||
|
├─ for each user → user.notification_channels
|
||||||
|
└─ for each channel → _dispatch_to_channel (filtered by min_level)
|
||||||
|
```
|
||||||
|
|
||||||
1. **Notification Channels** (`notification_channels` in config)
|
Every notification carries:
|
||||||
- Centralized definitions of notification providers
|
- **title** — `[LEVEL] hostname` (e.g. `[CRITICAL] webserver01`)
|
||||||
- Each channel has a type and type-specific credentials
|
- **body** — detail message (metric value, threshold, duration)
|
||||||
- Reusable across multiple hosts
|
- **url** — link to the plugin metrics page (`{base_url}/plugins#{hostname}`)
|
||||||
|
- **level** — `RECOVER | WARNING | CRITICAL | INFO`
|
||||||
2. **Channel Dispatcher** (`hbd/server/notify.py`)
|
|
||||||
- `pushmsg_for_host(hostname, message)`: Main entry point for host-specific notifications
|
|
||||||
- `_dispatch_to_channel(channel_name, channel_config, message)`: Routes to specific provider
|
|
||||||
- Provider functions: `pushover()`, `pushsignal()`, `pushmattermost()`, `send_email()`
|
|
||||||
|
|
||||||
3. **Configuration Utilities** (`hbd/server/config.py`)
|
|
||||||
- `get_notification_channels_for_host(config, hostname)`: Retrieves channel names for a host
|
|
||||||
- `get_notification_channels_config(config, hostname)`: Retrieves full channel configurations
|
|
||||||
- `get_channel_config(config, channel_name)`: Gets configuration for a specific channel
|
|
||||||
|
|
||||||
4. **Integration Points**
|
|
||||||
- **Threshold alerts**: `threshold.py` calls `notify_mod.pushmsg_for_host()`
|
|
||||||
- **Heartbeat events**: `udp.py` calls `notify_mod.pushmsg_for_host()` for boot/shutdown/overdue
|
|
||||||
- **Custom alerts**: Any code can call `notify_mod.pushmsg_for_host(hostname, message)`
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Centralized Channel Definitions
|
### Base URL
|
||||||
|
|
||||||
Define notification channels once in your configuration file:
|
Set `base_url` so notification links point to your hbd instance:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
base_url: https://hbd.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global channel definitions
|
||||||
|
|
||||||
|
Define channels once; reference them by name from user configs:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
notification_channels:
|
notification_channels:
|
||||||
# Signal notifications
|
|
||||||
signal_ops:
|
pushover_ops:
|
||||||
type: signal
|
type: pushover
|
||||||
cli_path: /usr/local/bin/signal-cli
|
token: your-app-token
|
||||||
user: +1234567890 # Your Signal number
|
user: your-user-key
|
||||||
recipient: +1234567890 # Recipient number
|
min_level: WARNING # optional, default: WARNING
|
||||||
|
|
||||||
signal_oncall:
|
|
||||||
type: signal
|
|
||||||
cli_path: /usr/local/bin/signal-cli
|
|
||||||
user: +1234567890
|
|
||||||
recipient: +0987654321 # Different recipient
|
|
||||||
|
|
||||||
# Email notifications
|
|
||||||
email_ops:
|
email_ops:
|
||||||
type: email
|
type: email
|
||||||
recipients:
|
recipients: [ops@example.com]
|
||||||
- ops@example.com
|
sender: hbd@example.com
|
||||||
- alerts@example.com
|
|
||||||
sender: heartbeat@example.com
|
|
||||||
smtp_server: smtp.example.com
|
smtp_server: smtp.example.com
|
||||||
smtp_port: 587
|
smtp_port: 587
|
||||||
smtp_user: heartbeat@example.com
|
smtp_user: hbd@example.com
|
||||||
smtp_password: your-smtp-password
|
smtp_password: secret
|
||||||
|
min_level: WARNING
|
||||||
email_devteam:
|
|
||||||
type: email
|
|
||||||
recipients: [dev-alerts@example.com]
|
|
||||||
sender: heartbeat-dev@example.com
|
|
||||||
smtp_server: smtp.example.com
|
|
||||||
smtp_port: 587
|
|
||||||
smtp_user: heartbeat-dev@example.com
|
|
||||||
smtp_password: your-smtp-password
|
|
||||||
|
|
||||||
# Pushover notifications
|
|
||||||
pushover_urgent:
|
|
||||||
type: pushover
|
|
||||||
token: your-pushover-app-token
|
|
||||||
user: your-pushover-user-key
|
|
||||||
|
|
||||||
pushover_normal:
|
|
||||||
type: pushover
|
|
||||||
token: your-pushover-app-token
|
|
||||||
user: another-user-key
|
|
||||||
|
|
||||||
# Mattermost notifications
|
|
||||||
mattermost_devops:
|
|
||||||
type: mattermost
|
|
||||||
host: mattermost.example.com
|
|
||||||
token: your-webhook-token
|
|
||||||
channel: devops-alerts
|
|
||||||
username: heartbeat-bot
|
|
||||||
icon: https://example.com/heartbeat-icon.png
|
|
||||||
```
|
|
||||||
|
|
||||||
### Default Notification Channels
|
matrix_oncall:
|
||||||
|
type: matrix
|
||||||
|
homeserver: https://matrix.example.org
|
||||||
|
access_token: syt_xxx
|
||||||
|
room_id: "!abc:matrix.example.org"
|
||||||
|
min_level: CRITICAL # only send critical alerts to this room
|
||||||
|
|
||||||
Specify default channels for hosts that don't have specific channel assignments:
|
sms_oncall:
|
||||||
|
type: sms_voipms
|
||||||
|
api_user: me@example.com
|
||||||
|
api_password: secret
|
||||||
|
did: "5551234567" # your voip.ms DID number
|
||||||
|
dst: "5559876543" # destination number
|
||||||
|
min_level: CRITICAL
|
||||||
|
|
||||||
```yaml
|
signal_ops:
|
||||||
default_notification_channels:
|
|
||||||
- email_ops
|
|
||||||
- mattermost_devops
|
|
||||||
```
|
|
||||||
|
|
||||||
Hosts without `notification_channels` defined will use these defaults.
|
|
||||||
|
|
||||||
### Per-Host Channel Assignment
|
|
||||||
|
|
||||||
Assign specific channels to each host in the `hosts` section:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
hosts:
|
|
||||||
# Critical production web server - multiple channels for redundancy
|
|
||||||
prod-web-01:
|
|
||||||
threshold_config: high_sensitivity
|
|
||||||
watch: true
|
|
||||||
notification_channels:
|
|
||||||
- signal_oncall # Immediate mobile notification
|
|
||||||
- pushover_urgent # Secondary mobile notification
|
|
||||||
- email_ops # Email for record keeping
|
|
||||||
dyndns: false
|
|
||||||
|
|
||||||
# Database server - ops team notifications only
|
|
||||||
prod-db-01:
|
|
||||||
threshold_config: database
|
|
||||||
watch: true
|
|
||||||
notification_channels:
|
|
||||||
- signal_ops
|
|
||||||
- email_ops
|
|
||||||
dyndns: false
|
|
||||||
|
|
||||||
# Development server - email only, no urgent notifications
|
|
||||||
dev-server-01:
|
|
||||||
threshold_config: low_sensitivity
|
|
||||||
watch: false
|
|
||||||
notification_channels:
|
|
||||||
- email_devteam
|
|
||||||
dyndns: false
|
|
||||||
|
|
||||||
# Test server - uses default_notification_channels
|
|
||||||
test-server-01:
|
|
||||||
threshold_config: default
|
|
||||||
watch: false
|
|
||||||
dyndns: false
|
|
||||||
# No notification_channels specified = uses default_notification_channels
|
|
||||||
```
|
|
||||||
|
|
||||||
## Channel Types
|
|
||||||
|
|
||||||
### Email
|
|
||||||
|
|
||||||
Sends notifications via SMTP.
|
|
||||||
|
|
||||||
**Configuration fields:**
|
|
||||||
```yaml
|
|
||||||
type: email
|
|
||||||
recipients: [email1@example.com, email2@example.com] # Required: List of recipients
|
|
||||||
sender: heartbeat@example.com # Required: From address
|
|
||||||
smtp_server: smtp.example.com # Required: SMTP server hostname
|
|
||||||
smtp_port: 587 # Optional: Default 587
|
|
||||||
smtp_user: heartbeat@example.com # Optional: For authenticated SMTP
|
|
||||||
smtp_password: your-password # Optional: For authenticated SMTP
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Supports multiple recipients
|
|
||||||
- TLS/STARTTLS support on port 587
|
|
||||||
- Authenticated and unauthenticated SMTP
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```yaml
|
|
||||||
notification_channels:
|
|
||||||
email_critical:
|
|
||||||
type: email
|
|
||||||
recipients: [admin@example.com, oncall@example.com]
|
|
||||||
sender: alerts@example.com
|
|
||||||
smtp_server: smtp.fastmail.com
|
|
||||||
smtp_port: 587
|
|
||||||
smtp_user: alerts@example.com
|
|
||||||
smtp_password: app-specific-password
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pushover
|
|
||||||
|
|
||||||
Sends push notifications to mobile devices via Pushover API.
|
|
||||||
|
|
||||||
**Configuration fields:**
|
|
||||||
```yaml
|
|
||||||
type: pushover
|
|
||||||
token: your-application-token # Required: Your Pushover app token
|
|
||||||
user: your-user-key # Required: Recipient's user key
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Instant mobile push notifications
|
|
||||||
- Works on iOS and Android
|
|
||||||
- Supports delivery confirmations
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
1. Create a Pushover account at https://pushover.net
|
|
||||||
2. Create an application to get your app token
|
|
||||||
3. Note your user key from your account dashboard
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```yaml
|
|
||||||
notification_channels:
|
|
||||||
pushover_admin:
|
|
||||||
type: pushover
|
|
||||||
token: azGDORePK8gMaC0QOYAMyEEuzJnyUi
|
|
||||||
user: uQiRzpo4DXghDmr9QzzfQu27cmVRsG
|
|
||||||
```
|
|
||||||
|
|
||||||
### Signal
|
|
||||||
|
|
||||||
Sends notifications via Signal messenger using signal-cli.
|
|
||||||
|
|
||||||
**Configuration fields:**
|
|
||||||
```yaml
|
|
||||||
type: signal
|
|
||||||
cli_path: /usr/local/bin/signal-cli # Optional: Path to signal-cli binary
|
|
||||||
user: +1234567890 # Required: Your Signal phone number
|
|
||||||
recipient: +0987654321 # Required: Recipient phone number
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
1. Install signal-cli: https://github.com/AsamK/signal-cli
|
|
||||||
2. Register signal-cli with your phone number:
|
|
||||||
```bash
|
|
||||||
signal-cli -u +1234567890 register
|
|
||||||
signal-cli -u +1234567890 verify CODE
|
|
||||||
```
|
|
||||||
3. Ensure signal-cli is in PATH or specify full path in config
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- End-to-end encrypted messaging
|
|
||||||
- Works without phone being online
|
|
||||||
- No API fees or rate limits
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```yaml
|
|
||||||
notification_channels:
|
|
||||||
signal_admin:
|
|
||||||
type: signal
|
type: signal
|
||||||
cli_path: /usr/local/bin/signal-cli
|
cli_path: /usr/local/bin/signal-cli
|
||||||
user: +12025551234
|
user: +12025551234
|
||||||
recipient: +12025559999
|
recipient: +12025559999
|
||||||
```
|
|
||||||
|
|
||||||
### Mattermost
|
mattermost_devops:
|
||||||
|
|
||||||
Sends notifications to Mattermost team chat via incoming webhooks.
|
|
||||||
|
|
||||||
**Configuration fields:**
|
|
||||||
```yaml
|
|
||||||
type: mattermost
|
|
||||||
host: mattermost.example.com # Required: Mattermost server hostname
|
|
||||||
token: your-webhook-token # Required: Incoming webhook token
|
|
||||||
channel: channel-name # Required: Target channel name
|
|
||||||
username: heartbeat-bot # Optional: Bot display name
|
|
||||||
icon: https://example.com/icon.png # Optional: Bot icon URL
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
1. Enable incoming webhooks in Mattermost
|
|
||||||
2. Create an incoming webhook for your team
|
|
||||||
3. Note the webhook token from the webhook URL
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Team-wide visibility
|
|
||||||
- Rich formatting support
|
|
||||||
- Message threading
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```yaml
|
|
||||||
notification_channels:
|
|
||||||
mattermost_ops:
|
|
||||||
type: mattermost
|
type: mattermost
|
||||||
host: chat.example.com
|
host: mattermost.example.com
|
||||||
token: abc123def456ghi789
|
token: webhook-token
|
||||||
channel: infrastructure-alerts
|
channel: devops-alerts
|
||||||
username: heartbeat-monitor
|
username: heartbeat-bot
|
||||||
icon: https://example.com/heartbeat-icon.png
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notification Events
|
### Users with notification channels
|
||||||
|
|
||||||
The system sends notifications for various events:
|
Each user lists which global channels they receive notifications on:
|
||||||
|
|
||||||
### Threshold Alerts
|
```yaml
|
||||||
|
users:
|
||||||
|
alice:
|
||||||
|
full_name: Alice Smith
|
||||||
|
password: pbkdf2:sha256:...
|
||||||
|
admin: true
|
||||||
|
notification_channels: [pushover_ops, email_ops]
|
||||||
|
|
||||||
When monitored metrics exceed configured thresholds:
|
bob:
|
||||||
|
full_name: Bob Jones
|
||||||
- **State changes**: OK → WARNING, WARNING → CRITICAL, CRITICAL → OK
|
password: pbkdf2:sha256:...
|
||||||
- **Format**: `{LEVEL}: {hostname} - {metric_path} = {value} {threshold_info}`
|
notification_channels: [sms_oncall, matrix_oncall]
|
||||||
- **Example**: `CRITICAL: prod-web-01 - cpu_monitor.cpu_percent = 95.2 (threshold: > 90.0)`
|
|
||||||
- **Re-notifications**: Periodic reminders for ongoing alerts (default: hourly)
|
|
||||||
|
|
||||||
### Heartbeat Events
|
|
||||||
|
|
||||||
Host lifecycle events:
|
|
||||||
|
|
||||||
- **Host boot**: `{hostname} booted`
|
|
||||||
- **Host shutdown**: `{hostname} {connection_type} shutdown`
|
|
||||||
- **Host recovery**: `{hostname} {connection_type} is back`
|
|
||||||
- **Connection issues**: `{hostname} {message}`
|
|
||||||
- **Host overdue**: `{hostname} {connection_type} overdue`
|
|
||||||
|
|
||||||
Only hosts with `watch: true` send heartbeat event notifications.
|
|
||||||
|
|
||||||
### Custom Alerts
|
|
||||||
|
|
||||||
Application code can send custom notifications:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from hbd.server import notify as notify_mod
|
|
||||||
|
|
||||||
# Send to host-specific channels
|
|
||||||
notify_mod.pushmsg_for_host("prod-web-01", "Custom alert message")
|
|
||||||
|
|
||||||
# Send using global config
|
|
||||||
notify_mod.pushmsg_from_config("Global notification")
|
|
||||||
|
|
||||||
# Send to specific config
|
|
||||||
notify_mod.pushmsg(custom_config_dict, "Targeted notification")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design Principles
|
### Host access — owner and managers
|
||||||
|
|
||||||
The notification system follows these core principles:
|
Notifications for a host go to its owner and all managers:
|
||||||
|
|
||||||
- **Centralization**: Define notification providers once, reference them by name
|
|
||||||
- **Flexibility**: Each host can use different channels for different notification needs
|
|
||||||
- **Redundancy**: Critical hosts can specify multiple channels for failover
|
|
||||||
- **Clarity**: Clean separation between channel definition and channel assignment
|
|
||||||
- **Type Safety**: Provider-specific validation at configuration time
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Channel Organization
|
|
||||||
|
|
||||||
- **Create purpose-specific channels**: `email_ops`, `signal_oncall`, `pushover_urgent`
|
|
||||||
- **Separate by team/role**: `email_devteam`, `signal_dbateam`, `mattermost_security`
|
|
||||||
- **Use descriptive names**: Channel names appear in logs and debugging
|
|
||||||
|
|
||||||
### Redundancy
|
|
||||||
|
|
||||||
For critical hosts, use multiple notification channels:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
hosts:
|
hosts:
|
||||||
critical-db:
|
webserver01:
|
||||||
notification_channels:
|
owner: alice # receives all notifications for this host
|
||||||
- signal_oncall # Primary: Mobile alert
|
managers: [bob] # also receives notifications
|
||||||
- pushover_urgent # Backup: Different mobile platform
|
threshold_config: default
|
||||||
- email_ops # Tertiary: Email for record-keeping
|
watch: true # bold in dashboard (cosmetic only)
|
||||||
|
dyndns: false
|
||||||
|
|
||||||
|
dbserver01:
|
||||||
|
owner: alice
|
||||||
|
managers: [bob]
|
||||||
|
threshold_config: database
|
||||||
|
dyndns: false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Notification Fatigue Prevention
|
`watch: true` only affects display (bold name in the live dashboard). Notifications are now controlled entirely by owner/managers.
|
||||||
|
|
||||||
- **Use `watch: false`** for non-critical hosts
|
## Channel Types
|
||||||
- **Configure appropriate thresholds** to avoid false positives
|
|
||||||
- **Set different channels for different severities**
|
|
||||||
- **Use `default_notification_channels`** for baseline, add more for critical systems
|
|
||||||
|
|
||||||
### Security
|
### `min_level` filtering
|
||||||
|
|
||||||
- **Protect credentials**: Use file permissions to protect config files with passwords/tokens
|
Every channel accepts an optional `min_level` field:
|
||||||
- **Rotate tokens**: Periodically rotate API tokens and passwords
|
|
||||||
- **Use app-specific passwords**: For email, use app-specific passwords instead of main account password
|
|
||||||
- **Separate accounts**: Consider separate notification accounts for different environments (prod vs dev)
|
|
||||||
|
|
||||||
### Testing
|
| Value | Channels receive |
|
||||||
|
|---|---|
|
||||||
|
| `WARNING` (default) | WARNING, CRITICAL, RECOVER |
|
||||||
|
| `CRITICAL` | CRITICAL only (and RECOVER) |
|
||||||
|
|
||||||
Test notification channels before relying on them:
|
`RECOVER` is always passed through — you don't want to miss a recovery.
|
||||||
|
|
||||||
|
### pushover
|
||||||
|
|
||||||
|
Sends push notifications via [Pushover](https://pushover.net). Includes title, body, and a clickable URL.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: pushover
|
||||||
|
token: your-app-token # Required: Pushover application token
|
||||||
|
user: your-user-key # Required: Recipient's user key
|
||||||
|
min_level: WARNING
|
||||||
|
```
|
||||||
|
|
||||||
|
### email
|
||||||
|
|
||||||
|
Sends via SMTP. Subject = title, body = message + URL on final line.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: email
|
||||||
|
recipients: [ops@example.com, oncall@example.com]
|
||||||
|
sender: hbd@example.com
|
||||||
|
smtp_server: smtp.example.com
|
||||||
|
smtp_port: 587 # 587 = STARTTLS (default), 465 = SSL
|
||||||
|
smtp_user: hbd@example.com
|
||||||
|
smtp_password: secret
|
||||||
|
min_level: WARNING
|
||||||
|
```
|
||||||
|
|
||||||
|
### matrix
|
||||||
|
|
||||||
|
Sends a formatted HTML message to a Matrix room via [matrix-nio](https://github.com/poljar/matrix-nio).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: matrix
|
||||||
|
homeserver: https://matrix.example.org
|
||||||
|
access_token: syt_xxx # Bot account access token
|
||||||
|
room_id: "!abc:matrix.example.org"
|
||||||
|
min_level: WARNING
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
1. Create a bot Matrix account
|
||||||
|
2. Obtain its access token (Element → Settings → Help & About → Access Token)
|
||||||
|
3. Invite the bot to the target room and note the room ID
|
||||||
|
|
||||||
|
### sms_voipms
|
||||||
|
|
||||||
|
Sends SMS via the [voip.ms REST API](https://voip.ms/api/v1/rest.php). Message is truncated to 160 characters.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: sms_voipms
|
||||||
|
api_user: me@example.com # voip.ms account email
|
||||||
|
api_password: secret # voip.ms API password
|
||||||
|
did: "5551234567" # Your voip.ms DID (sending number)
|
||||||
|
dst: "5559876543" # Destination number
|
||||||
|
min_level: CRITICAL
|
||||||
|
```
|
||||||
|
|
||||||
|
### signal
|
||||||
|
|
||||||
|
Sends via [signal-cli](https://github.com/AsamK/signal-cli).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: signal
|
||||||
|
cli_path: /usr/local/bin/signal-cli
|
||||||
|
user: +12025551234 # Your registered Signal number
|
||||||
|
recipient: +12025559999 # Recipient number
|
||||||
|
min_level: WARNING
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
```bash
|
```bash
|
||||||
# Test signal-cli directly
|
signal-cli -u +12025551234 register
|
||||||
signal-cli -u +1234567890 send -m "Test message" +0987654321
|
signal-cli -u +12025551234 verify CODE
|
||||||
|
|
||||||
# Test SMTP
|
|
||||||
echo "Test" | mail -s "Test Subject" admin@example.com
|
|
||||||
|
|
||||||
# Test through heartbeat system (Python REPL)
|
|
||||||
from hbd.server import notify as notify_mod, config as config_mod
|
|
||||||
cfg = config_mod.load_config(".hb.yaml")
|
|
||||||
notify_mod.setup(cfg)
|
|
||||||
notify_mod.pushmsg_for_host("test-host", "Test notification")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### mattermost
|
||||||
|
|
||||||
|
Sends via Mattermost incoming webhook. Message is formatted as Markdown.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: mattermost
|
||||||
|
host: mattermost.example.com
|
||||||
|
token: your-webhook-token
|
||||||
|
channel: devops-alerts
|
||||||
|
username: heartbeat-bot # Optional: display name
|
||||||
|
icon: https://…/icon.png # Optional: bot icon URL
|
||||||
|
min_level: WARNING
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notification events
|
||||||
|
|
||||||
|
| Source | Level | Title example | Body example |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Host overdue | CRITICAL | `[CRITICAL] webserver01` | `IPv4 overdue` |
|
||||||
|
| Host recover | RECOVER | `[RECOVER] webserver01` | `IPv4 back after being overdue for 5:23` |
|
||||||
|
| Host boot | INFO | `[INFO] webserver01` | `webserver01 booted` |
|
||||||
|
| Host shutdown | INFO | `[INFO] webserver01` | `IPv4 shutdown` |
|
||||||
|
| Threshold breach | WARNING/CRITICAL | `[CRITICAL] webserver01` | `cpu_percent = 95.2 (threshold: > 90.0)` |
|
||||||
|
| Threshold reminder | CRITICAL | `[REMINDER/CRITICAL] webserver01` | `REMINDER (CRITICAL): … ongoing for 3600s` |
|
||||||
|
| Connection issue | WARNING | `[WARNING] webserver01` | `new address detected …` |
|
||||||
|
|
||||||
|
Reminder notifications (re-notify) are sent only for CRITICAL level alerts.
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
### `send_notification(host_name, notif) -> dict`
|
||||||
|
|
||||||
|
Main entry point. Dispatches to owner + managers.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from hbd.server.notify import send_notification, Notification
|
||||||
|
|
||||||
|
send_notification(
|
||||||
|
"webserver01",
|
||||||
|
Notification(
|
||||||
|
title="[CRITICAL] webserver01",
|
||||||
|
body="cpu_percent = 95.2 (threshold: > 90.0)",
|
||||||
|
level="CRITICAL",
|
||||||
|
url="https://hbd.example.com/plugins#webserver01",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{channel_name: bool}` for each channel dispatched.
|
||||||
|
|
||||||
|
### `setup(cfg, loop=None)`
|
||||||
|
|
||||||
|
Called once at startup from `main.py`. Pass the running asyncio event loop so Matrix sends work correctly.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Notifications Not Sending
|
**No notifications sent:**
|
||||||
|
- Check that users are configured (`users:` section in yaml)
|
||||||
|
- Check that the host has an `owner` or `managers` set
|
||||||
|
- Check that users have `notification_channels` listed
|
||||||
|
- Check that the channel names in user config match keys under `notification_channels:`
|
||||||
|
|
||||||
1. **Check logs**: Look for "Failed to send notification" errors
|
**min_level filtering too aggressive:**
|
||||||
2. **Verify host is watched**: Ensure `watch: true` in host definition
|
- Default is `WARNING` — both WARNING and CRITICAL are sent
|
||||||
3. **Check channel configuration**: Verify credentials and settings
|
- Set `min_level: WARNING` explicitly if you were expecting warnings but set CRITICAL
|
||||||
4. **Test channel directly**: Use command-line tools to test provider
|
|
||||||
5. **Check network**: Ensure server can reach notification endpoints
|
|
||||||
|
|
||||||
### Signal Issues
|
**Matrix sends time out:**
|
||||||
|
- Verify the access token is valid and the bot is in the room
|
||||||
|
- `matrix-nio` must be installed: `pip install matrix-nio`
|
||||||
|
|
||||||
- **signal-cli not found**: Specify full path in `cli_path`
|
**voip.ms SMS fails:**
|
||||||
- **Not registered**: Run `signal-cli -u +NUMBER register` and verify
|
- Enable the API in your voip.ms account (Account → API)
|
||||||
- **Trust issues**: Run `signal-cli -u +NUMBER receive` to sync trust store
|
- Verify the DID is SMS-capable in your voip.ms account
|
||||||
- **Recipient not found**: Ensure recipient is in your Signal contacts
|
|
||||||
|
|
||||||
### Email Issues
|
**Signal not found:**
|
||||||
|
- Specify full `cli_path`
|
||||||
|
- Run `signal-cli -u +NUMBER receive` to sync trust store
|
||||||
|
|
||||||
- **Authentication failed**: Check SMTP username/password
|
**Email authentication failed:**
|
||||||
- **TLS errors**: Verify SMTP port (587 for STARTTLS, 465 for SSL)
|
- Use app-specific passwords for Gmail/Fastmail
|
||||||
- **Relay denied**: Ensure SMTP server allows relay from your IP
|
- Verify port: 587 for STARTTLS, 465 for SSL
|
||||||
- **Timeout**: Check firewall rules for SMTP ports
|
|
||||||
|
|
||||||
### Pushover Issues
|
**Pushover `400` errors:**
|
||||||
|
- Double-check `token` (app) and `user` (user key) — they are different values
|
||||||
- **Invalid token/user**: Verify token and user key from Pushover dashboard
|
|
||||||
- **API rate limits**: Pushover has monthly message limits on free tier
|
|
||||||
- **HTTP errors**: Check Pushover API status page
|
|
||||||
|
|
||||||
### Mattermost Issues
|
|
||||||
|
|
||||||
- **Webhook not found**: Verify webhook token and ensure webhook is enabled
|
|
||||||
- **Channel not found**: Check channel name spelling and permissions
|
|
||||||
- **Driver import error**: Install mattermostdriver: `pip install mattermostdriver`
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### Main Functions
|
|
||||||
|
|
||||||
#### `pushmsg_for_host(hostname: str, msg: str, debug: int = 0) -> dict`
|
|
||||||
|
|
||||||
Send notification to host-specific channels.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `hostname`: Name of the host (used to look up notification channels)
|
|
||||||
- `msg`: Message to send
|
|
||||||
- `debug`: Debug level (0=no debug, 1+=debug output)
|
|
||||||
|
|
||||||
**Returns:** Dictionary of results per channel: `{"signal_ops": True, "email_ops": False}`
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```python
|
|
||||||
from hbd.server import notify as notify_mod
|
|
||||||
|
|
||||||
notify_mod.pushmsg_for_host("prod-web-01", "Server CPU at 95%")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
1. Looks up notification channels configured for the host
|
|
||||||
2. If no host-specific channels, uses `default_notification_channels`
|
|
||||||
3. Dispatches to each channel in parallel
|
|
||||||
4. Returns dict of results keyed by channel name
|
|
||||||
5. Logs success/failure for each channel
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Complete Configuration Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Notification channel definitions
|
|
||||||
notification_channels:
|
|
||||||
signal_oncall:
|
|
||||||
type: signal
|
|
||||||
cli_path: /usr/local/bin/signal-cli
|
|
||||||
user: +12025551234
|
|
||||||
recipient: +12025555678
|
|
||||||
|
|
||||||
email_ops:
|
|
||||||
type: email
|
|
||||||
recipients: [ops@example.com, alerts@example.com]
|
|
||||||
sender: heartbeat@example.com
|
|
||||||
smtp_server: smtp.fastmail.com
|
|
||||||
smtp_port: 587
|
|
||||||
smtp_user: heartbeat@example.com
|
|
||||||
smtp_password: app-password-here
|
|
||||||
|
|
||||||
# Default channels
|
|
||||||
default_notification_channels: [email_ops]
|
|
||||||
|
|
||||||
# Host definitions with channel assignments
|
|
||||||
hosts:
|
|
||||||
prod-web-01:
|
|
||||||
threshold_config: high_sensitivity
|
|
||||||
watch: true
|
|
||||||
notification_channels: [signal_oncall, email_ops]
|
|
||||||
dyndns: false
|
|
||||||
|
|
||||||
dev-server-01:
|
|
||||||
threshold_config: low_sensitivity
|
|
||||||
watch: false
|
|
||||||
notification_channels: [email_ops]
|
|
||||||
dyndns: false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple Environments Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
notification_channels:
|
|
||||||
# Production channels
|
|
||||||
signal_prod_oncall:
|
|
||||||
type: signal
|
|
||||||
user: +12025551234
|
|
||||||
recipient: +12025551111 # On-call phone
|
|
||||||
|
|
||||||
email_prod_ops:
|
|
||||||
type: email
|
|
||||||
recipients: [prod-ops@example.com]
|
|
||||||
sender: prod-heartbeat@example.com
|
|
||||||
smtp_server: smtp.example.com
|
|
||||||
|
|
||||||
# Staging channels
|
|
||||||
email_staging:
|
|
||||||
type: email
|
|
||||||
recipients: [staging-alerts@example.com]
|
|
||||||
sender: staging-heartbeat@example.com
|
|
||||||
smtp_server: smtp.example.com
|
|
||||||
|
|
||||||
# Development channels
|
|
||||||
mattermost_dev:
|
|
||||||
type: mattermost
|
|
||||||
host: chat.example.com
|
|
||||||
token: dev-webhook-token
|
|
||||||
channel: dev-alerts
|
|
||||||
|
|
||||||
hosts:
|
|
||||||
prod-api-01:
|
|
||||||
notification_channels: [signal_prod_oncall, email_prod_ops]
|
|
||||||
|
|
||||||
staging-api-01:
|
|
||||||
notification_channels: [email_staging]
|
|
||||||
|
|
||||||
dev-api-01:
|
|
||||||
notification_channels: [mattermost_dev]
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ This guide explains how to create custom plugins for the Heartbeat monitoring sy
|
|||||||
- [Plugin Types](#plugin-types)
|
- [Plugin Types](#plugin-types)
|
||||||
- [Creating a Plugin](#creating-a-plugin)
|
- [Creating a Plugin](#creating-a-plugin)
|
||||||
- [Plugin Lifecycle](#plugin-lifecycle)
|
- [Plugin Lifecycle](#plugin-lifecycle)
|
||||||
|
- [Server-initiated InfoPlugin refresh](#server-initiated-infoplugin-refresh)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Best Practices](#best-practices)
|
- [Best Practices](#best-practices)
|
||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
@@ -250,6 +251,28 @@ Understanding the plugin lifecycle helps you implement plugins correctly:
|
|||||||
└─> Plugin releases resources, closes connections
|
└─> Plugin releases resources, closes connections
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Server-initiated InfoPlugin refresh
|
||||||
|
|
||||||
|
When a heartbeat packet arrives from a host the server has no plugin data for (e.g. after a server restart), the server sets `request_update = 1` in the ACK reply. The client detects this flag and immediately re-runs all InfoPlugins — clearing their cached results first — then resends the data as PLG messages.
|
||||||
|
|
||||||
|
This means InfoPlugin data will always reach the server as soon as possible without requiring a client restart. No action is needed from plugin authors: the framework handles cache invalidation and re-collection automatically.
|
||||||
|
|
||||||
|
The lifecycle for this case looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Server restarts, host reconnects
|
||||||
|
└─> hbd receives HTB with no existing plugin_data for host
|
||||||
|
└─> hbd sets request_update=1 in ACK
|
||||||
|
|
||||||
|
Client receives ACK
|
||||||
|
└─> Detects request_update flag
|
||||||
|
└─> Clears _cache on every registered InfoPlugin
|
||||||
|
└─> Calls collect() on each InfoPlugin
|
||||||
|
└─> Sends fresh PLG messages to server
|
||||||
|
```
|
||||||
|
|
||||||
|
If you write an `InfoPlugin` with side effects in `_collect_info()` (opening connections, writing files, etc.), be aware it may be called more than once per client session when this mechanism triggers.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Plugin-Specific Configuration
|
### Plugin-Specific Configuration
|
||||||
|
|||||||
+231
-72
@@ -256,6 +256,56 @@ disk_monitor:
|
|||||||
operator: "<"
|
operator: "<"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ZFS Monitor
|
||||||
|
|
||||||
|
ZFS pool health is checked automatically for every pool. A pool in any state
|
||||||
|
other than `ONLINE` (e.g. `DEGRADED`, `SUSPENDED`, `FAULTED`, `UNAVAIL`) raises
|
||||||
|
a **CRITICAL** alert by default — no configuration required.
|
||||||
|
|
||||||
|
The default threshold is equivalent to:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
zfs_monitor:
|
||||||
|
pools:
|
||||||
|
'*':
|
||||||
|
status:
|
||||||
|
warning: 1
|
||||||
|
critical: 2
|
||||||
|
operator: ">"
|
||||||
|
hysteresis: 0.0
|
||||||
|
display: "ZFS pool {pool_name} is {health}"
|
||||||
|
```
|
||||||
|
|
||||||
|
`'*'` matches every pool on the host. The notification message includes the pool
|
||||||
|
name and its current health string, e.g. `ZFS pool tank is DEGRADED`.
|
||||||
|
|
||||||
|
**Override for specific pools** — named pool entries take priority over `'*'`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
zfs_monitor:
|
||||||
|
pools:
|
||||||
|
# Suppress health alerts for a scratch pool (not mission-critical)
|
||||||
|
scratch:
|
||||||
|
status:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Capacity threshold for a specific pool
|
||||||
|
tank:
|
||||||
|
capacity:
|
||||||
|
warning: 75.0
|
||||||
|
critical: 90.0
|
||||||
|
operator: ">"
|
||||||
|
hysteresis: 0.05
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alert state paths** follow the pattern `zfs_monitor.<pool_name>.status`,
|
||||||
|
so acknowledgements and silences target individual pools:
|
||||||
|
|
||||||
|
```
|
||||||
|
zfs_monitor.tank.status
|
||||||
|
zfs_monitor.backup.status
|
||||||
|
```
|
||||||
|
|
||||||
### Network Monitor
|
### Network Monitor
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -814,42 +864,39 @@ Planned features:
|
|||||||
|
|
||||||
## Multi-Threshold Configuration
|
## Multi-Threshold Configuration
|
||||||
|
|
||||||
**New in version 2.0**: Support for multiple named threshold configurations with per-host mapping.
|
Support for multiple named threshold configurations with per-host mapping and composable layering.
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
The multi-threshold feature allows you to:
|
The multi-threshold feature allows you to:
|
||||||
- Define multiple sets of threshold configurations
|
- Define multiple named threshold configurations
|
||||||
- Map different hosts to different threshold sets
|
- Assign one or more configurations to each host
|
||||||
|
- Compose configurations by layering — each named config's overrides are applied in order on top of the defaults
|
||||||
- Use different sensitivity levels for different environments
|
- Use different sensitivity levels for different environments
|
||||||
- Maintain a default configuration for unmapped hosts
|
|
||||||
|
|
||||||
### Configuration Structure
|
### Configuration Structure
|
||||||
|
|
||||||
|
Named configurations are defined under `threshold_configs`. Each host selects which ones to use via `threshold_config` in the `hosts` section (a string for a single config, or a list to layer multiple):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Optional: Set the default configuration name (defaults to "default")
|
# Optional: set the default configuration name (defaults to "default")
|
||||||
default_threshold_config: "default"
|
default_threshold_config: "default"
|
||||||
|
|
||||||
# Define multiple named threshold configurations
|
|
||||||
threshold_configs:
|
threshold_configs:
|
||||||
# Configuration name 1
|
|
||||||
default:
|
default:
|
||||||
thresholds:
|
thresholds:
|
||||||
# Standard threshold definitions
|
|
||||||
cpu_monitor:
|
cpu_monitor:
|
||||||
cpu_percent:
|
cpu_percent:
|
||||||
warning: 80.0
|
warning: 80.0
|
||||||
critical: 90.0
|
critical: 90.0
|
||||||
|
|
||||||
# Configuration name 2
|
|
||||||
high_sensitivity:
|
high_sensitivity:
|
||||||
thresholds:
|
thresholds:
|
||||||
cpu_monitor:
|
cpu_monitor:
|
||||||
cpu_percent:
|
cpu_percent:
|
||||||
warning: 60.0
|
warning: 60.0
|
||||||
critical: 75.0
|
critical: 75.0
|
||||||
|
|
||||||
# Configuration name 3
|
|
||||||
low_sensitivity:
|
low_sensitivity:
|
||||||
thresholds:
|
thresholds:
|
||||||
cpu_monitor:
|
cpu_monitor:
|
||||||
@@ -857,14 +904,77 @@ threshold_configs:
|
|||||||
warning: 90.0
|
warning: 90.0
|
||||||
critical: 95.0
|
critical: 95.0
|
||||||
|
|
||||||
# Map specific hosts to specific configurations
|
hosts:
|
||||||
host_threshold_mapping:
|
prod-web-01:
|
||||||
prod-web-01: high_sensitivity
|
threshold_config: high_sensitivity # single config
|
||||||
prod-web-02: high_sensitivity
|
|
||||||
dev-server-01: low_sensitivity
|
dev-server-01:
|
||||||
# Unmapped hosts use default_threshold_config
|
threshold_config: low_sensitivity
|
||||||
|
|
||||||
|
# Hosts with no threshold_config use default_threshold_config
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Composable Configurations (list form)
|
||||||
|
|
||||||
|
`threshold_config` can be a list. Configs are applied **left to right**: the defaults are the base, then each named config's overrides are layered on top. Later entries in the list win on any metric they define.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
threshold_configs:
|
||||||
|
default:
|
||||||
|
thresholds:
|
||||||
|
cpu_monitor:
|
||||||
|
cpu_percent: {warning: 80, critical: 90}
|
||||||
|
memory_monitor:
|
||||||
|
memory_percent: {warning: 85, critical: 95}
|
||||||
|
disk_monitor:
|
||||||
|
partitions:
|
||||||
|
/:
|
||||||
|
percent: {warning: 80, critical: 90}
|
||||||
|
|
||||||
|
# Tighter CPU limits for busy servers
|
||||||
|
high_cpu_load:
|
||||||
|
thresholds:
|
||||||
|
cpu_monitor:
|
||||||
|
cpu_percent: {warning: 60, critical: 75}
|
||||||
|
|
||||||
|
# Tighter disk limits for data-heavy servers
|
||||||
|
busy_disk:
|
||||||
|
thresholds:
|
||||||
|
disk_monitor:
|
||||||
|
partitions:
|
||||||
|
/:
|
||||||
|
percent: {warning: 70, critical: 85}
|
||||||
|
|
||||||
|
hosts:
|
||||||
|
# Gets default thresholds only
|
||||||
|
web-01:
|
||||||
|
threshold_config: default
|
||||||
|
|
||||||
|
# Gets tighter CPU limits, default memory and disk
|
||||||
|
build-server:
|
||||||
|
threshold_config: high_cpu_load
|
||||||
|
|
||||||
|
# Layers both: tighter CPU AND tighter disk, default memory
|
||||||
|
db-01:
|
||||||
|
threshold_config: [high_cpu_load, busy_disk]
|
||||||
|
|
||||||
|
# Three layers: busy_disk overrides high_cpu_load if they conflict
|
||||||
|
storage-01:
|
||||||
|
threshold_config: [default, high_cpu_load, busy_disk]
|
||||||
|
```
|
||||||
|
|
||||||
|
**How layering works:**
|
||||||
|
|
||||||
|
Starting from the `default` thresholds:
|
||||||
|
|
||||||
|
| Layer | Applied config | Effect |
|
||||||
|
|-------|---------------|--------|
|
||||||
|
| Base | `default` | all default thresholds |
|
||||||
|
| +1 | `high_cpu_load` | cpu_percent overridden to 60/75 |
|
||||||
|
| +2 | `busy_disk` | disk percent overridden to 70/85; cpu_percent stays at 60/75 |
|
||||||
|
|
||||||
|
Each named config only overrides the metrics it explicitly defines. Metrics not mentioned in a config inherit from the layers beneath.
|
||||||
|
|
||||||
### Use Cases
|
### Use Cases
|
||||||
|
|
||||||
#### 1. Environment-Based Thresholds
|
#### 1. Environment-Based Thresholds
|
||||||
@@ -879,7 +989,7 @@ threshold_configs:
|
|||||||
cpu_percent:
|
cpu_percent:
|
||||||
warning: 70.0 # Alert earlier in production
|
warning: 70.0 # Alert earlier in production
|
||||||
critical: 85.0
|
critical: 85.0
|
||||||
|
|
||||||
development:
|
development:
|
||||||
thresholds:
|
thresholds:
|
||||||
cpu_monitor:
|
cpu_monitor:
|
||||||
@@ -887,11 +997,15 @@ threshold_configs:
|
|||||||
warning: 90.0 # More relaxed for dev
|
warning: 90.0 # More relaxed for dev
|
||||||
critical: 98.0
|
critical: 98.0
|
||||||
|
|
||||||
host_threshold_mapping:
|
hosts:
|
||||||
prod-web-01: production
|
prod-web-01:
|
||||||
prod-web-02: production
|
threshold_config: production
|
||||||
dev-web-01: development
|
prod-web-02:
|
||||||
dev-web-02: development
|
threshold_config: production
|
||||||
|
dev-web-01:
|
||||||
|
threshold_config: development
|
||||||
|
dev-web-02:
|
||||||
|
threshold_config: development
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Server Role-Based Thresholds
|
#### 2. Server Role-Based Thresholds
|
||||||
@@ -906,7 +1020,7 @@ threshold_configs:
|
|||||||
cpu_percent:
|
cpu_percent:
|
||||||
warning: 80.0
|
warning: 80.0
|
||||||
critical: 90.0
|
critical: 90.0
|
||||||
|
|
||||||
database:
|
database:
|
||||||
thresholds:
|
thresholds:
|
||||||
cpu_monitor:
|
cpu_monitor:
|
||||||
@@ -914,7 +1028,7 @@ threshold_configs:
|
|||||||
warning: 70.0
|
warning: 70.0
|
||||||
critical: 85.0
|
critical: 85.0
|
||||||
memory_monitor:
|
memory_monitor:
|
||||||
percent:
|
memory_percent:
|
||||||
warning: 90.0 # Databases can use high memory
|
warning: 90.0 # Databases can use high memory
|
||||||
critical: 97.0
|
critical: 97.0
|
||||||
disk_monitor:
|
disk_monitor:
|
||||||
@@ -923,21 +1037,27 @@ threshold_configs:
|
|||||||
percent:
|
percent:
|
||||||
warning: 75.0
|
warning: 75.0
|
||||||
critical: 85.0
|
critical: 85.0
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
thresholds:
|
thresholds:
|
||||||
memory_monitor:
|
memory_monitor:
|
||||||
percent:
|
memory_percent:
|
||||||
warning: 95.0 # Redis/Memcached can use very high memory
|
warning: 95.0 # Redis/Memcached can use very high memory
|
||||||
critical: 99.0
|
critical: 99.0
|
||||||
|
|
||||||
host_threshold_mapping:
|
hosts:
|
||||||
web-01: webserver
|
web-01:
|
||||||
web-02: webserver
|
threshold_config: webserver
|
||||||
db-01: database
|
web-02:
|
||||||
db-02: database
|
threshold_config: webserver
|
||||||
redis-01: cache
|
db-01:
|
||||||
memcached-01: cache
|
threshold_config: database
|
||||||
|
db-02:
|
||||||
|
threshold_config: database
|
||||||
|
redis-01:
|
||||||
|
threshold_config: cache
|
||||||
|
memcached-01:
|
||||||
|
threshold_config: cache
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Sensitivity Levels
|
#### 3. Sensitivity Levels
|
||||||
@@ -952,10 +1072,10 @@ threshold_configs:
|
|||||||
partitions:
|
partitions:
|
||||||
/:
|
/:
|
||||||
percent:
|
percent:
|
||||||
warning: 70.0 # Very sensitive
|
warning: 70.0
|
||||||
critical: 80.0
|
critical: 80.0
|
||||||
hysteresis: 0.15
|
hysteresis: 0.15
|
||||||
|
|
||||||
standard:
|
standard:
|
||||||
thresholds:
|
thresholds:
|
||||||
disk_monitor:
|
disk_monitor:
|
||||||
@@ -965,7 +1085,7 @@ threshold_configs:
|
|||||||
warning: 85.0
|
warning: 85.0
|
||||||
critical: 95.0
|
critical: 95.0
|
||||||
hysteresis: 0.1
|
hysteresis: 0.1
|
||||||
|
|
||||||
relaxed:
|
relaxed:
|
||||||
thresholds:
|
thresholds:
|
||||||
disk_monitor:
|
disk_monitor:
|
||||||
@@ -976,52 +1096,91 @@ threshold_configs:
|
|||||||
critical: 98.0
|
critical: 98.0
|
||||||
hysteresis: 0.05
|
hysteresis: 0.05
|
||||||
|
|
||||||
host_threshold_mapping:
|
hosts:
|
||||||
payment-gateway: critical
|
payment-gateway:
|
||||||
auth-server: critical
|
threshold_config: critical
|
||||||
web-01: standard
|
auth-server:
|
||||||
web-02: standard
|
threshold_config: critical
|
||||||
test-server: relaxed
|
web-01:
|
||||||
|
threshold_config: standard
|
||||||
|
web-02:
|
||||||
|
threshold_config: standard
|
||||||
|
test-server:
|
||||||
|
threshold_config: relaxed
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backward Compatibility
|
#### 4. Composable Profiles
|
||||||
|
|
||||||
The legacy single threshold configuration is fully supported:
|
Build host-specific thresholds by combining small, focused configs:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Old format - still works
|
|
||||||
thresholds:
|
|
||||||
cpu_monitor:
|
|
||||||
cpu_percent:
|
|
||||||
warning: 80.0
|
|
||||||
critical: 90.0
|
|
||||||
```
|
|
||||||
|
|
||||||
This is equivalent to:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# New format
|
|
||||||
threshold_configs:
|
threshold_configs:
|
||||||
|
# Baseline — everything at default levels
|
||||||
default:
|
default:
|
||||||
thresholds:
|
thresholds:
|
||||||
cpu_monitor:
|
cpu_monitor:
|
||||||
cpu_percent:
|
cpu_percent: {warning: 80, critical: 90}
|
||||||
warning: 80.0
|
memory_monitor:
|
||||||
critical: 90.0
|
memory_percent: {warning: 85, critical: 95}
|
||||||
```
|
|
||||||
|
|
||||||
|
# Overlay: tighter CPU only
|
||||||
|
tight_cpu:
|
||||||
|
thresholds:
|
||||||
|
cpu_monitor:
|
||||||
|
cpu_percent: {warning: 60, critical: 75}
|
||||||
|
|
||||||
|
# Overlay: tighter memory only
|
||||||
|
tight_memory:
|
||||||
|
thresholds:
|
||||||
|
memory_monitor:
|
||||||
|
memory_percent: {warning: 70, critical: 85}
|
||||||
|
|
||||||
|
# Overlay: extra disk partition for database servers
|
||||||
|
db_disk:
|
||||||
|
thresholds:
|
||||||
|
disk_monitor:
|
||||||
|
partitions:
|
||||||
|
/var/lib/postgresql:
|
||||||
|
percent: {warning: 75, critical: 88}
|
||||||
|
|
||||||
|
hosts:
|
||||||
|
# Plain web server
|
||||||
|
web-01:
|
||||||
|
threshold_config: default
|
||||||
|
|
||||||
|
# Build server: tight CPU, default memory and disk
|
||||||
|
build-01:
|
||||||
|
threshold_config: tight_cpu
|
||||||
|
|
||||||
|
# Database: tight CPU + tight memory + extra disk partition
|
||||||
|
db-01:
|
||||||
|
threshold_config: [tight_cpu, tight_memory, db_disk]
|
||||||
|
|
||||||
|
# Replica database: tight memory + extra disk, normal CPU
|
||||||
|
db-02:
|
||||||
|
threshold_config: [tight_memory, db_disk]
|
||||||
|
```
|
||||||
### Configuration Priority
|
### Configuration Priority
|
||||||
|
|
||||||
1. **Host-specific mapping**: If host is in `host_threshold_mapping`, use that config
|
1. **Host `threshold_config` (list)**: Layer each named config's overrides left-to-right on top of the defaults
|
||||||
2. **Default config**: Use `default_threshold_config`
|
2. **Host `threshold_config` (string)**: Use that single named config directly
|
||||||
3. **First alphabetically**: If default not found, use first config alphabetically
|
3. **`host_threshold_mapping`** (legacy): Same as above, string only
|
||||||
4. **Legacy fallback**: If `threshold_configs` not present, use `thresholds`
|
4. **`default_threshold_config`**: Used for hosts with no mapping
|
||||||
|
5. **First alphabetically**: If the default config is not found, use the first config alphabetically
|
||||||
|
6. **Legacy `thresholds` section**: Used when `threshold_configs` is absent entirely
|
||||||
|
|
||||||
### Example: Complete Multi-Threshold Setup
|
### Backward Compatibility
|
||||||
|
|
||||||
See `hbd/config_multi_threshold_example.yaml` for a complete example with:
|
The legacy `host_threshold_mapping` top-level key and the flat `thresholds` section are still fully supported:
|
||||||
- 4 named configurations (default, high_sensitivity, low_sensitivity, database)
|
|
||||||
- Host-to-config mappings for production, development, and test systems
|
```yaml
|
||||||
- Specialized database server thresholds
|
# Still works — equivalent to hosts: {prod-web-01: {threshold_config: high_sensitivity}}
|
||||||
- Custom display messages with plugin data
|
host_threshold_mapping:
|
||||||
|
prod-web-01: high_sensitivity
|
||||||
|
|
||||||
|
# Still works — equivalent to threshold_configs: {default: {thresholds: ...}}
|
||||||
|
thresholds:
|
||||||
|
cpu_monitor:
|
||||||
|
cpu_percent: {warning: 80, critical: 90}
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
# User Management
|
||||||
|
|
||||||
|
Heartbeat supports optional user accounts with role-based access control per host. When no users are configured the server runs in **unauthenticated mode** — all existing behaviour is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Users are defined in the server config file. Each host can have an **owner**, zero or more **managers**, and zero or more **monitors**. A **default owner** catches any host that does not name an explicit owner.
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
| Role | Inherits | Permissions |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| **monitor** | — | View host status, plugin data, alerts; acknowledge alerts they were notified for |
|
||||||
|
| **manager** | monitor | + Queue commands (`/c`), trigger DNS re-registration (`/n`), queue upgrades (`/u`); add/remove monitors |
|
||||||
|
| **owner** | manager | + Drop host (`/d`); add/remove managers; transfer ownership; update host access |
|
||||||
|
| **admin** *(flag)* | owner on all hosts | Full access to every host and the user list |
|
||||||
|
|
||||||
|
`admin` is a flag on the user, not a per-host role. An admin user has owner-level access on every host without being listed as owner/manager/monitor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Defining users
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
users:
|
||||||
|
andreas:
|
||||||
|
full_name: Andreas Wrede
|
||||||
|
avatar: /path/to/avatar.png # file path, URL, or base64 data URI (optional)
|
||||||
|
password: pbkdf2:sha256:... # generated with: hbd passwd andreas
|
||||||
|
admin: true # optional — grants server-wide owner access
|
||||||
|
|
||||||
|
bob:
|
||||||
|
full_name: Bob Smith
|
||||||
|
password: pbkdf2:sha256:...
|
||||||
|
notification_channels: [pushover_standard]
|
||||||
|
|
||||||
|
carol:
|
||||||
|
full_name: Carol Jones
|
||||||
|
password: pbkdf2:sha256:...
|
||||||
|
|
||||||
|
default_owner: andreas # owns hosts with no explicit owner
|
||||||
|
# falls back to the first admin user if omitted
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client-declared host ownership
|
||||||
|
|
||||||
|
A host can declare its own owner directly in the hbc or hbc_mini client configuration. This is useful for hosts that are not listed in the server config, or during initial setup before a server-side config entry has been created.
|
||||||
|
|
||||||
|
**`~/.hbc.yaml`** (hbc):
|
||||||
|
```yaml
|
||||||
|
owner: andreas
|
||||||
|
```
|
||||||
|
|
||||||
|
**`~/.hbc.json`** (hbc_mini):
|
||||||
|
```json
|
||||||
|
{ "owner": "andreas" }
|
||||||
|
```
|
||||||
|
|
||||||
|
When set, the value is included in the `os_info` plugin data sent to the server. The server applies it as `host.owner` the first time `os_info` arrives, provided no owner has been configured server-side for that host. Server-configured ownership always takes precedence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Assigning roles to hosts
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hosts:
|
||||||
|
webserver01:
|
||||||
|
owner: andreas
|
||||||
|
managers: [bob]
|
||||||
|
monitors: [carol]
|
||||||
|
threshold_config: default
|
||||||
|
watch: true
|
||||||
|
notification_channels: [pushover_standard]
|
||||||
|
|
||||||
|
unattended-host: # no owner → owned by default_owner
|
||||||
|
threshold_config: default
|
||||||
|
watch: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generating a password hash
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hbd passwd andreas
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter and confirm the password when prompted. Paste the printed hash into the config file under the user's `password` key.
|
||||||
|
|
||||||
|
You can also generate a hash non-interactively from Python:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from hbd.server.users import hash_password
|
||||||
|
print(hash_password("mysecret"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Passwords are stored as PBKDF2-HMAC-SHA256 hashes (260 000 iterations). No third-party libraries are required — only Python's standard `hashlib`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
When at least one user is defined, every request must be authenticated. Unauthenticated requests to HTML pages are redirected to `/login`; unauthenticated API requests receive `401 Unauthorized`.
|
||||||
|
|
||||||
|
### Browser login
|
||||||
|
|
||||||
|
Navigate to any page — you will be redirected to `/login` automatically. After submitting valid credentials the server sets an `hbd_session` cookie (HttpOnly, SameSite=Lax, 24 h lifetime). All subsequent requests, including JavaScript `fetch()` calls on the dashboards, carry the cookie automatically.
|
||||||
|
|
||||||
|
To log out, visit `/logout`.
|
||||||
|
|
||||||
|
### API / programmatic login
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Log in and capture the token
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"andreas","password":"mysecret"}' | jq -r .token)
|
||||||
|
|
||||||
|
# Use the token in subsequent requests
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://localhost:50004/api/0/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
The token is identical to the session cookie value — both mechanisms work simultaneously.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Log out
|
||||||
|
curl -s -X POST http://localhost:50004/api/0/auth/logout \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
#### POST /api/0/auth/login
|
||||||
|
Obtain a session token.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{ "username": "andreas", "password": "mysecret" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "token": "<opaque-hex-token>", "username": "andreas" }
|
||||||
|
```
|
||||||
|
Also sets the `hbd_session` cookie for browser clients.
|
||||||
|
|
||||||
|
**Status codes:** `200 OK`, `401 Unauthorized`, `404` (auth not configured)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/0/auth/logout
|
||||||
|
Invalidate the current session.
|
||||||
|
|
||||||
|
**Headers:** `Authorization: Bearer <token>` or cookie
|
||||||
|
|
||||||
|
**Response:** `{ "success": true }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
#### GET /api/0/users
|
||||||
|
List all users. **Admin only.**
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "username": "andreas", "full_name": "Andreas Wrede", "avatar": "", "admin": true, "notification_channels": [] },
|
||||||
|
{ "username": "bob", "full_name": "Bob Smith", "avatar": "", "admin": false, "notification_channels": ["pushover_standard"] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/0/users/me
|
||||||
|
Return the currently authenticated user's profile.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "username": "carol", "full_name": "Carol Jones", "avatar": "", "admin": false, "notification_channels": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Host Access
|
||||||
|
|
||||||
|
#### GET /api/0/hosts/{hostname}/access
|
||||||
|
Return owner/managers/monitors for a host. Requires at least **monitor** role.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "andreas",
|
||||||
|
"managers": ["bob"],
|
||||||
|
"monitors": ["carol"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/0/hosts/{hostname}/access
|
||||||
|
Update owner/managers/monitors. Requires **owner** role or admin.
|
||||||
|
|
||||||
|
**Request body** (all fields optional):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "bob",
|
||||||
|
"managers": ["carol"],
|
||||||
|
"monitors": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes take effect immediately in memory. They are not written back to the config file — reload (`SIGHUP`) will re-apply config values. To make changes permanent, update the config file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host visibility
|
||||||
|
|
||||||
|
When users are configured, `GET /api/0/hosts` only returns hosts the authenticated user has at least monitor access to. Admins see all hosts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config reload
|
||||||
|
|
||||||
|
On `SIGHUP`, the server reloads the config file, re-loads the user registry, and re-applies `owner`/`managers`/`monitors` from config to all known hosts. Existing sessions remain valid after a reload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No-auth mode
|
||||||
|
|
||||||
|
If `users:` is absent or empty, the server starts in **unauthenticated mode**:
|
||||||
|
|
||||||
|
- No login required — all pages and API endpoints are accessible without credentials.
|
||||||
|
- All permission checks pass unconditionally.
|
||||||
|
- `/login`, `/logout`, and the auth/user API endpoints return `404`.
|
||||||
|
|
||||||
|
This preserves full backwards compatibility with existing deployments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Session tokens are 64-character cryptographically random hex strings (`secrets.token_hex(32)`).
|
||||||
|
- Sessions expire after 24 hours (configurable via `users_mod.SESSION_TTL`).
|
||||||
|
- Cookies are `HttpOnly` and `SameSite=Lax` — they are not accessible to JavaScript and are not sent on cross-site requests.
|
||||||
|
- The HTTP API does not yet enforce TLS. For production use, place hbd behind a TLS-terminating reverse proxy (nginx, Caddy, etc.) or enable WSS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [HTTP API Documentation](HTTP_API.md)
|
||||||
|
- [Notifications](NOTIFICATIONS.md)
|
||||||
|
- Configuration example: `hbd/config_example.yaml`
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
# 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"
|
||||||
|
```
|
||||||
@@ -0,0 +1,781 @@
|
|||||||
|
# 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
@@ -0,0 +1,92 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# 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,9 +0,0 @@
|
|||||||
Plan
|
|
||||||
|
|
||||||
Heartbeat is a client/server based network monitor and host observer. hbd, the server portion receives heartbeat and state messages from clients and maintaines state and hisgtory of the informations it receives.
|
|
||||||
|
|
||||||
hbc, the client portion gathers information on various aspects of the
|
|
||||||
system it is running on, and sends it to hbd. Initially this info is basic, like OS make and version, hardware info (CPU type, memory and disks), fileystem info and some resource info. hbc/hbd support a plugin system to extend the info gathered and stored.
|
|
||||||
|
|
||||||
hbd also can send notification based on missed hbc updates, and on violation of pre-set limits for various state paramaters.
|
|
||||||
|
|
||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.0.8"
|
__version__ = "5.3.0"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""HeartBeat Client (hbc) - System monitoring client."""
|
"""HeartBeat Client (hbc) - System monitoring client."""
|
||||||
|
|
||||||
__version__ = "5.0.5"
|
from hbd import __version__
|
||||||
|
|||||||
+13
-6
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
@@ -12,12 +15,15 @@ CLIENT_DEFAULTS = {
|
|||||||
# Network settings
|
# Network settings
|
||||||
"hb_port": 50003, # Port where hbd servers listen
|
"hb_port": 50003, # Port where hbd servers listen
|
||||||
"interval": 10, # Heartbeat interval in seconds
|
"interval": 10, # Heartbeat interval in seconds
|
||||||
|
|
||||||
|
# Host identity
|
||||||
|
"owner": None, # Optional username to set as this host's owner on the server
|
||||||
|
|
||||||
# Runtime flags
|
# Runtime flags
|
||||||
"foreground": False,
|
"foreground": False,
|
||||||
"verbose": False,
|
"verbose": False,
|
||||||
"debug": 0,
|
"debug": 0,
|
||||||
|
|
||||||
# Plugin configuration
|
# Plugin configuration
|
||||||
"plugins": {}, # Per-plugin configuration
|
"plugins": {}, # Per-plugin configuration
|
||||||
"thresholds": {}, # Threshold configuration for monitoring
|
"thresholds": {}, # Threshold configuration for monitoring
|
||||||
@@ -30,18 +36,19 @@ def load_config(path=None):
|
|||||||
If YAML is not available or the file does not exist, defaults are returned.
|
If YAML is not available or the file does not exist, defaults are returned.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to YAML config file (default: ~/.hb.yaml)
|
path: Path to YAML config file (default: ~/.hbc.yaml)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with configuration
|
Dictionary with configuration
|
||||||
"""
|
"""
|
||||||
cfg = CLIENT_DEFAULTS.copy()
|
cfg = CLIENT_DEFAULTS.copy()
|
||||||
if not path:
|
if not path:
|
||||||
# default path (~/.hb.yaml)
|
# default path (~/.hbc.yaml)
|
||||||
path = os.path.join(os.path.expanduser("~"), ".hb.yaml")
|
path = os.path.join(os.path.expanduser("~"), ".hbc.yaml")
|
||||||
|
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
if yaml:
|
if yaml:
|
||||||
|
logger.info("Loading configuration from %s", path)
|
||||||
with open(path) as fh:
|
with open(path) as fh:
|
||||||
data = yaml.safe_load(fh)
|
data = yaml.safe_load(fh)
|
||||||
# Merge YAML data with defaults
|
# Merge YAML data with defaults
|
||||||
@@ -50,5 +57,5 @@ def load_config(path=None):
|
|||||||
cfg[k] = v
|
cfg[k] = v
|
||||||
else:
|
else:
|
||||||
# yaml not installed: do not attempt to parse; user must ensure defaults
|
# yaml not installed: do not attempt to parse; user must ensure defaults
|
||||||
pass
|
logger.warning("PyYAML not available - cannot load config from %s, using defaults", path)
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
+201
-111
@@ -14,13 +14,14 @@ import signal
|
|||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from hashlib import md5
|
from logging.handlers import SysLogHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
# Import protocol and config
|
# Import protocol and config
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
from ..common.proto import dicttos, stodict
|
from ..common.proto import dicttos, stodict
|
||||||
|
from .. import __version__
|
||||||
|
|
||||||
# Import plugin system
|
# Import plugin system
|
||||||
from .plugin import PluginRegistry, PluginLoader, InfoPlugin, MonitorPlugin
|
from .plugin import PluginRegistry, PluginLoader, InfoPlugin, MonitorPlugin
|
||||||
@@ -55,23 +56,28 @@ class AsyncConnection:
|
|||||||
|
|
||||||
self.transport: Optional[asyncio.DatagramTransport] = None
|
self.transport: Optional[asyncio.DatagramTransport] = None
|
||||||
self.protocol: Optional[asyncio.DatagramProtocol] = None
|
self.protocol: Optional[asyncio.DatagramProtocol] = None
|
||||||
|
self._dead = False
|
||||||
|
self._ever_opened = False
|
||||||
|
self._open_fail_count = 0 # consecutive failures before first success
|
||||||
|
self.request_info_event: asyncio.Event = asyncio.Event()
|
||||||
|
|
||||||
self.logger = logging.getLogger(f"hbc.conn.{addr}")
|
self.logger = logging.getLogger(f"hbc.conn.{addr}")
|
||||||
|
|
||||||
async def open(self) -> bool:
|
async def open(self) -> bool:
|
||||||
"""Open the UDP connection.
|
"""Open the UDP connection.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
# Create datagram endpoint
|
# Create datagram endpoint
|
||||||
self.transport, self.protocol = await loop.create_datagram_endpoint(
|
self.transport, self.protocol = await loop.create_datagram_endpoint(
|
||||||
lambda: HeartbeatProtocol(self),
|
lambda: HeartbeatProtocol(self),
|
||||||
family=self.af
|
family=self.af
|
||||||
)
|
)
|
||||||
|
self._ever_opened = True
|
||||||
self.logger.debug(f"Opened connection to {self.addr}:{self.port}")
|
self.logger.debug(f"Opened connection to {self.addr}:{self.port}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -92,9 +98,12 @@ class AsyncConnection:
|
|||||||
msg: Message dictionary
|
msg: Message dictionary
|
||||||
msg_id: Message ID (HTB, PLG, etc.)
|
msg_id: Message ID (HTB, PLG, etc.)
|
||||||
"""
|
"""
|
||||||
|
if self._dead:
|
||||||
|
return
|
||||||
|
|
||||||
if not self.transport:
|
if not self.transport:
|
||||||
await self.open()
|
await self.open()
|
||||||
|
|
||||||
if not self.transport:
|
if not self.transport:
|
||||||
self.logger.error("Cannot send - no transport")
|
self.logger.error("Cannot send - no transport")
|
||||||
return
|
return
|
||||||
@@ -130,6 +139,9 @@ class AsyncConnection:
|
|||||||
|
|
||||||
self.ackcount += 1
|
self.ackcount += 1
|
||||||
self.logger.debug(f"ACK received, RTT: {rtt:.1f}ms")
|
self.logger.debug(f"ACK received, RTT: {rtt:.1f}ms")
|
||||||
|
if msg.get("request_update"):
|
||||||
|
self.logger.info("server requested plugin info refresh")
|
||||||
|
self.request_info_event.set()
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatProtocol(asyncio.DatagramProtocol):
|
class HeartbeatProtocol(asyncio.DatagramProtocol):
|
||||||
@@ -165,8 +177,9 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
|
|||||||
self.logger.error(f"Error processing datagram: {e}", exc_info=True)
|
self.logger.error(f"Error processing datagram: {e}", exc_info=True)
|
||||||
|
|
||||||
def error_received(self, exc):
|
def error_received(self, exc):
|
||||||
"""Handle protocol errors."""
|
"""Handle protocol errors — close transport so the heartbeat sender retries."""
|
||||||
self.logger.error(f"Protocol error: {exc}")
|
self.logger.warning(f"Protocol error on {self.connection.addr}: {exc} — will retry")
|
||||||
|
self.connection.close()
|
||||||
|
|
||||||
|
|
||||||
async def handle_command(conn: AsyncConnection, msg: dict):
|
async def handle_command(conn: AsyncConnection, msg: dict):
|
||||||
@@ -203,55 +216,52 @@ async def handle_command(conn: AsyncConnection, msg: dict):
|
|||||||
await conn.sendto(response)
|
await conn.sendto(response)
|
||||||
|
|
||||||
|
|
||||||
async def handle_update(conn: AsyncConnection, msg: dict):
|
async def handle_update(conn: AsyncConnection, _msg: dict): # pyright: ignore[reportUnusedParameter]
|
||||||
"""Handle self-update from server."""
|
"""Handle self-update by running hb_install.sh."""
|
||||||
import codecs
|
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
logger = logging.getLogger("hbc.update")
|
logger = logging.getLogger("hbc.update")
|
||||||
|
|
||||||
|
installer = shutil.which("hb_install.sh")
|
||||||
|
if installer is None:
|
||||||
|
candidate = Path(sys.argv[0]).parent / "hb_install.sh"
|
||||||
|
if candidate.exists():
|
||||||
|
installer = str(candidate)
|
||||||
|
|
||||||
|
if installer is None:
|
||||||
|
error = "hb_install.sh not found in PATH or alongside hbc"
|
||||||
|
logger.error(error)
|
||||||
|
await conn.sendto({"service": "update", "msg": error})
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Running installer: {installer}")
|
||||||
try:
|
try:
|
||||||
code = codecs.decode(msg["code"], "base64").decode()
|
proc = await asyncio.create_subprocess_exec(
|
||||||
csum = msg["csum"]
|
installer, "client",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
out, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
error = "Installer timed out"
|
||||||
|
logger.error(error)
|
||||||
|
await conn.sendto({"service": "update", "msg": error})
|
||||||
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = f"Missing code/csum: {e}"
|
error = f"Installer failed: {e}"
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
await conn.sendto({"service": "update", "msg": error})
|
await conn.sendto({"service": "update", "msg": error})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Verify checksum
|
if proc.returncode != 0:
|
||||||
m = md5()
|
error = f"Installer exited {proc.returncode}: {out.decode().strip()}"
|
||||||
m.update(code.encode())
|
|
||||||
if m.hexdigest() != csum:
|
|
||||||
error = "Checksum mismatch"
|
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
await conn.sendto({"service": "update", "msg": error})
|
await conn.sendto({"service": "update", "msg": error})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Backup current file
|
|
||||||
fn = sys.argv[0]
|
|
||||||
ofn = f"{fn}.sav"
|
|
||||||
try:
|
|
||||||
shutil.copy2(fn, ofn)
|
|
||||||
except Exception as e:
|
|
||||||
error = f"Backup failed: {e}"
|
|
||||||
logger.error(error)
|
|
||||||
await conn.sendto({"service": "update", "msg": error})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Write new code
|
|
||||||
try:
|
|
||||||
with open(fn, "w") as fh:
|
|
||||||
fh.write(code)
|
|
||||||
except Exception as e:
|
|
||||||
error = f"Write failed: {e}"
|
|
||||||
logger.error(error)
|
|
||||||
await conn.sendto({"service": "update", "msg": error})
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Update successful, restart required")
|
logger.info("Update successful, restart required")
|
||||||
await conn.sendto({"service": "update", "msg": "OK"})
|
await conn.sendto({"service": "update", "msg": "OK"})
|
||||||
|
|
||||||
# Trigger restart
|
# Trigger restart
|
||||||
global dorestart
|
global dorestart
|
||||||
dorestart = True
|
dorestart = True
|
||||||
@@ -259,15 +269,51 @@ async def handle_update(conn: AsyncConnection, msg: dict):
|
|||||||
|
|
||||||
|
|
||||||
async def heartbeat_sender(conn: AsyncConnection, interval: int):
|
async def heartbeat_sender(conn: AsyncConnection, interval: int):
|
||||||
"""Send periodic heartbeats.
|
"""Send periodic heartbeats, retrying the connection if it is not open.
|
||||||
|
|
||||||
|
IPv6 connections that fail to open before their first successful send are
|
||||||
|
dropped after IPV6_EARLY_FAIL_LIMIT attempts so that a network without IPv6
|
||||||
|
does not keep a dead sender alive. IPv4 connections are retried indefinitely.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: Connection to send on
|
conn: Connection to send on
|
||||||
interval: Heartbeat interval in seconds
|
interval: Heartbeat interval in seconds
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger("hbc.heartbeat")
|
logger = logging.getLogger("hbc.heartbeat")
|
||||||
|
IPV6_EARLY_FAIL_LIMIT = 3
|
||||||
while running:
|
|
||||||
|
while running and not conn._dead:
|
||||||
|
# Ensure transport is open before attempting to send.
|
||||||
|
if not conn.transport:
|
||||||
|
opened = await conn.open()
|
||||||
|
if opened:
|
||||||
|
conn._open_fail_count = 0
|
||||||
|
else:
|
||||||
|
conn._open_fail_count += 1
|
||||||
|
# Drop an IPv6 connection that has never come up within the
|
||||||
|
# first few attempts — it is likely unavailable on this network.
|
||||||
|
if (not conn._ever_opened
|
||||||
|
and conn.af == socket.AF_INET6
|
||||||
|
and conn._open_fail_count >= IPV6_EARLY_FAIL_LIMIT):
|
||||||
|
logger.warning(
|
||||||
|
f"IPv6 connection to {conn.addr} unreachable after "
|
||||||
|
f"{conn._open_fail_count} attempts, disabling"
|
||||||
|
)
|
||||||
|
conn._dead = True
|
||||||
|
break
|
||||||
|
# Retry after the normal interval; IPv4 retries forever.
|
||||||
|
try:
|
||||||
|
if shutdown_event:
|
||||||
|
await asyncio.wait_for(shutdown_event.wait(), timeout=interval)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = {
|
msg = {
|
||||||
"acks": conn.ackcount,
|
"acks": conn.ackcount,
|
||||||
@@ -275,20 +321,17 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
|
|||||||
"interval": interval
|
"interval": interval
|
||||||
}
|
}
|
||||||
await conn.sendto(msg, "HTB")
|
await conn.sendto(msg, "HTB")
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error sending heartbeat: {e}", exc_info=True)
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug("Heartbeat sender cancelled")
|
logger.debug("Heartbeat sender cancelled")
|
||||||
raise
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending heartbeat: {e}", exc_info=True)
|
||||||
|
|
||||||
# Wait for next interval or shutdown event
|
# Wait for next interval or shutdown event
|
||||||
try:
|
try:
|
||||||
if shutdown_event:
|
if shutdown_event:
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(shutdown_event.wait(), timeout=interval)
|
||||||
shutdown_event.wait(),
|
|
||||||
timeout=interval
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
@@ -299,15 +342,35 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _info_plugin_refresh_loop(conn: AsyncConnection, info_plugins: List):
|
||||||
|
"""Wait for server requests to re-send InfoPlugin data."""
|
||||||
|
logger = logging.getLogger("hbc.plugins")
|
||||||
|
while running:
|
||||||
|
await conn.request_info_event.wait()
|
||||||
|
if not running:
|
||||||
|
break
|
||||||
|
conn.request_info_event.clear()
|
||||||
|
logger.info("refreshing InfoPlugins on server request")
|
||||||
|
for plugin in info_plugins:
|
||||||
|
plugin._cache = None
|
||||||
|
try:
|
||||||
|
data = await plugin.collect()
|
||||||
|
if data:
|
||||||
|
await conn.sendto({"plugin": plugin.name, **data}, "PLG")
|
||||||
|
logger.info(f"Resent {plugin.name} data")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error re-collecting {plugin.name}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
|
async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
|
||||||
"""Collect and send plugin data.
|
"""Collect and send plugin data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: Connection to send on
|
conn: Connection to send on
|
||||||
registry: Plugin registry
|
registry: Plugin registry
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger("hbc.plugins")
|
logger = logging.getLogger("hbc.plugins")
|
||||||
|
|
||||||
# Collect InfoPlugins once at startup
|
# Collect InfoPlugins once at startup
|
||||||
info_plugins = registry.get_by_type(InfoPlugin)
|
info_plugins = registry.get_by_type(InfoPlugin)
|
||||||
for plugin in info_plugins:
|
for plugin in info_plugins:
|
||||||
@@ -320,34 +383,31 @@ async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
|
|||||||
logger.info(f"Sent {plugin.name} data")
|
logger.info(f"Sent {plugin.name} data")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting {plugin.name}: {e}", exc_info=True)
|
logger.error(f"Error collecting {plugin.name}: {e}", exc_info=True)
|
||||||
|
|
||||||
# Schedule MonitorPlugins
|
# Schedule MonitorPlugins
|
||||||
# Group plugins by interval
|
# Group plugins by interval
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
by_interval = defaultdict(list)
|
by_interval = defaultdict(list)
|
||||||
|
|
||||||
monitor_plugins = registry.get_by_type(MonitorPlugin)
|
monitor_plugins = registry.get_by_type(MonitorPlugin)
|
||||||
for plugin in monitor_plugins:
|
for plugin in monitor_plugins:
|
||||||
by_interval[plugin.interval].append(plugin)
|
by_interval[plugin.interval].append(plugin)
|
||||||
|
|
||||||
# Create tasks for each interval
|
# Create tasks for each interval; always include the info-refresh watcher
|
||||||
tasks = []
|
tasks = [asyncio.create_task(_info_plugin_refresh_loop(conn, info_plugins))]
|
||||||
for interval, plugins in by_interval.items():
|
for interval, plugins in by_interval.items():
|
||||||
task = asyncio.create_task(
|
tasks.append(asyncio.create_task(
|
||||||
plugin_collector_interval(conn, plugins, interval)
|
plugin_collector_interval(conn, plugins, interval)
|
||||||
)
|
))
|
||||||
tasks.append(task)
|
|
||||||
|
try:
|
||||||
# Wait for all tasks
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
if tasks:
|
except asyncio.CancelledError:
|
||||||
try:
|
logger.debug("Plugin collector cancelled, cancelling sub-tasks")
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
for task in tasks:
|
||||||
except asyncio.CancelledError:
|
if not task.done():
|
||||||
logger.debug("Plugin collector cancelled, cancelling sub-tasks")
|
task.cancel()
|
||||||
for task in tasks:
|
raise
|
||||||
if not task.done():
|
|
||||||
task.cancel()
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def plugin_collector_interval(
|
async def plugin_collector_interval(
|
||||||
@@ -424,16 +484,13 @@ async def cleanup(connections: List[AsyncConnection]):
|
|||||||
logger = logging.getLogger("hbc.cleanup")
|
logger = logging.getLogger("hbc.cleanup")
|
||||||
logger.info("Cleaning up connections")
|
logger.info("Cleaning up connections")
|
||||||
|
|
||||||
for conn in connections:
|
target = next((c for c in connections if c.transport), connections[0] if connections else None)
|
||||||
|
if target and send_shutdown:
|
||||||
try:
|
try:
|
||||||
msg = {
|
await target.sendto({"shutdown": 1, "acks": target.ackcount})
|
||||||
"shutdown": 1,
|
|
||||||
"acks": conn.ackcount
|
|
||||||
}
|
|
||||||
await conn.sendto(msg)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending shutdown: {e}")
|
logger.error(f"Error sending shutdown: {e}")
|
||||||
|
for conn in connections:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# Give messages time to send
|
# Give messages time to send
|
||||||
@@ -442,7 +499,7 @@ async def cleanup(connections: List[AsyncConnection]):
|
|||||||
|
|
||||||
async def async_main(args, config):
|
async def async_main(args, config):
|
||||||
"""Async main function."""
|
"""Async main function."""
|
||||||
global running, shutdown_event, active_tasks
|
global running, shutdown_event, active_tasks, send_shutdown
|
||||||
|
|
||||||
# Create shutdown event
|
# Create shutdown event
|
||||||
shutdown_event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
@@ -459,8 +516,7 @@ async def async_main(args, config):
|
|||||||
hb_port = config.get("hb_port", PORT)
|
hb_port = config.get("hb_port", PORT)
|
||||||
interval = config.get("interval", INTERVAL)
|
interval = config.get("interval", INTERVAL)
|
||||||
|
|
||||||
logger.info(f"Starting hbc for {iam} -> {hb_hosts}")
|
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
|
||||||
logger.info(f"Port: {hb_port}, Interval: {interval}s")
|
|
||||||
|
|
||||||
# Create connections
|
# Create connections
|
||||||
connections = []
|
connections = []
|
||||||
@@ -476,30 +532,34 @@ async def async_main(args, config):
|
|||||||
for addr_info in addrs:
|
for addr_info in addrs:
|
||||||
af = addr_info[0]
|
af = addr_info[0]
|
||||||
addr = addr_info[4][0]
|
addr = addr_info[4][0]
|
||||||
|
|
||||||
conn = AsyncConnection(conn_id, addr, hb_port, af, iam)
|
conn = AsyncConnection(conn_id, addr, hb_port, af, iam)
|
||||||
if await conn.open():
|
if not await conn.open():
|
||||||
connections.append(conn)
|
logger.warning(f"Initial open to {addr} failed, heartbeat sender will retry")
|
||||||
conn_id += 1
|
connections.append(conn)
|
||||||
|
conn_id += 1
|
||||||
|
|
||||||
if not connections:
|
if not connections:
|
||||||
logger.error("No connections established")
|
logger.error("No connections established (DNS resolution failed for all hosts)")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
logger.info(f"Created {len(connections)} connections")
|
logger.info(f"Created {len(connections)} connections")
|
||||||
|
|
||||||
# Send boot/message if requested
|
# Send boot/message if requested
|
||||||
|
send_shutdown = False
|
||||||
if args.boot or args.message:
|
if args.boot or args.message:
|
||||||
boot_msg = {}
|
boot_msg = {}
|
||||||
if args.boot:
|
if args.boot:
|
||||||
boot_msg["boot"] = 1
|
boot_msg["boot"] = 1
|
||||||
|
args.boot = False # Clear boot flag so we don't send it again in main loop
|
||||||
|
send_shutdown = True
|
||||||
if args.message:
|
if args.message:
|
||||||
boot_msg["service"] = "service"
|
boot_msg["service"] = "service"
|
||||||
boot_msg["msg"] = args.message
|
boot_msg["msg"] = args.message
|
||||||
|
|
||||||
boot_msg["acks"] = 0
|
boot_msg["acks"] = 0
|
||||||
for conn in connections:
|
target = next((c for c in connections if c.transport), connections[0])
|
||||||
await conn.sendto(boot_msg)
|
await target.sendto(boot_msg)
|
||||||
|
|
||||||
if args.message and not args.daemon:
|
if args.message and not args.daemon:
|
||||||
# Message-only mode
|
# Message-only mode
|
||||||
@@ -521,6 +581,13 @@ async def async_main(args, config):
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
loop.add_signal_handler(sig, stop)
|
loop.add_signal_handler(sig, stop)
|
||||||
|
|
||||||
|
def _sighup():
|
||||||
|
global dorestart
|
||||||
|
dorestart = True
|
||||||
|
stop()
|
||||||
|
|
||||||
|
loop.add_signal_handler(signal.SIGHUP, _sighup)
|
||||||
|
|
||||||
# Start async tasks
|
# Start async tasks
|
||||||
# Heartbeat senders (one per connection)
|
# Heartbeat senders (one per connection)
|
||||||
@@ -586,6 +653,36 @@ def daemonize(
|
|||||||
os.dup2(se.fileno(), sys.stderr.fileno())
|
os.dup2(se.fileno(), sys.stderr.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
def _reconfigure_logging_for_daemon(log_level: int) -> None:
|
||||||
|
"""Replace StreamHandlers (now writing to /dev/null) with a SysLogHandler."""
|
||||||
|
root = logging.getLogger()
|
||||||
|
for handler in root.handlers[:]:
|
||||||
|
root.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
use_udp_fallback = not os.path.exists("/dev/log")
|
||||||
|
|
||||||
|
if use_udp_fallback:
|
||||||
|
syslog_handler = SysLogHandler(
|
||||||
|
address=("localhost", 514),
|
||||||
|
facility=SysLogHandler.LOG_DAEMON,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
syslog_handler = SysLogHandler(
|
||||||
|
address="/dev/log",
|
||||||
|
facility=SysLogHandler.LOG_DAEMON,
|
||||||
|
)
|
||||||
|
|
||||||
|
syslog_handler.setFormatter(
|
||||||
|
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
|
||||||
|
)
|
||||||
|
root.addHandler(syslog_handler)
|
||||||
|
root.setLevel(log_level)
|
||||||
|
|
||||||
|
if use_udp_fallback:
|
||||||
|
logging.warning("/dev/log not found, using syslog UDP localhost:514")
|
||||||
|
|
||||||
|
|
||||||
def build_parser():
|
def build_parser():
|
||||||
"""Build argument parser."""
|
"""Build argument parser."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -644,13 +741,10 @@ def main(argv=None):
|
|||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
# Load config
|
|
||||||
config = load_config(args.configfile)
|
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
log_level = logging.INFO
|
log_level = logging.WARNING
|
||||||
if args.verbose:
|
if args.verbose:
|
||||||
log_level = logging.DEBUG
|
log_level = logging.INFO
|
||||||
if args.debug:
|
if args.debug:
|
||||||
log_level = logging.DEBUG
|
log_level = logging.DEBUG
|
||||||
|
|
||||||
@@ -659,20 +753,16 @@ def main(argv=None):
|
|||||||
format="%(asctime)s %(name)s %(levelname)s: %(message)s",
|
format="%(asctime)s %(name)s %(levelname)s: %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S"
|
datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config = load_config(args.configfile)
|
||||||
|
|
||||||
# Daemonize if requested
|
# Daemonize if requested
|
||||||
if args.daemon:
|
if args.daemon:
|
||||||
print("Daemonizing...")
|
logging.info("Daemonizing...")
|
||||||
import syslog
|
|
||||||
syslog.openlog("hbc", syslog.LOG_PID, syslog.LOG_DAEMON)
|
|
||||||
syslog.syslog(syslog.LOG_INFO, f"Starting heartbeat to {', '.join(args.hosts)}")
|
|
||||||
daemonize()
|
daemonize()
|
||||||
|
_reconfigure_logging_for_daemon(log_level)
|
||||||
# Reconfigure logging for syslog
|
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
|
||||||
logging.basicConfig(
|
|
||||||
level=log_level,
|
|
||||||
format="hbc[%(process)d]: %(name)s %(levelname)s: %(message)s"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run async main
|
# Run async main
|
||||||
try:
|
try:
|
||||||
|
|||||||
+23
-8
@@ -22,13 +22,14 @@ from typing import Any, Dict, List, Optional, Type
|
|||||||
|
|
||||||
class Plugin(ABC):
|
class Plugin(ABC):
|
||||||
"""Base class for all plugins.
|
"""Base class for all plugins.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
name: Unique plugin identifier (e.g., "os_info", "cpu_monitor")
|
name: Unique plugin identifier (e.g., "os_info", "cpu_monitor")
|
||||||
version: Plugin version string
|
version: Plugin version string
|
||||||
description: Human-readable description
|
description: Human-readable description
|
||||||
interval: Collection interval in seconds (0 for InfoPlugin = collect once)
|
interval: Collection interval in seconds (0 for InfoPlugin = collect once)
|
||||||
enabled: Whether plugin is active (can be disabled via config)
|
enabled: Whether plugin is active (can be disabled via config)
|
||||||
|
skip_reason: Set by plugin before returning False from initialize(); causes loader to log INFO instead of WARNING.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str = ""
|
name: str = ""
|
||||||
@@ -39,13 +40,14 @@ class Plugin(ABC):
|
|||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
"""Initialize plugin with optional configuration.
|
"""Initialize plugin with optional configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Plugin-specific configuration from YAML (e.g., thresholds, paths)
|
config: Plugin-specific configuration from YAML (e.g., thresholds, paths)
|
||||||
"""
|
"""
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self.logger = logging.getLogger(f"plugin.{self.name}")
|
self.logger = logging.getLogger(f"plugin.{self.name}")
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
self.skip_reason: Optional[str] = None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
@@ -311,7 +313,11 @@ class PluginLoader:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
loaded_count = 0
|
loaded_count = 0
|
||||||
plugin_config = config or {}
|
raw_config = config or {}
|
||||||
|
# Per-plugin config lives under the 'plugins' key or at top-level.
|
||||||
|
# CLIENT_DEFAULTS seeds "plugins": {} so the key always exists; check
|
||||||
|
# both the subdict and top-level so that either layout in .hbc.yaml works.
|
||||||
|
plugins_subconfig = raw_config.get("plugins", {})
|
||||||
|
|
||||||
# Scan for Python files
|
# Scan for Python files
|
||||||
for plugin_file in directory.glob("*.py"):
|
for plugin_file in directory.glob("*.py"):
|
||||||
@@ -356,17 +362,26 @@ class PluginLoader:
|
|||||||
|
|
||||||
self.logger.debug(f"Found plugin class: {name}")
|
self.logger.debug(f"Found plugin class: {name}")
|
||||||
|
|
||||||
# Instantiate plugin with config
|
# Instantiate plugin with config — check plugins subdict first,
|
||||||
plugin_instance_config = plugin_config.get(obj.name, {})
|
# then top-level keys (e.g. nagios_runner: ... at root of config).
|
||||||
|
plugin_instance_config = dict(plugins_subconfig.get(obj.name) or raw_config.get(obj.name) or {})
|
||||||
|
# Propagate top-level owner so os_info (and any future plugin) can report it.
|
||||||
|
if "owner" in raw_config and "owner" not in plugin_instance_config:
|
||||||
|
plugin_instance_config["owner"] = raw_config["owner"]
|
||||||
plugin = obj(config=plugin_instance_config)
|
plugin = obj(config=plugin_instance_config)
|
||||||
|
|
||||||
# Initialize plugin
|
# Initialize plugin
|
||||||
try:
|
try:
|
||||||
initialized = await plugin.initialize()
|
initialized = await plugin.initialize()
|
||||||
if not initialized:
|
if not initialized:
|
||||||
self.logger.warning(
|
if plugin.skip_reason:
|
||||||
f"Plugin {plugin.name} failed initialization, skipping"
|
self.logger.info(
|
||||||
)
|
f"Plugin {plugin.name} skipped: {plugin.skip_reason}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Plugin {plugin.name} failed initialization, skipping"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ class CPUMonitorPlugin(MonitorPlugin):
|
|||||||
data["cpu_iowait"] = round(cpu_times.iowait, 1)
|
data["cpu_iowait"] = round(cpu_times.iowait, 1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug(f"Could not get CPU times: {e}")
|
self.logger.debug(f"Could not get CPU times: {e}")
|
||||||
|
|
||||||
|
# Uptime in seconds
|
||||||
|
try:
|
||||||
|
import time
|
||||||
|
data["uptime_seconds"] = int(time.time() - self.psutil.boot_time())
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"Could not get uptime: {e}")
|
||||||
|
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
f"Collected CPU metrics: {data.get('cpu_percent', 'N/A')}% usage"
|
f"Collected CPU metrics: {data.get('cpu_percent', 'N/A')}% usage"
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ except ImportError:
|
|||||||
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
from hbd.client.plugin import MonitorPlugin
|
||||||
|
|
||||||
|
|
||||||
|
def _zfs_arc_bytes() -> int:
|
||||||
|
"""Return current ZFS ARC size in bytes, or 0 if ZFS is not present.
|
||||||
|
|
||||||
|
ZFS ARC is reclaimable but is not included in MemAvailable by the Linux
|
||||||
|
kernel (it is not in SReclaimable), so it would otherwise be counted as
|
||||||
|
used memory.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open("/proc/spl/kstat/zfs/arcstats") as fh:
|
||||||
|
for line in fh:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 3 and parts[0] == "size":
|
||||||
|
return int(parts[2])
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -101,11 +119,21 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
|||||||
|
|
||||||
# Virtual (physical) memory statistics
|
# Virtual (physical) memory statistics
|
||||||
vmem = psutil.virtual_memory()
|
vmem = psutil.virtual_memory()
|
||||||
|
|
||||||
|
# psutil's available already excludes page cache / file buffers
|
||||||
|
# (uses MemAvailable on Linux). Add ZFS ARC on top because the kernel
|
||||||
|
# does not include it in SReclaimable / MemAvailable even though it is
|
||||||
|
# reclaimable.
|
||||||
|
arc_bytes = _zfs_arc_bytes()
|
||||||
|
available = min(vmem.available + arc_bytes, vmem.total)
|
||||||
|
used = vmem.total - available
|
||||||
|
percent = round(used / vmem.total * 100, 1) if vmem.total else 0.0
|
||||||
|
|
||||||
metrics['memory_total'] = vmem.total
|
metrics['memory_total'] = vmem.total
|
||||||
metrics['memory_available'] = vmem.available
|
metrics['memory_available'] = available
|
||||||
metrics['memory_used'] = vmem.used
|
metrics['memory_used'] = used
|
||||||
metrics['memory_free'] = vmem.free
|
metrics['memory_free'] = vmem.free
|
||||||
metrics['memory_percent'] = vmem.percent
|
metrics['memory_percent'] = percent
|
||||||
|
|
||||||
# Platform-specific memory details
|
# Platform-specific memory details
|
||||||
if hasattr(vmem, 'active'):
|
if hasattr(vmem, 'active'):
|
||||||
|
|||||||
@@ -21,24 +21,23 @@ nagios_runner:
|
|||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import shlex
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
from hbd.client.plugin import MonitorPlugin
|
||||||
|
|
||||||
|
|
||||||
# Nagios exit codes
|
# Nagios exit codes
|
||||||
NAGIOS_OK = 0
|
|
||||||
NAGIOS_WARNING = 1
|
|
||||||
NAGIOS_CRITICAL = 2
|
|
||||||
NAGIOS_UNKNOWN = 3
|
NAGIOS_UNKNOWN = 3
|
||||||
|
|
||||||
STATUS_NAMES = {
|
STATUS_NAMES = {
|
||||||
NAGIOS_OK: "OK",
|
0: "OK",
|
||||||
NAGIOS_WARNING: "WARNING",
|
1: "WARNING",
|
||||||
NAGIOS_CRITICAL: "CRITICAL",
|
2: "CRITICAL",
|
||||||
NAGIOS_UNKNOWN: "UNKNOWN"
|
3: "UNKNOWN",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,8 +51,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
interval: Collection interval in seconds (default: 300)
|
interval: Collection interval in seconds (default: 300)
|
||||||
commands: List of command definitions with 'name' and 'command' keys
|
commands: List of command definitions with 'name' and 'command' keys
|
||||||
timeout: Command execution timeout in seconds (default: 30)
|
timeout: Command execution timeout in seconds (default: 30)
|
||||||
shell: Whether to execute commands via shell (default: True)
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
nagios_runner:
|
nagios_runner:
|
||||||
interval: 300 # Check every 5 minutes
|
interval: 300 # Check every 5 minutes
|
||||||
@@ -76,32 +74,48 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
# Extract configuration
|
# Extract configuration
|
||||||
self.commands: List[Dict[str, str]] = config.get("commands", []) if config else []
|
self.commands: List[Dict[str, str]] = config.get("commands", []) if config else []
|
||||||
self.timeout: int = config.get("timeout", 30) if config else 30
|
self.timeout: int = config.get("timeout", 30) if config else 30
|
||||||
self.shell: bool = config.get("shell", True) if config else True
|
|
||||||
self.interval = config.get("interval", 300) if config else 300
|
self.interval = config.get("interval", 300) if config else 300
|
||||||
|
|
||||||
# Validate commands
|
|
||||||
if not self.commands:
|
|
||||||
self.logger.warning(
|
|
||||||
"No Nagios commands configured. Add 'nagios_runner.commands' to config."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
"""Initialize the Nagios runner plugin.
|
"""Initialize the Nagios runner plugin.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if at least one command is configured, False otherwise
|
True if at least one command is configured, False otherwise
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"Initializing {self.name} plugin")
|
self.logger.info(f"Initializing {self.name} plugin")
|
||||||
|
|
||||||
if not self.commands:
|
if not self.commands:
|
||||||
self.logger.error("No Nagios commands configured")
|
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.logger.info(f"Configured to run {len(self.commands)} Nagios plugin(s)")
|
self.logger.info(f"Configured to run {len(self.commands)} Nagios plugin(s)")
|
||||||
for cmd_config in self.commands:
|
for cmd_config in self.commands:
|
||||||
name = cmd_config.get("name", "unnamed")
|
name = cmd_config.get("name", "unnamed")
|
||||||
self.logger.info(f" - {name}: {cmd_config.get('command', 'N/A')}")
|
self.logger.info(f" - {name}: {cmd_config.get('command', 'N/A')}")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(command)
|
||||||
|
except ValueError:
|
||||||
|
continue # malformed command string; skip validation
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
exe = tokens[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}"
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
@@ -111,98 +125,88 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
Dictionary with results from all plugins
|
Dictionary with results from all plugins
|
||||||
"""
|
"""
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
# Track overall status (worst status wins)
|
|
||||||
worst_status = NAGIOS_OK
|
|
||||||
|
|
||||||
for cmd_config in self.commands:
|
for cmd_config in self.commands:
|
||||||
name = cmd_config.get("name")
|
name = cmd_config.get("name")
|
||||||
command = cmd_config.get("command")
|
command = cmd_config.get("command")
|
||||||
|
|
||||||
if not name or not command:
|
if not name or not command:
|
||||||
self.logger.warning("Skipping command with missing name or command")
|
self.logger.warning("Skipping command with missing name or command")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Execute plugin
|
# Execute plugin
|
||||||
try:
|
try:
|
||||||
status_code, output, perfdata = await self._run_nagios_plugin(command)
|
status_code, output, perfdata = await self._run_nagios_plugin(command)
|
||||||
|
|
||||||
# Store results
|
# Store results
|
||||||
results[f"{name}_status"] = STATUS_NAMES.get(status_code, "UNKNOWN")
|
results[f"{name}_status"] = STATUS_NAMES.get(status_code, "UNKNOWN")
|
||||||
results[f"{name}_status_code"] = status_code
|
results[f"{name}_status_code"] = status_code
|
||||||
results[f"{name}_output"] = output
|
results[f"{name}_output"] = output
|
||||||
|
|
||||||
# Track worst status
|
|
||||||
if status_code > worst_status:
|
|
||||||
worst_status = status_code
|
|
||||||
|
|
||||||
# Parse and add performance data
|
# Parse and add performance data
|
||||||
if perfdata:
|
if perfdata:
|
||||||
for metric_name, metric_value in perfdata.items():
|
for metric_name, metric_value in perfdata.items():
|
||||||
results[f"{name}_{metric_name}"] = metric_value
|
results[f"{name}_{metric_name}"] = metric_value
|
||||||
|
|
||||||
self.logger.debug(
|
self.logger.info(
|
||||||
f"Executed {name}: {STATUS_NAMES.get(status_code, 'UNKNOWN')} - {output[:50]}"
|
f"Executed {name}: {STATUS_NAMES.get(status_code, 'UNKNOWN')} - {output[:50]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error running {name}: {e}", exc_info=True)
|
self.logger.error(f"Error running {name}: {e}", exc_info=True)
|
||||||
results[f"{name}_status"] = "ERROR"
|
results[f"{name}_status"] = "ERROR"
|
||||||
results[f"{name}_status_code"] = NAGIOS_UNKNOWN
|
results[f"{name}_status_code"] = NAGIOS_UNKNOWN
|
||||||
results[f"{name}_output"] = str(e)
|
results[f"{name}_output"] = str(e)
|
||||||
worst_status = NAGIOS_UNKNOWN
|
|
||||||
|
|
||||||
# Add overall status
|
|
||||||
results["overall_status"] = STATUS_NAMES.get(worst_status, "UNKNOWN")
|
|
||||||
results["overall_status_code"] = worst_status
|
|
||||||
results["plugin_count"] = len(self.commands)
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def _run_nagios_plugin(
|
async def _run_nagios_plugin(
|
||||||
self,
|
self,
|
||||||
command: str
|
command: str
|
||||||
) -> Tuple[int, str, Dict[str, Any]]:
|
) -> Tuple[int, str, Dict[str, Any]]:
|
||||||
"""Execute a Nagios plugin and parse its output.
|
"""Execute a Nagios plugin and parse its output."""
|
||||||
|
|
||||||
Args:
|
|
||||||
command: Command string to execute
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (status_code, output_message, performance_data_dict)
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Run command
|
proc = await asyncio.create_subprocess_shell(
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
command,
|
||||||
shell=self.shell,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
capture_output=True,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
timeout=self.timeout,
|
|
||||||
text=True
|
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
status_code = result.returncode
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||||
output = result.stdout.strip()
|
proc.communicate(), timeout=self.timeout
|
||||||
|
)
|
||||||
# Nagios plugins can return codes > 3, treat as UNKNOWN
|
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:
|
if status_code > 3:
|
||||||
status_code = NAGIOS_UNKNOWN
|
status_code = NAGIOS_UNKNOWN
|
||||||
|
|
||||||
# Parse performance data
|
stdout = stdout_bytes.decode(errors="replace").strip()
|
||||||
perfdata = self._parse_perfdata(output)
|
stderr = stderr_bytes.decode(errors="replace").strip()
|
||||||
|
|
||||||
# Extract just the status message (before the pipe if present)
|
# Parse perfdata from stdout before mixing in stderr
|
||||||
if '|' in output:
|
perfdata = self._parse_perfdata(stdout)
|
||||||
output_msg = output.split('|')[0].strip()
|
|
||||||
|
# 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:
|
else:
|
||||||
output_msg = output
|
output_msg = status_part
|
||||||
|
|
||||||
return status_code, output_msg, perfdata
|
return status_code, output_msg, perfdata
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self.logger.error(f"Command timed out: {command}")
|
|
||||||
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error executing command: {e}")
|
self.logger.error(f"Error executing command: {e}")
|
||||||
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
|
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class OSInfoPlugin(InfoPlugin):
|
|||||||
Dictionary with OS details
|
Dictionary with OS details
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from hbd import __version__ as hbc_version
|
||||||
data = {
|
data = {
|
||||||
"system": platform.system(), # e.g., "Linux", "Darwin", "Windows"
|
"system": platform.system(), # e.g., "Linux", "Darwin", "Windows"
|
||||||
"node": platform.node(), # hostname
|
"node": platform.node(), # hostname
|
||||||
@@ -58,7 +59,12 @@ class OSInfoPlugin(InfoPlugin):
|
|||||||
"architecture": platform.architecture()[0], # e.g., "64bit"
|
"architecture": platform.architecture()[0], # e.g., "64bit"
|
||||||
"python_version": platform.python_version(),
|
"python_version": platform.python_version(),
|
||||||
"python_implementation": platform.python_implementation(),
|
"python_implementation": platform.python_implementation(),
|
||||||
|
"hbc_version": hbc_version,
|
||||||
|
"hbc_type": "full",
|
||||||
}
|
}
|
||||||
|
if self.config.get("owner"):
|
||||||
|
self.logger.debug(f"Adding owner from config: {self.config['owner']}")
|
||||||
|
data["owner"] = self.config["owner"]
|
||||||
|
|
||||||
# Add Linux-specific distribution info
|
# Add Linux-specific distribution info
|
||||||
if platform.system() == "Linux":
|
if platform.system() == "Linux":
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Ping Monitor Plugin for Heartbeat.
|
||||||
|
|
||||||
|
Pings one or more hosts and reports round-trip time. Results are sent as
|
||||||
|
plugin metrics so the server-side threshold system can raise WARNING/CRITICAL
|
||||||
|
alerts using the same RTT threshold configuration format used for heartbeat RTT.
|
||||||
|
|
||||||
|
Example configuration in ~/.hbc.yaml (or the plugins section of ~/.hb.yaml):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
plugins:
|
||||||
|
ping_monitor:
|
||||||
|
interval: 60 # ping every 60 seconds (default)
|
||||||
|
count: 3 # ICMP packets per ping run (default 3)
|
||||||
|
timeout: 5 # seconds before a host is considered unreachable (default 5)
|
||||||
|
hosts:
|
||||||
|
- 8.8.8.8
|
||||||
|
- 192.168.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Reported metrics per host (metric key uses the hostname with dots/colons replaced
|
||||||
|
by underscores so it is a valid identifier):
|
||||||
|
|
||||||
|
ping.<hostname>.rtt_avg – average RTT in ms (float, or inf if unreachable)
|
||||||
|
ping.<hostname>.rtt_min – minimum RTT in ms
|
||||||
|
ping.<hostname>.rtt_max – maximum RTT in ms
|
||||||
|
ping.<hostname>.loss – packet loss percentage (0–100)
|
||||||
|
|
||||||
|
Server-side threshold config example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
threshold_configs:
|
||||||
|
default:
|
||||||
|
thresholds:
|
||||||
|
ping_monitor:
|
||||||
|
8_8_8_8_rtt_avg:
|
||||||
|
warning: 20.0
|
||||||
|
critical: 100.0
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from hbd.client.plugin import MonitorPlugin
|
||||||
|
|
||||||
|
|
||||||
|
def _host_key(host: str) -> str:
|
||||||
|
"""Convert a hostname/IP to a safe metric key (replace . and : with _)."""
|
||||||
|
return re.sub(r"[^a-zA-Z0-9_]", "_", host)
|
||||||
|
|
||||||
|
|
||||||
|
class PingMonitorPlugin(MonitorPlugin):
|
||||||
|
"""Ping one or more configured hosts and report RTT metrics."""
|
||||||
|
|
||||||
|
name = "ping_monitor"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "ICMP ping latency monitoring"
|
||||||
|
interval = 60
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
|
super().__init__(config)
|
||||||
|
cfg = config or {}
|
||||||
|
self.interval = cfg.get("interval", 60)
|
||||||
|
self.count = int(cfg.get("count", 3))
|
||||||
|
self.timeout = int(cfg.get("timeout", 5))
|
||||||
|
# hosts: dict of {hostname: {warning: x, critical: y}} or list of hostnames
|
||||||
|
raw_hosts = cfg.get("hosts", {})
|
||||||
|
if isinstance(raw_hosts, list):
|
||||||
|
self.hosts = {h: {} for h in raw_hosts}
|
||||||
|
else:
|
||||||
|
self.hosts = dict(raw_hosts)
|
||||||
|
|
||||||
|
async def initialize(self) -> bool:
|
||||||
|
if not self.hosts:
|
||||||
|
self.logger.warning("ping_monitor: no hosts configured, plugin disabled")
|
||||||
|
return False
|
||||||
|
self.logger.info(
|
||||||
|
"ping_monitor initialized: %d host(s), interval=%ds, count=%d, timeout=%ds",
|
||||||
|
len(self.hosts), self.interval, self.count, self.timeout,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _ping(self, host: str) -> Dict[str, float]:
|
||||||
|
"""Run a system ping command and return rtt_min/avg/max/loss."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
cmd = ["ping", "-n", str(self.count), "-w", str(self.timeout * 1000), host]
|
||||||
|
else:
|
||||||
|
cmd = ["ping", "-c", str(self.count), "-W", str(self.timeout), host]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, _ = await asyncio.wait_for(
|
||||||
|
proc.communicate(),
|
||||||
|
timeout=self.timeout * self.count + 2,
|
||||||
|
)
|
||||||
|
output = stdout.decode(errors="replace")
|
||||||
|
except (asyncio.TimeoutError, FileNotFoundError, OSError) as e:
|
||||||
|
self.logger.warning("ping_monitor: ping failed for %s: %s", host, e)
|
||||||
|
return {"rtt_min": float("inf"), "rtt_avg": float("inf"),
|
||||||
|
"rtt_max": float("inf"), "loss": 100.0}
|
||||||
|
|
||||||
|
# Parse packet loss
|
||||||
|
loss = 100.0
|
||||||
|
loss_match = re.search(r"(\d+(?:\.\d+)?)\s*%\s*packet\s*loss", output)
|
||||||
|
if loss_match:
|
||||||
|
loss = float(loss_match.group(1))
|
||||||
|
|
||||||
|
# Parse rtt min/avg/max — Linux: "rtt min/avg/max/mdev = x/x/x/x ms"
|
||||||
|
# macOS: "round-trip min/avg/max/stddev = x/x/x/x ms"
|
||||||
|
rtt_match = re.search(
|
||||||
|
r"(?:rtt|round-trip)\s+min/avg/max/\S+\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)",
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
if rtt_match:
|
||||||
|
return {
|
||||||
|
"rtt_min": float(rtt_match.group(1)),
|
||||||
|
"rtt_avg": float(rtt_match.group(2)),
|
||||||
|
"rtt_max": float(rtt_match.group(3)),
|
||||||
|
"loss": loss,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Host unreachable or all packets lost
|
||||||
|
return {"rtt_min": float("inf"), "rtt_avg": float("inf"),
|
||||||
|
"rtt_max": float("inf"), "loss": loss}
|
||||||
|
|
||||||
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
tasks = {host: asyncio.create_task(self._ping(host)) for host in self.hosts}
|
||||||
|
for host, task in tasks.items():
|
||||||
|
try:
|
||||||
|
result = await task
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("ping_monitor: error pinging %s: %s", host, e)
|
||||||
|
result = {"rtt_min": float("inf"), "rtt_avg": float("inf"),
|
||||||
|
"rtt_max": float("inf"), "loss": 100.0}
|
||||||
|
key = _host_key(host)
|
||||||
|
for metric, value in result.items():
|
||||||
|
data[f"{key}_{metric}"] = value
|
||||||
|
status = "unreachable" if result["loss"] == 100.0 else f"{result['rtt_avg']:.1f}ms"
|
||||||
|
self.logger.debug("ping_monitor: %s -> %s", host, status)
|
||||||
|
return data
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
ZFS pool monitoring plugin for Heartbeat.
|
||||||
|
|
||||||
|
Collects per-pool health, capacity, and cumulative I/O statistics via zpool(8).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from hbd.client.plugin import MonitorPlugin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _int(s: str) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(s.strip().rstrip("KMGTkBkmgt%x"))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _float(s: str) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
return float(s.strip().rstrip("%x"))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ZFSMonitorPlugin(MonitorPlugin):
|
||||||
|
"""Monitor ZFS pool health, capacity, and I/O statistics.
|
||||||
|
|
||||||
|
Collects per pool:
|
||||||
|
- health: ONLINE, DEGRADED, FAULTED, etc.
|
||||||
|
- size / alloc / free: total, allocated and free bytes
|
||||||
|
- capacity: percentage used (0-100)
|
||||||
|
- frag: fragmentation percentage
|
||||||
|
- dedup: deduplication ratio
|
||||||
|
- read_ops / write_ops: cumulative I/O operations since last boot/clear
|
||||||
|
- read_bw / write_bw: cumulative bytes transferred since last boot/clear
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
interval: collection interval in seconds (default: 300)
|
||||||
|
pools: list of pool names to monitor (default: all)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "zfs_monitor"
|
||||||
|
description = "ZFS pool health, capacity, and I/O statistics"
|
||||||
|
interval = 300
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
|
super().__init__(config)
|
||||||
|
self.interval = self.config.get("interval", 300)
|
||||||
|
self._pools_filter: Optional[List[str]] = self.config.get("pools", None)
|
||||||
|
|
||||||
|
async def initialize(self) -> bool:
|
||||||
|
if not shutil.which("zpool"):
|
||||||
|
self.skip_reason = "zpool not found"
|
||||||
|
return False
|
||||||
|
logger.info("ZFS monitor initialized (interval: %ds)", self.interval)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _run(self, *args: str) -> List[str]:
|
||||||
|
"""Run a command and return its stdout lines, or [] on error."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15)
|
||||||
|
return stdout.decode(errors="replace").splitlines()
|
||||||
|
except (FileNotFoundError, asyncio.TimeoutError) as exc:
|
||||||
|
logger.warning("zfs_monitor: %s: %s", args[0], exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _zpool_list(self) -> Dict[str, Dict]:
|
||||||
|
"""Return per-pool health and capacity from `zpool list`."""
|
||||||
|
lines = await self._run(
|
||||||
|
"zpool", "list", "-H", "-p",
|
||||||
|
"-o", "name,health,size,alloc,free,cap,frag,dedup",
|
||||||
|
)
|
||||||
|
pools: Dict[str, Dict] = {}
|
||||||
|
for line in lines:
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) < 8:
|
||||||
|
continue
|
||||||
|
name = parts[0].strip()
|
||||||
|
if self._pools_filter and name not in self._pools_filter:
|
||||||
|
continue
|
||||||
|
health = parts[1].strip()
|
||||||
|
if health == "ONLINE":
|
||||||
|
status = 0
|
||||||
|
elif health in ("DEGRADED", "ONLINE with errors"):
|
||||||
|
status = 1
|
||||||
|
elif health in ("FAULTED", "OFFLINE", "UNAVAIL"):
|
||||||
|
status = 2
|
||||||
|
else:
|
||||||
|
status = 3 # unknown status
|
||||||
|
pools[name] = {
|
||||||
|
"health": health,
|
||||||
|
"status": status,
|
||||||
|
"size": _int(parts[2]),
|
||||||
|
"alloc": _int(parts[3]),
|
||||||
|
"free": _int(parts[4]),
|
||||||
|
"capacity": _float(parts[5]),
|
||||||
|
"frag": _float(parts[6]),
|
||||||
|
"dedup": _float(parts[7]),
|
||||||
|
}
|
||||||
|
return pools
|
||||||
|
|
||||||
|
async def _zpool_iostat(self) -> Dict[str, Dict]:
|
||||||
|
"""Return per-pool cumulative I/O counters from `zpool iostat`."""
|
||||||
|
lines = await self._run("zpool", "iostat", "-H", "-p")
|
||||||
|
io: Dict[str, Dict] = {}
|
||||||
|
for line in lines:
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) < 7:
|
||||||
|
continue
|
||||||
|
name = parts[0].strip()
|
||||||
|
if not name or name.startswith(" "):
|
||||||
|
continue
|
||||||
|
io[name] = {
|
||||||
|
"read_ops": _int(parts[3]),
|
||||||
|
"write_ops": _int(parts[4]),
|
||||||
|
"read_bw": _int(parts[5]),
|
||||||
|
"write_bw": _int(parts[6]),
|
||||||
|
}
|
||||||
|
return io
|
||||||
|
|
||||||
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
|
pools, io = await asyncio.gather(self._zpool_list(), self._zpool_iostat())
|
||||||
|
for name, stats in io.items():
|
||||||
|
if name in pools:
|
||||||
|
pools[name].update(stats)
|
||||||
|
return {"pools": pools}
|
||||||
|
|
||||||
|
|
||||||
|
plugin = ZFSMonitorPlugin
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Common utilities shared between hbc and hbd."""
|
"""Common utilities shared between hbc and hbd."""
|
||||||
|
|
||||||
__version__ = "5.0.5"
|
from hbd import __version__
|
||||||
|
|||||||
+9
-4
@@ -52,12 +52,17 @@ def decode_value(val: str) -> Any:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return val[1:] # Return as string without @
|
return val[1:] # Return as string without @
|
||||||
|
|
||||||
# Try numeric evaluation (original behavior)
|
# Try numeric conversion (avoid eval to prevent SyntaxWarnings on version strings)
|
||||||
if val[0].isdigit() or (val[0] == '-' and len(val) > 1 and val[1].isdigit()):
|
if val[0].isdigit() or (val[0] == '-' and len(val) > 1 and val[1].isdigit()):
|
||||||
try:
|
try:
|
||||||
return eval(val)
|
return int(val)
|
||||||
except Exception:
|
except ValueError:
|
||||||
return val
|
pass
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return val
|
||||||
|
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,30 @@ thresholds:
|
|||||||
hysteresis: 0.1
|
hysteresis: 0.1
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# ZFS Monitor Thresholds
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
zfs_monitor:
|
||||||
|
# Pool health check — built-in default; shown here for reference/override.
|
||||||
|
# status is 0 (ONLINE) or 1 (DEGRADED) or 2 (SUSPENDED, FAULTED, UNAVAIL…).
|
||||||
|
# Use '*' to apply the same rule to every pool, or name a specific pool.
|
||||||
|
pools:
|
||||||
|
'*':
|
||||||
|
status:
|
||||||
|
warning: 1 # Alert WARNING when pool is DEGRADED
|
||||||
|
critical: 2 # Alert CRITICAL when pool is SUSPENDED/FAULTED/UNAVAIL
|
||||||
|
operator: ">"
|
||||||
|
hysteresis: 0.0 # No hysteresis — a degraded pool is always critical
|
||||||
|
display: "ZFS pool {pool_name} is {health}"
|
||||||
|
|
||||||
|
# Per-pool capacity thresholds (optional; add pools you care about)
|
||||||
|
# tank:
|
||||||
|
# capacity:
|
||||||
|
# warning: 75.0 # Warn at 75% used
|
||||||
|
# critical: 90.0 # Critical at 90% used
|
||||||
|
# operator: ">"
|
||||||
|
# hysteresis: 0.05
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Network Monitor Thresholds
|
# Network Monitor Thresholds
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""HeartBeat Daemon (hbd) - Server/daemon component."""
|
"""HeartBeat Daemon (hbd) - Server/daemon component."""
|
||||||
|
|
||||||
__version__ = "5.0.5"
|
from hbd import __version__
|
||||||
|
|||||||
+258
-10
@@ -1,6 +1,8 @@
|
|||||||
"""Command line interface for hbd package."""
|
"""Command line interface for hbd package."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
from .main import run as run_server
|
from .main import run as run_server
|
||||||
@@ -14,26 +16,272 @@ def build_parser():
|
|||||||
description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)",
|
description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"-c", "--config", dest="configfile", help="Config file path (YAML)"
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
)
|
|
||||||
parser.add_argument(
|
# --- serve (default) ---
|
||||||
"-f", "--foreground", action="store_true", help="Run in foreground"
|
serve_p = subparsers.add_parser("serve", help="Start the hbd server (default)")
|
||||||
)
|
serve_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
|
||||||
|
serve_p.add_argument("-f", "--foreground", action="store_true", help="Run in foreground")
|
||||||
|
serve_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||||
|
serve_p.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS,
|
||||||
|
help="Push service to use")
|
||||||
|
serve_p.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level")
|
||||||
|
|
||||||
|
# Legacy top-level flags (no subcommand) — kept for backward compatibility
|
||||||
|
parser.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
|
||||||
|
parser.add_argument("-f", "--foreground", action="store_true", help="Run in foreground")
|
||||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||||
parser.add_argument(
|
parser.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS,
|
||||||
"-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS, help="Push service to use"
|
help="Push service to use")
|
||||||
|
parser.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level")
|
||||||
|
|
||||||
|
# --- passwd ---
|
||||||
|
passwd_p = subparsers.add_parser(
|
||||||
|
"passwd",
|
||||||
|
help="Generate a password hash for use in the config file",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
passwd_p.add_argument(
|
||||||
"-x", "--debug", action="count", default=0, help="Increase debug level"
|
"username",
|
||||||
|
nargs="?",
|
||||||
|
help="Username (informational only, for display)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- notify ---
|
||||||
|
notify_p = subparsers.add_parser(
|
||||||
|
"notify",
|
||||||
|
help="Send a test message via a configured notification channel",
|
||||||
|
)
|
||||||
|
notify_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
|
||||||
|
notify_p.add_argument(
|
||||||
|
"channel",
|
||||||
|
help="Channel name as defined in notification_channels",
|
||||||
|
)
|
||||||
|
notify_p.add_argument(
|
||||||
|
"message",
|
||||||
|
nargs="?",
|
||||||
|
default="Test notification from hbd",
|
||||||
|
help="Message body (default: 'Test notification from hbd')",
|
||||||
|
)
|
||||||
|
notify_p.add_argument(
|
||||||
|
"--level",
|
||||||
|
default="WARNING",
|
||||||
|
choices=["INFO", "WARNING", "CRITICAL", "RECOVER"],
|
||||||
|
help="Notification level (default: WARNING)",
|
||||||
|
)
|
||||||
|
notify_p.add_argument(
|
||||||
|
"--title",
|
||||||
|
default=None,
|
||||||
|
help="Notification title (default: '[LEVEL] test')",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- stop ---
|
||||||
|
stop_p = subparsers.add_parser("stop", help="Stop the running hbd instance")
|
||||||
|
stop_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
|
||||||
|
|
||||||
|
# --- reload ---
|
||||||
|
reload_p = subparsers.add_parser("reload", help="Reload configuration (SIGHUP)")
|
||||||
|
reload_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
|
||||||
|
|
||||||
|
# --- restart ---
|
||||||
|
restart_p = subparsers.add_parser("restart", help="Restart the running hbd instance")
|
||||||
|
restart_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
|
||||||
|
restart_p.add_argument("-f", "--foreground", action="store_true", help="Run in foreground after restart")
|
||||||
|
restart_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output after restart")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_passwd(args):
|
||||||
|
"""Interactive password hash generator."""
|
||||||
|
from .users import hash_password
|
||||||
|
|
||||||
|
username = args.username or ""
|
||||||
|
prompt = f"New password for {username}: " if username else "New password: "
|
||||||
|
while True:
|
||||||
|
pw = getpass.getpass(prompt)
|
||||||
|
if not pw:
|
||||||
|
print("Password must not be empty.", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
pw2 = getpass.getpass("Confirm password: ")
|
||||||
|
if pw != pw2:
|
||||||
|
print("Passwords do not match, try again.", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
hashed = hash_password(pw)
|
||||||
|
if username:
|
||||||
|
print(f"\nAdd the following to your config under users: -> {username}:")
|
||||||
|
else:
|
||||||
|
print("\nPassword hash (paste into config file under the user's 'password' key):")
|
||||||
|
print(f" password: {hashed}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_notify(args):
|
||||||
|
"""Send a test message via a single notification channel."""
|
||||||
|
from .config import load_config
|
||||||
|
from .notify import Notification, _dispatch_to_channel, setup
|
||||||
|
|
||||||
|
config = load_config(args.configfile)
|
||||||
|
setup(config)
|
||||||
|
|
||||||
|
channels = config.get("notification_channels", {})
|
||||||
|
if args.channel not in channels:
|
||||||
|
available = ", ".join(channels.keys()) if channels else "(none)"
|
||||||
|
print(f"Error: channel '{args.channel}' not found in notification_channels.", file=sys.stderr)
|
||||||
|
print(f"Available channels: {available}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
channel_cfg = channels[args.channel]
|
||||||
|
level = args.level.upper()
|
||||||
|
title = args.title or f"[{level}] test"
|
||||||
|
base_url = config.get("base_url", "").rstrip("/")
|
||||||
|
|
||||||
|
notif = Notification(
|
||||||
|
title=title,
|
||||||
|
body=args.message,
|
||||||
|
level=level,
|
||||||
|
url=f"{base_url}/plugins" if base_url else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from .notify import _send_matrix_async, _send_sms_voipms_async, _DRIVERS
|
||||||
|
ch_type = channel_cfg.get("type", "")
|
||||||
|
print(f"Sending via {args.channel} ({ch_type}): {title} — {args.message}")
|
||||||
|
|
||||||
|
if ch_type == "matrix":
|
||||||
|
ok = asyncio.run(_send_matrix_async(channel_cfg, notif))
|
||||||
|
elif ch_type == "sms_voipms":
|
||||||
|
ok = asyncio.run(_send_sms_voipms_async(channel_cfg, notif))
|
||||||
|
else:
|
||||||
|
driver = _DRIVERS.get(ch_type)
|
||||||
|
if driver is None:
|
||||||
|
print(f"Error: unknown channel type '{ch_type}'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
ok = driver(channel_cfg, notif)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
print("OK")
|
||||||
|
else:
|
||||||
|
print("FAILED — check logs for details", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pid(configfile) -> int | None:
|
||||||
|
"""Return the PID from the pidfile, or None if not found / not running."""
|
||||||
|
import os
|
||||||
|
config = load_config(configfile)
|
||||||
|
pidfile = config.get("pidfile", "")
|
||||||
|
if not pidfile:
|
||||||
|
print("Error: no pidfile configured.", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(pidfile) as f:
|
||||||
|
pid = int(f.read().strip())
|
||||||
|
# Verify process is actually running
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return pid
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"PID file not found ({pidfile}). Is hbd running?", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except ProcessLookupError:
|
||||||
|
print(f"PID file exists but process {pid} is not running.", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading pidfile: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_stop(args):
|
||||||
|
import os, signal as _signal, time
|
||||||
|
pid = _read_pid(args.configfile)
|
||||||
|
if pid is None:
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"Stopping hbd (pid {pid})...")
|
||||||
|
os.kill(pid, _signal.SIGTERM)
|
||||||
|
# Wait up to 10 s for the process to exit
|
||||||
|
for _ in range(20):
|
||||||
|
time.sleep(0.5)
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
print("hbd stopped.")
|
||||||
|
return
|
||||||
|
print("Warning: hbd did not stop within 10 seconds.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_reload(args):
|
||||||
|
import os, signal as _signal
|
||||||
|
pid = _read_pid(args.configfile)
|
||||||
|
if pid is None:
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"Sending SIGHUP to hbd (pid {pid})...")
|
||||||
|
os.kill(pid, _signal.SIGHUP)
|
||||||
|
print("Reload signal sent.")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_restart(args):
|
||||||
|
import os, signal as _signal, time, subprocess
|
||||||
|
pid = _read_pid(args.configfile)
|
||||||
|
if pid is not None:
|
||||||
|
print(f"Stopping hbd (pid {pid})...")
|
||||||
|
os.kill(pid, _signal.SIGTERM)
|
||||||
|
for _ in range(20):
|
||||||
|
time.sleep(0.5)
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
print("hbd stopped.")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("Warning: hbd did not stop within 10 seconds.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("hbd does not appear to be running — starting fresh.")
|
||||||
|
|
||||||
|
# Re-launch hbd with the same config
|
||||||
|
cmd = [sys.executable, "-m", "hbd.server.cli", "serve"]
|
||||||
|
if args.configfile:
|
||||||
|
cmd += ["-c", args.configfile]
|
||||||
|
if getattr(args, "foreground", False):
|
||||||
|
cmd += ["-f"]
|
||||||
|
if getattr(args, "verbose", False):
|
||||||
|
cmd += ["-v"]
|
||||||
|
|
||||||
|
if getattr(args, "foreground", False):
|
||||||
|
# Run in foreground — replace current process
|
||||||
|
os.execv(sys.executable, cmd)
|
||||||
|
else:
|
||||||
|
subprocess.Popen(cmd, start_new_session=True)
|
||||||
|
print("hbd restarted.")
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.command == "passwd":
|
||||||
|
cmd_passwd(args)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "notify":
|
||||||
|
cmd_notify(args)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "stop":
|
||||||
|
cmd_stop(args)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "reload":
|
||||||
|
cmd_reload(args)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "restart":
|
||||||
|
cmd_restart(args)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Default: run the server (supports both `hbd serve ...` and `hbd ...`)
|
||||||
config = load_config(args.configfile)
|
config = load_config(args.configfile)
|
||||||
|
|
||||||
# Apply CLI overrides
|
# Apply CLI overrides
|
||||||
|
|||||||
+123
-121
@@ -14,26 +14,31 @@ SERVER_DEFAULTS = {
|
|||||||
"hb_port": 50003, # Port to listen for heartbeats
|
"hb_port": 50003, # Port to listen for heartbeats
|
||||||
"hbd_port": 50004, # HTTP API port
|
"hbd_port": 50004, # HTTP API port
|
||||||
"hbd_host": "", # Bind address (empty = all interfaces)
|
"hbd_host": "", # Bind address (empty = all interfaces)
|
||||||
|
|
||||||
# Persistence
|
# Persistence
|
||||||
"pickfile": "/tmp/hb.pick",
|
"pickfile": os.path.join(os.path.expanduser("~"), ".hb.pick"), # File to store host state between restarts
|
||||||
|
"pidfile": os.path.join(os.path.expanduser("~"), ".hb.pid"), # PID file for stop/restart/reload
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
"logfile": "/var/log/heartbeat.log",
|
"logfile": os.path.join(os.path.expanduser("~"), ".hb.log"),
|
||||||
"logfmt": "text", # text or msg or json
|
|
||||||
|
|
||||||
# Notification channels
|
# Notification channels
|
||||||
"notification_channels": {}, # Named channels with type and credentials
|
"notification_channels": {}, # Named channels with type and credentials
|
||||||
"default_notification_channels": [], # Default channels if host doesn't specify
|
"base_url": "", # Base URL for notification links (e.g. https://hbd.example.com)
|
||||||
|
|
||||||
# Monitoring settings
|
# Monitoring settings
|
||||||
"interval": 20, # Expected heartbeat interval (for server checks)
|
"interval": 20, # Expected heartbeat interval (for server checks)
|
||||||
"grace": 2, # Grace multiplier (interval * grace = timeout)
|
"grace": 2, # Grace period (extra seconds before notifying after a missed heartbeat)
|
||||||
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications
|
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications
|
||||||
|
|
||||||
|
# User management
|
||||||
|
"users": {}, # username -> {full_name, avatar, password, admin, notification_channels}
|
||||||
|
"default_owner": None, # Username that owns hosts with no explicit owner
|
||||||
|
|
||||||
|
# OAuth2 providers
|
||||||
|
"oauth": {}, # oauth.gitea.{url,client_id,client_secret}
|
||||||
|
|
||||||
# Host management
|
# Host management
|
||||||
"hosts": {}, # New unified host definitions (optional)
|
"hosts": {}, # Unified host definitions
|
||||||
"watchhosts": [], # Hosts to monitor and notify about (legacy)
|
|
||||||
"dyndnshosts": [], # Hosts with dynamic DNS (legacy)
|
"dyndnshosts": [], # Hosts with dynamic DNS (legacy)
|
||||||
"drophosts": [], # Hosts to ignore
|
"drophosts": [], # Hosts to ignore
|
||||||
"dyndomains": ["wrede.org"],
|
"dyndomains": ["wrede.org"],
|
||||||
@@ -65,6 +70,57 @@ SERVER_DEFAULTS = {
|
|||||||
"thresholds": {},
|
"thresholds": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
THRESHOLD_DEFAULTS = {
|
||||||
|
'thresholds': {
|
||||||
|
'cpu_monitor': {
|
||||||
|
'cpu_percent': {
|
||||||
|
'warning': 80.0,
|
||||||
|
'critical': 90.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'memory_monitor': {
|
||||||
|
'percent': {
|
||||||
|
'warning': 85.0,
|
||||||
|
'critical': 95.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'disk_monitor': {
|
||||||
|
'partitions': {
|
||||||
|
'/': {
|
||||||
|
'percent': {
|
||||||
|
'warning': 85.0,
|
||||||
|
'critical': 90.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'rtt': {
|
||||||
|
'warning': 200,
|
||||||
|
'critical': 250.0,
|
||||||
|
'count': 3 # Optional: number of consecutive breaches before alerting
|
||||||
|
},
|
||||||
|
'nagios_runner': {
|
||||||
|
'status_code': {
|
||||||
|
'display': '{check_name} {output}',
|
||||||
|
'operator': "nagios"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'zfs_monitor': {
|
||||||
|
'pools': {
|
||||||
|
'*': {
|
||||||
|
'status': {
|
||||||
|
'warning': 1,
|
||||||
|
'critical': 2,
|
||||||
|
'operator': '>',
|
||||||
|
'hysteresis': 0.0,
|
||||||
|
'display': 'ZFS pool {pool_name} is {health}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_config(path=None):
|
def load_config(path=None):
|
||||||
"""Load configuration from a YAML file and merge with server defaults.
|
"""Load configuration from a YAML file and merge with server defaults.
|
||||||
@@ -182,34 +238,18 @@ class ReloadableConfig:
|
|||||||
|
|
||||||
|
|
||||||
def get_watchhosts(config):
|
def get_watchhosts(config):
|
||||||
"""Extract watchhosts from config, supporting both new and legacy formats.
|
"""Extract watched hostnames from config (hosts with watch: true).
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Configuration dictionary
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of hostnames to watch
|
List of hostnames to watch
|
||||||
"""
|
"""
|
||||||
watchhosts = []
|
watchhosts = []
|
||||||
|
hosts_config = config.get("hosts", {})
|
||||||
# New format: hosts section with watch attribute
|
if isinstance(hosts_config, dict):
|
||||||
if "hosts" in config:
|
for host_name, host_attrs in hosts_config.items():
|
||||||
hosts_config = config["hosts"]
|
if isinstance(host_attrs, dict) and host_attrs.get("watch", True):
|
||||||
if isinstance(hosts_config, dict):
|
watchhosts.append(host_name)
|
||||||
for host_name, host_attrs in hosts_config.items():
|
return watchhosts
|
||||||
if isinstance(host_attrs, dict) and host_attrs.get("watch", False):
|
|
||||||
watchhosts.append(host_name)
|
|
||||||
|
|
||||||
# Legacy format: watchhosts list
|
|
||||||
if "watchhosts" in config:
|
|
||||||
legacy_watchhosts = config.get("watchhosts", [])
|
|
||||||
if isinstance(legacy_watchhosts, (list, set)):
|
|
||||||
watchhosts.extend(legacy_watchhosts)
|
|
||||||
elif isinstance(legacy_watchhosts, dict):
|
|
||||||
# Old dict format: {"host1": {attrs}, "host2": {attrs}}
|
|
||||||
watchhosts.extend(legacy_watchhosts.keys())
|
|
||||||
|
|
||||||
return list(set(watchhosts)) # Remove duplicates
|
|
||||||
|
|
||||||
|
|
||||||
def get_dyndnshosts(config):
|
def get_dyndnshosts(config):
|
||||||
@@ -241,100 +281,62 @@ def get_dyndnshosts(config):
|
|||||||
|
|
||||||
|
|
||||||
def get_host_config(config, hostname):
|
def get_host_config(config, hostname):
|
||||||
"""Get configuration for a specific host.
|
"""Get configuration for a specific host from the hosts section.
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Configuration dictionary
|
|
||||||
hostname: Host name
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with host attributes or empty dict
|
Dictionary with host attributes or empty dict
|
||||||
"""
|
"""
|
||||||
if "hosts" in config:
|
hosts_config = config.get("hosts", {})
|
||||||
hosts_config = config.get("hosts", {})
|
if isinstance(hosts_config, dict) and hostname in hosts_config:
|
||||||
if isinstance(hosts_config, dict) and hostname in hosts_config:
|
val = hosts_config[hostname]
|
||||||
return hosts_config[hostname] if isinstance(hosts_config[hostname], dict) else {}
|
return val if isinstance(val, dict) else {}
|
||||||
|
|
||||||
# Check legacy watchhosts for notification settings
|
|
||||||
if "watchhosts" in config:
|
|
||||||
watchhosts = config.get("watchhosts", {})
|
|
||||||
if isinstance(watchhosts, dict) and hostname in watchhosts:
|
|
||||||
legacy_attrs = watchhosts[hostname]
|
|
||||||
if isinstance(legacy_attrs, dict):
|
|
||||||
# Convert legacy format to new format
|
|
||||||
return {
|
|
||||||
"watch": True,
|
|
||||||
"notify": legacy_attrs.get("notify"),
|
|
||||||
"notify_src": legacy_attrs.get("src"),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def get_notification_channels_for_host(config, hostname):
|
# ---------------------------------------------------------------------------
|
||||||
"""Get notification channels configured for a specific host.
|
# User / host-access helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
Args:
|
|
||||||
config: Configuration dictionary
|
|
||||||
hostname: Host name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of channel names to use for this host
|
|
||||||
"""
|
|
||||||
host_config = get_host_config(config, hostname)
|
|
||||||
|
|
||||||
# Check if host specifies notification channels
|
|
||||||
channels = host_config.get("notification_channels", [])
|
|
||||||
if channels:
|
|
||||||
if isinstance(channels, str):
|
|
||||||
return [channels]
|
|
||||||
elif isinstance(channels, list):
|
|
||||||
return channels
|
|
||||||
|
|
||||||
# Fall back to default channels
|
|
||||||
default_channels = config.get("default_notification_channels", [])
|
|
||||||
if default_channels:
|
|
||||||
if isinstance(default_channels, str):
|
|
||||||
return [default_channels]
|
|
||||||
elif isinstance(default_channels, list):
|
|
||||||
return default_channels
|
|
||||||
|
|
||||||
# No channels configured, return empty list (will use legacy global config)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
def get_default_owner(config) -> str | None:
|
||||||
def get_channel_config(config, channel_name):
|
"""Return the configured default_owner username, or the first admin user, or None."""
|
||||||
"""Get configuration for a specific notification channel.
|
explicit = config.get("default_owner")
|
||||||
|
if explicit:
|
||||||
Args:
|
return explicit
|
||||||
config: Configuration dictionary
|
# Fall back to first admin user found in config
|
||||||
channel_name: Name of the notification channel
|
users_cfg = config.get("users", {})
|
||||||
|
if isinstance(users_cfg, dict):
|
||||||
Returns:
|
for username, attrs in users_cfg.items():
|
||||||
Dictionary with channel configuration or None if not found
|
if isinstance(attrs, dict) and attrs.get("admin", False):
|
||||||
"""
|
return username
|
||||||
channels = config.get("notification_channels", {})
|
|
||||||
if isinstance(channels, dict) and channel_name in channels:
|
|
||||||
return channels[channel_name]
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_notification_channels_config(config, hostname):
|
def get_host_access(config, hostname) -> dict:
|
||||||
"""Get list of notification channel configurations for a host.
|
"""Return the access dict for *hostname*: owner, managers, monitors.
|
||||||
|
|
||||||
Args:
|
Falls back to default_owner for hosts without an explicit owner.
|
||||||
config: Configuration dictionary
|
|
||||||
hostname: Host name
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of (channel_name, channel_config) tuples
|
{
|
||||||
|
"owner": str | None,
|
||||||
|
"managers": list[str],
|
||||||
|
"monitors": list[str],
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
channel_names = get_notification_channels_for_host(config, hostname)
|
host_cfg = get_host_config(config, hostname)
|
||||||
|
|
||||||
channels = []
|
owner = host_cfg.get("owner") # or get_default_owner(config)
|
||||||
for channel_name in channel_names:
|
|
||||||
channel_config = get_channel_config(config, channel_name)
|
managers = host_cfg.get("managers", [])
|
||||||
if channel_config and channel_config.get("type"):
|
if isinstance(managers, str):
|
||||||
channels.append((channel_name, channel_config))
|
managers = [managers]
|
||||||
|
|
||||||
return channels
|
monitors = host_cfg.get("monitors", [])
|
||||||
|
if isinstance(monitors, str):
|
||||||
|
monitors = [monitors]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"owner": owner,
|
||||||
|
"managers": list(managers),
|
||||||
|
"monitors": list(monitors),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""YAML round-trip read/write for .hb.yaml, with backup and atomic writes."""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
_write_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_yaml() -> YAML:
|
||||||
|
y = YAML()
|
||||||
|
y.preserve_quotes = True
|
||||||
|
return y
|
||||||
|
|
||||||
|
# Top-level keys managed by the 'server' logical section
|
||||||
|
_SERVER_KEYS = [
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Top-level keys managed by the 'dns' logical section
|
||||||
|
_DNS_KEYS = ["nsupdate_bin", "dyndomains", "dyndnshosts", "drophosts"]
|
||||||
|
|
||||||
|
|
||||||
|
def read_roundtrip(path: str):
|
||||||
|
"""Load .hb.yaml with ruamel.yaml, preserving comments and ordering."""
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return _make_yaml().load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(path: str, data) -> 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.
|
||||||
|
"""
|
||||||
|
with _write_lock:
|
||||||
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_path = f"{path}.bak.{ts}"
|
||||||
|
n = 0
|
||||||
|
while os.path.exists(backup_path):
|
||||||
|
n += 1
|
||||||
|
backup_path = f"{path}.bak.{ts}-{n}"
|
||||||
|
orig_mode = None
|
||||||
|
if os.path.exists(path):
|
||||||
|
orig_mode = os.stat(path).st_mode
|
||||||
|
with open(path, "rb") as src, open(backup_path, "wb") as dst:
|
||||||
|
dst.write(src.read())
|
||||||
|
os.chmod(backup_path, orig_mode)
|
||||||
|
backups = sorted(glob.glob(f"{path}.bak.*"), reverse=True)
|
||||||
|
for old in backups[10:]:
|
||||||
|
os.unlink(old)
|
||||||
|
tmp = f"{path}.tmp"
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
_make_yaml().dump(data, f)
|
||||||
|
if orig_mode is not None:
|
||||||
|
os.chmod(tmp, orig_mode)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups(path: str) -> list:
|
||||||
|
"""Return backup paths sorted newest-first."""
|
||||||
|
return sorted(glob.glob(f"{path}.bak.*"), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_structured_section(data, section: str, values: dict) -> None:
|
||||||
|
"""Merge a dict of scalar/list values into data for the named logical section.
|
||||||
|
|
||||||
|
For 'server': updates each known key individually, preserving comments on
|
||||||
|
unchanged keys. For 'users': replaces the entire users dict.
|
||||||
|
"""
|
||||||
|
if section == "server":
|
||||||
|
for key in _SERVER_KEYS:
|
||||||
|
if key in values:
|
||||||
|
data[key] = values[key]
|
||||||
|
elif section == "users":
|
||||||
|
data["users"] = values
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown structured section: {section!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_yaml_section(data, section: str, yaml_text: str) -> None:
|
||||||
|
"""Replace the named logical section by parsing yaml_text."""
|
||||||
|
parsed = _make_yaml().load(yaml_text)
|
||||||
|
if section == "notification_channels":
|
||||||
|
data["notification_channels"] = parsed
|
||||||
|
elif section == "thresholds":
|
||||||
|
data["threshold_configs"] = parsed
|
||||||
|
elif section == "hosts":
|
||||||
|
data["hosts"] = parsed
|
||||||
|
elif section == "dns":
|
||||||
|
if parsed:
|
||||||
|
for key in _DNS_KEYS:
|
||||||
|
if key in parsed:
|
||||||
|
data[key] = parsed[key]
|
||||||
|
else:
|
||||||
|
for key in _DNS_KEYS:
|
||||||
|
data.pop(key, None)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown YAML section: {section!r}")
|
||||||
+56
-5
@@ -95,7 +95,7 @@ class Connection:
|
|||||||
if not Null:
|
if not Null:
|
||||||
d["addr"] = self.addr
|
d["addr"] = self.addr
|
||||||
if self.rtts[-1]:
|
if self.rtts[-1]:
|
||||||
d["rtt"] = "%0.1f" % self.rtts[-1]
|
d["rtt"] = "%d" % round(self.rtts[-1])
|
||||||
elif self.state == Connection.UNKNOWN:
|
elif self.state == Connection.UNKNOWN:
|
||||||
d["rtt"] = ""
|
d["rtt"] = ""
|
||||||
else:
|
else:
|
||||||
@@ -189,7 +189,7 @@ class Connection:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.addr = addr
|
self.addr = addr
|
||||||
Connection.htab[addr] = self.host.nameconnection_count
|
Connection.htab[addr] = self.host.name
|
||||||
if self.host.isDynDns():
|
if self.host.isDynDns():
|
||||||
Host.dnsQ.put((self.host.name, self.addr))
|
Host.dnsQ.put((self.host.name, self.addr))
|
||||||
return r
|
return r
|
||||||
@@ -286,7 +286,7 @@ class Host:
|
|||||||
Host.hosts[name] = self
|
Host.hosts[name] = self
|
||||||
self.num = num
|
self.num = num
|
||||||
self.dyn = False
|
self.dyn = False
|
||||||
self.watched = False
|
self.watched = True
|
||||||
self.upcount = 0
|
self.upcount = 0
|
||||||
self.interval = 0
|
self.interval = 0
|
||||||
self.doesack = -1
|
self.doesack = -1
|
||||||
@@ -297,9 +297,14 @@ class Host:
|
|||||||
self.plugin_retention = 100 # Keep last N samples per plugin
|
self.plugin_retention = 100 # Keep last N samples per plugin
|
||||||
# Alert state tracking: {metric_path: AlertState}
|
# Alert state tracking: {metric_path: AlertState}
|
||||||
self.alert_states = {}
|
self.alert_states = {}
|
||||||
|
# User access control
|
||||||
|
self.owner: str | None = None # username of owner
|
||||||
|
self.managers: list = [] # usernames with manager role
|
||||||
|
self.monitors: list = [] # usernames with monitor role
|
||||||
|
|
||||||
def statedict(self):
|
def statedict(self):
|
||||||
d = {}
|
d = {}
|
||||||
|
d["raw_name"] = self.name
|
||||||
d["name"] = self.name
|
d["name"] = self.name
|
||||||
if self.dyn:
|
if self.dyn:
|
||||||
d["name"] += "*"
|
d["name"] += "*"
|
||||||
@@ -412,7 +417,20 @@ class Host:
|
|||||||
ddict["alert_warning_acked"] = warning_acked
|
ddict["alert_warning_acked"] = warning_acked
|
||||||
ddict["alert_critical_unacked"] = critical_unacked
|
ddict["alert_critical_unacked"] = critical_unacked
|
||||||
ddict["alert_critical_acked"] = critical_acked
|
ddict["alert_critical_acked"] = critical_acked
|
||||||
|
|
||||||
|
# User access
|
||||||
|
ddict["owner"] = getattr(self, "owner", None)
|
||||||
|
ddict["managers"] = list(getattr(self, "managers", []))
|
||||||
|
ddict["monitors"] = list(getattr(self, "monitors", []))
|
||||||
|
|
||||||
|
# hbc version from latest os_info plugin data
|
||||||
|
hbc_version = None
|
||||||
|
latest_os = self.get_latest_plugin_data("os_info")
|
||||||
|
if latest_os:
|
||||||
|
_, os_data = latest_os
|
||||||
|
hbc_version = os_data.get("hbc_version")
|
||||||
|
ddict["hbc_version"] = hbc_version
|
||||||
|
|
||||||
return ddict
|
return ddict
|
||||||
|
|
||||||
def jsons(self):
|
def jsons(self):
|
||||||
@@ -458,6 +476,13 @@ class Host:
|
|||||||
self.plugin_retention = 100
|
self.plugin_retention = 100
|
||||||
if not hasattr(self, "alert_states"):
|
if not hasattr(self, "alert_states"):
|
||||||
self.alert_states = {}
|
self.alert_states = {}
|
||||||
|
# User access fields (added in user-management feature)
|
||||||
|
if not hasattr(self, "owner"):
|
||||||
|
self.owner = None
|
||||||
|
if not hasattr(self, "managers"):
|
||||||
|
self.managers = []
|
||||||
|
if not hasattr(self, "monitors"):
|
||||||
|
self.monitors = []
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -511,12 +536,38 @@ class Host:
|
|||||||
|
|
||||||
def get_all_plugin_data(self):
|
def get_all_plugin_data(self):
|
||||||
"""Get all plugin data for this host.
|
"""Get all plugin data for this host.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict of {plugin_name: [(timestamp, data), ...]}
|
Dict of {plugin_name: [(timestamp, data), ...]}
|
||||||
"""
|
"""
|
||||||
return self.plugin_data
|
return self.plugin_data
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# User-role helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def apply_access(self, owner, managers, monitors):
|
||||||
|
"""Set owner/managers/monitors on this host (called from config load)."""
|
||||||
|
self.owner = owner
|
||||||
|
self.managers = list(managers)
|
||||||
|
self.monitors = list(monitors)
|
||||||
|
|
||||||
|
def is_owner(self, username: str) -> bool:
|
||||||
|
return self.owner == username
|
||||||
|
|
||||||
|
def is_manager(self, username: str) -> bool:
|
||||||
|
return username in self.managers or self.is_owner(username)
|
||||||
|
|
||||||
|
def is_monitor(self, username: str) -> bool:
|
||||||
|
return username in self.monitors or self.is_manager(username)
|
||||||
|
|
||||||
|
def access_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"owner": self.owner,
|
||||||
|
"managers": list(self.managers),
|
||||||
|
"monitors": list(self.monitors),
|
||||||
|
}
|
||||||
|
|
||||||
hostfields_long = [
|
hostfields_long = [
|
||||||
"name",
|
"name",
|
||||||
"IPv4.addr",
|
"IPv4.addr",
|
||||||
|
|||||||
+908
-53
File diff suppressed because it is too large
Load Diff
+148
-63
@@ -14,7 +14,8 @@ from . import hbdclass
|
|||||||
|
|
||||||
from . import ws as ws_mod
|
from . import ws as ws_mod
|
||||||
from . import notify as notify_mod
|
from . import notify as notify_mod
|
||||||
from . import data
|
from . import data
|
||||||
|
from . import users as users_mod
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
msg_to_websockets = ws_mod.broadcast
|
msg_to_websockets = ws_mod.broadcast
|
||||||
@@ -22,12 +23,13 @@ eventlog = notify_mod.eventlog
|
|||||||
|
|
||||||
# shared runtime collections and helpers
|
# shared runtime collections and helpers
|
||||||
|
|
||||||
def cleanup_function(config, hbdclass):
|
def save_state(config, hbdclass):
|
||||||
"""This function will be executed upon program exit."""
|
"""Save current state to pickle file. Safe to call at any time."""
|
||||||
logger.info("Running cleanup function...")
|
|
||||||
import pickle
|
import pickle
|
||||||
|
import os
|
||||||
|
from . import users as users_mod
|
||||||
|
|
||||||
# Ensure all timer references are cleared before pickling
|
# Clear timer references before pickling (they can't be serialized)
|
||||||
for hostname, host in list(hbdclass.Host.hosts.items()):
|
for hostname, host in list(hbdclass.Host.hosts.items()):
|
||||||
for conn_type, conn in host.connections.items():
|
for conn_type, conn in host.connections.items():
|
||||||
if hasattr(conn, 'cancel_overdue_timer'):
|
if hasattr(conn, 'cancel_overdue_timer'):
|
||||||
@@ -40,13 +42,27 @@ def cleanup_function(config, hbdclass):
|
|||||||
conn.timeout_duration = None
|
conn.timeout_duration = None
|
||||||
|
|
||||||
pickfile = config.get("pickfile", "hbd.pickle")
|
pickfile = config.get("pickfile", "hbd.pickle")
|
||||||
|
tmpfile = pickfile + ".tmp"
|
||||||
|
|
||||||
pickf = open(pickfile, "wb")
|
try:
|
||||||
pick = pickle.Pickler(pickf)
|
with open(tmpfile, "wb") as pickf:
|
||||||
pick.dump(hbdclass.Host.hosts)
|
pick = pickle.Pickler(pickf)
|
||||||
pick.dump(data.msgs)
|
pick.dump(hbdclass.Host.hosts)
|
||||||
pickf.close()
|
pick.dump(data.msgs)
|
||||||
|
pick.dump(users_mod.save_sessions())
|
||||||
|
os.replace(tmpfile, pickfile)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to save state: %s", e)
|
||||||
|
try:
|
||||||
|
os.unlink(tmpfile)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_function(config, hbdclass):
|
||||||
|
"""This function will be executed upon program exit."""
|
||||||
|
logger.info("Running cleanup function...")
|
||||||
|
save_state(config, hbdclass)
|
||||||
logger.info("Cleanup complete.")
|
logger.info("Cleanup complete.")
|
||||||
|
|
||||||
|
|
||||||
@@ -71,10 +87,24 @@ async def reload_configuration(config_obj, config_path, components):
|
|||||||
|
|
||||||
# Update notify module
|
# Update notify module
|
||||||
notify_mod.reload_config(new_config)
|
notify_mod.reload_config(new_config)
|
||||||
|
|
||||||
# Reload threshold checker
|
# Reload users
|
||||||
|
users_mod.load_users(new_config)
|
||||||
|
|
||||||
|
# Re-apply host attributes from updated config to all known hosts
|
||||||
|
from . import config as config_mod
|
||||||
|
dyndnshosts = config_mod.get_dyndnshosts(new_config)
|
||||||
|
watchhosts = config_mod.get_watchhosts(new_config)
|
||||||
|
for hostname, host in hbdclass.Host.hosts.items():
|
||||||
|
host.dyn = hostname in dyndnshosts
|
||||||
|
host.watched = hostname in watchhosts
|
||||||
|
access = config_mod.get_host_access(new_config, hostname)
|
||||||
|
host.apply_access(access["owner"], access["managers"], access["monitors"])
|
||||||
|
|
||||||
|
# Reload threshold checker and prune alerts orphaned by the new config
|
||||||
if 'threshold_checker' in components:
|
if 'threshold_checker' in components:
|
||||||
components['threshold_checker'].reload(new_config)
|
components['threshold_checker'].reload(new_config)
|
||||||
|
components['threshold_checker'].purge_stale_alerts(hbdclass)
|
||||||
|
|
||||||
# Note: Changes to the following require restart:
|
# Note: Changes to the following require restart:
|
||||||
# - hb_port, hbd_port, ws_port (already bound)
|
# - hb_port, hbd_port, ws_port (already bound)
|
||||||
@@ -103,6 +133,10 @@ async def reload_configuration(config_obj, config_path, components):
|
|||||||
|
|
||||||
|
|
||||||
async def _run_async(config, config_path=None):
|
async def _run_async(config, config_path=None):
|
||||||
|
from .config import ReloadableConfig
|
||||||
|
if not isinstance(config, ReloadableConfig):
|
||||||
|
config = ReloadableConfig(config, config_path)
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
shutdown_event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
reload_event = asyncio.Event()
|
reload_event = asyncio.Event()
|
||||||
@@ -129,7 +163,7 @@ async def _run_async(config, config_path=None):
|
|||||||
from . import journal as journal_mod
|
from . import journal as journal_mod
|
||||||
from . import threshold as threshold_mod
|
from . import threshold as threshold_mod
|
||||||
|
|
||||||
notify_mod.setup(config)
|
notify_mod.setup(config, loop=loop)
|
||||||
|
|
||||||
# Initialize message journal
|
# Initialize message journal
|
||||||
msg_journal = journal_mod.get_journal(config)
|
msg_journal = journal_mod.get_journal(config)
|
||||||
@@ -160,30 +194,57 @@ async def _run_async(config, config_path=None):
|
|||||||
f"Warning: Could not reset IPV6_V6ONLY not supported or dual-stack is unavailable. Error: {e}"
|
f"Warning: Could not reset IPV6_V6ONLY not supported or dual-stack is unavailable. Error: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Bind to all interfaces (::) on a specific port
|
|
||||||
|
|
||||||
# UDP server endpoint (handler wired to handle_datagram with context)
|
|
||||||
bind_addr = ("::", config.get("hb_port", 50003))
|
bind_addr = ("::", config.get("hb_port", 50003))
|
||||||
sock.bind(bind_addr)
|
sock.bind(bind_addr)
|
||||||
logger.info("Starting UDP server on %s:%s", *bind_addr)
|
logger.info("Starting UDP server on %s:%s", *bind_addr)
|
||||||
|
|
||||||
def udp_handler(msg, addr, transport):
|
# Try to enable kernel receive timestamps (Linux SO_TIMESTAMP).
|
||||||
|
# If supported, read datagrams via recvmsg() so RTT uses the kernel
|
||||||
|
# timestamp rather than the time.time() call after asyncio scheduling.
|
||||||
|
use_kernel_ts = udp.enable_kernel_timestamps(sock)
|
||||||
|
if use_kernel_ts:
|
||||||
|
logger.info("SO_TIMESTAMP enabled: using kernel receive timestamps for RTT")
|
||||||
|
else:
|
||||||
|
logger.info("SO_TIMESTAMP not available: using time.time() for RTT")
|
||||||
|
|
||||||
|
def udp_handler(msg, addr, transport, recv_ts=None):
|
||||||
ctx = dict(
|
ctx = dict(
|
||||||
config=config,
|
config=config,
|
||||||
hbdclass=hbdclass,
|
hbdclass=hbdclass,
|
||||||
log=eventlog,
|
|
||||||
msg_to_websockets=msg_to_websockets,
|
msg_to_websockets=msg_to_websockets,
|
||||||
msg_journal=msg_journal,
|
msg_journal=msg_journal,
|
||||||
threshold_checker=threshold_checker,
|
threshold_checker=threshold_checker,
|
||||||
DEBUG=config.get("debug", 0),
|
DEBUG=config.get("debug", 0),
|
||||||
verbose=config.get("verbose", False),
|
verbose=config.get("verbose", False),
|
||||||
|
recv_ts=recv_ts,
|
||||||
)
|
)
|
||||||
udp.handle_datagram(msg, addr, transport, ctx)
|
udp.handle_datagram(msg, addr, transport, ctx)
|
||||||
|
|
||||||
transport, protocol = await loop.create_datagram_endpoint(
|
if use_kernel_ts:
|
||||||
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler),
|
# recvmsg path: manage the socket ourselves with loop.add_reader()
|
||||||
sock=sock,
|
sock.setblocking(False)
|
||||||
|
transport = udp.RecvmsgTransport(loop, sock)
|
||||||
|
reader = udp.make_recvmsg_reader(sock, udp_handler, transport)
|
||||||
|
loop.add_reader(sock.fileno(), reader)
|
||||||
|
protocol = None
|
||||||
|
else:
|
||||||
|
transport, protocol = await loop.create_datagram_endpoint(
|
||||||
|
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler),
|
||||||
|
sock=sock,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restore connection timers for hosts loaded from pickle
|
||||||
|
restore_ctx = dict(
|
||||||
|
config=config,
|
||||||
|
hbdclass=hbdclass,
|
||||||
|
msg_to_websockets=msg_to_websockets,
|
||||||
|
threshold_checker=threshold_checker,
|
||||||
)
|
)
|
||||||
|
udp.restore_connection_timers(hbdclass, restore_ctx)
|
||||||
|
|
||||||
|
# Drop alert states that no longer have a matching threshold (stale after
|
||||||
|
# upgrade or config change between runs).
|
||||||
|
threshold_checker.purge_stale_alerts(hbdclass)
|
||||||
|
|
||||||
# HTTP server (asyncio-based via aiohttp)
|
# HTTP server (asyncio-based via aiohttp)
|
||||||
try:
|
try:
|
||||||
@@ -194,6 +255,7 @@ async def _run_async(config, config_path=None):
|
|||||||
config=config,
|
config=config,
|
||||||
hbdclass=hbdclass,
|
hbdclass=hbdclass,
|
||||||
tcss=None,
|
tcss=None,
|
||||||
|
threshold_checker=threshold_checker,
|
||||||
verbose=config.get("verbose", False),
|
verbose=config.get("verbose", False),
|
||||||
get_now=lambda: time.time(),
|
get_now=lambda: time.time(),
|
||||||
VER="",
|
VER="",
|
||||||
@@ -217,45 +279,30 @@ async def _run_async(config, config_path=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("dns worker failed to start: %s", e)
|
logger.exception("dns worker failed to start: %s", e)
|
||||||
|
|
||||||
# Start the websocket servers as a background task
|
# Register WebSocket state — connections are now served through /ws on the HTTP port
|
||||||
if config.get("wss_port", None):
|
ws_task = None
|
||||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
ws_mod.setup(
|
||||||
ssl_path = config.get("cert_path", "")
|
loop=loop,
|
||||||
wss_pem = ssl_path + config.get("wss_pem", "")
|
get_hosts=lambda: [
|
||||||
wss_key = ssl_path + config.get("wss_key", "")
|
hbdclass.Host.hosts[h].stateinfo()
|
||||||
try:
|
for h in sorted(hbdclass.Host.hosts)
|
||||||
ssl_context.load_cert_chain(wss_pem, keyfile=wss_key)
|
],
|
||||||
except FileNotFoundError:
|
verbose=config.get("verbose", False),
|
||||||
logger.error("error: missing SSL keys %s or %s", wss_pem, wss_key)
|
)
|
||||||
sys.exit(1)
|
logger.info("WebSocket handler registered on /ws (HTTP port %s)", config.get("hbd_port", 50004))
|
||||||
logger.info(
|
|
||||||
"Starting secure WebSocket server on port %s with cert %s",
|
|
||||||
config.get("wss_port", None),
|
|
||||||
wss_pem,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ssl_context = None
|
|
||||||
|
|
||||||
try:
|
# Periodic autosave task
|
||||||
ws_port = config.get("ws_port", 50005)
|
autosave_interval = config.get("autosave_interval", 300) # default: 5 minutes
|
||||||
logger.info("Starting WebSocket server on port %s", ws_port)
|
|
||||||
ws_task = asyncio.create_task(
|
async def autosave_task():
|
||||||
ws_mod.start(
|
while True:
|
||||||
host=config.get("hbd_host", ""),
|
await asyncio.sleep(autosave_interval)
|
||||||
ws_port=ws_port,
|
logger.debug("Autosaving state...")
|
||||||
wss_port=config.get("wss_port", None),
|
save_state(config, hbdclass)
|
||||||
ssl_context=ssl_context,
|
logger.debug("Autosave complete (%d hosts)", len(hbdclass.Host.hosts))
|
||||||
get_hosts=lambda: [
|
|
||||||
hbdclass.Host.hosts[h].stateinfo()
|
autosave = asyncio.create_task(autosave_task())
|
||||||
for h in sorted(hbdclass.Host.hosts)
|
logger.info("Autosave task started (interval: %ds)", autosave_interval)
|
||||||
],
|
|
||||||
# get_msgs=lambda: msgs,
|
|
||||||
config=config,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.info("WebSocket task started")
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("websocket server failed to start: %s", e)
|
|
||||||
|
|
||||||
# Main event loop - monitor shutdown and reload events
|
# Main event loop - monitor shutdown and reload events
|
||||||
try:
|
try:
|
||||||
@@ -304,7 +351,7 @@ async def _run_async(config, config_path=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error closing UDP transport: %s", e)
|
logger.warning("Error closing UDP transport: %s", e)
|
||||||
|
|
||||||
tasks_to_cancel = [http_task, ws_task]
|
tasks_to_cancel = [http_task, autosave]
|
||||||
for task in tasks_to_cancel:
|
for task in tasks_to_cancel:
|
||||||
if task:
|
if task:
|
||||||
try:
|
try:
|
||||||
@@ -355,6 +402,13 @@ async def _run_async(config, config_path=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error stopping DNS worker: %s", e)
|
logger.warning("Error stopping DNS worker: %s", e)
|
||||||
|
|
||||||
|
# Save state (hosts + sessions) on clean shutdown
|
||||||
|
try:
|
||||||
|
save_state(config, hbdclass)
|
||||||
|
logger.info("State saved on shutdown")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error saving state on shutdown: %s", e)
|
||||||
|
|
||||||
logger.info("All tasks cancelled")
|
logger.info("All tasks cancelled")
|
||||||
|
|
||||||
|
|
||||||
@@ -363,6 +417,7 @@ def load_pickled_hosts(config, hbdclass):
|
|||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
from . import config as config_mod
|
from . import config as config_mod
|
||||||
|
from . import users as users_mod
|
||||||
|
|
||||||
pickfile = config.get("pickfile", "hbd.pickle")
|
pickfile = config.get("pickfile", "hbd.pickle")
|
||||||
dyndnshosts = config_mod.get_dyndnshosts(config)
|
dyndnshosts = config_mod.get_dyndnshosts(config)
|
||||||
@@ -376,6 +431,10 @@ def load_pickled_hosts(config, hbdclass):
|
|||||||
try:
|
try:
|
||||||
hbdclass.Host.hosts = pick.load()
|
hbdclass.Host.hosts = pick.load()
|
||||||
data.msgs = pick.load()
|
data.msgs = pick.load()
|
||||||
|
try:
|
||||||
|
users_mod.load_sessions(pick.load())
|
||||||
|
except Exception:
|
||||||
|
pass # older pickle without sessions — fine
|
||||||
pickf.close()
|
pickf.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("load pickled failed: %s", e)
|
logger.exception("load pickled failed: %s", e)
|
||||||
@@ -385,6 +444,10 @@ def load_pickled_hosts(config, hbdclass):
|
|||||||
hbdclass.Host.hosts[h].dyn = h in dyndnshosts
|
hbdclass.Host.hosts[h].dyn = h in dyndnshosts
|
||||||
hbdclass.Host.hosts[h].watched = h in watchhosts
|
hbdclass.Host.hosts[h].watched = h in watchhosts
|
||||||
hbdclass.Host.hosts[h].fixup()
|
hbdclass.Host.hosts[h].fixup()
|
||||||
|
access = config_mod.get_host_access(config, h)
|
||||||
|
hbdclass.Host.hosts[h].apply_access(
|
||||||
|
access["owner"], access["managers"], access["monitors"]
|
||||||
|
)
|
||||||
for h in drophosts:
|
for h in drophosts:
|
||||||
if h in hbdclass.Host.hosts:
|
if h in hbdclass.Host.hosts:
|
||||||
del hbdclass.Host.hosts[h]
|
del hbdclass.Host.hosts[h]
|
||||||
@@ -406,12 +469,28 @@ def run(config, config_path=None):
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
logging.basicConfig(
|
log_level = logging.WARNING
|
||||||
level=logging.DEBUG if config.get("debug", 0) > 0 else logging.INFO
|
if config.get("verbose", False):
|
||||||
)
|
log_level = logging.INFO
|
||||||
|
if config.get("debug", 0) > 0:
|
||||||
|
log_level = logging.DEBUG
|
||||||
|
logging.basicConfig(level=log_level)
|
||||||
|
if not config.get("debug", 0):
|
||||||
|
logging.getLogger("aiohttp.access").propagate = False
|
||||||
load_pickled_hosts(config, hbdclass)
|
load_pickled_hosts(config, hbdclass)
|
||||||
|
|
||||||
notify_mod.initlog(logfile=config.get("logfile", "messages.log"))
|
notify_mod.initlog(logfile=config.get("logfile", "messages.log"))
|
||||||
|
users_mod.load_users(config)
|
||||||
|
|
||||||
|
# Write pidfile
|
||||||
|
pidfile = config.get("pidfile", "")
|
||||||
|
if pidfile:
|
||||||
|
try:
|
||||||
|
with open(pidfile, "w") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to write pidfile %s: %s", pidfile, e)
|
||||||
|
|
||||||
eventlog(None, "INFO", f"hbd version {__version__} starting up")
|
eventlog(None, "INFO", f"hbd version {__version__} starting up")
|
||||||
|
|
||||||
if config_path:
|
if config_path:
|
||||||
@@ -434,6 +513,12 @@ def run(config, config_path=None):
|
|||||||
logger.info("hbd shutdown complete")
|
logger.info("hbd shutdown complete")
|
||||||
eventlog(None, "INFO", f"hbd version {__version__} shutdown")
|
eventlog(None, "INFO", f"hbd version {__version__} shutdown")
|
||||||
notify_mod.closelog()
|
notify_mod.closelog()
|
||||||
|
# Remove pidfile
|
||||||
|
if pidfile:
|
||||||
|
try:
|
||||||
|
os.unlink(pidfile)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Explicitly close the loop
|
# Explicitly close the loop
|
||||||
try:
|
try:
|
||||||
# Cancel all remaining tasks
|
# Cancel all remaining tasks
|
||||||
|
|||||||
+400
-234
@@ -1,37 +1,100 @@
|
|||||||
"""Notification helpers: email, pushover, mattermost, signal and dispatcher."""
|
"""Notification helpers: email, pushover, matrix, mattermost, signal, sms and dispatcher.
|
||||||
|
|
||||||
|
Channel types supported:
|
||||||
|
pushover - Pushover app notifications
|
||||||
|
email - SMTP email
|
||||||
|
matrix - Matrix (via matrix-nio)
|
||||||
|
mattermost - Mattermost webhook
|
||||||
|
signal - Signal via signal-cli subprocess
|
||||||
|
sms_voipms - SMS via voip.ms REST API
|
||||||
|
|
||||||
|
Each channel can specify ``min_level: WARNING|CRITICAL`` (default: WARNING).
|
||||||
|
|
||||||
|
Notifications are dispatched to the owner + managers of the host, each via
|
||||||
|
their own ``notification_channels`` list. When no users are configured the
|
||||||
|
server runs silently (no notifications sent).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
import http.client
|
|
||||||
import urllib.parse
|
|
||||||
import subprocess
|
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import sys
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from . import data
|
from . import data
|
||||||
from . import ws as ws_mod
|
from . import ws as ws_mod
|
||||||
from . import main as main_mod
|
|
||||||
|
|
||||||
DEFAULT_PUSHPROVIDERS = ["all", "pushover", "mattermost", "signal"]
|
|
||||||
msg_to_websockets = ws_mod.broadcast
|
|
||||||
|
|
||||||
# module-level configuration set via setup()
|
|
||||||
_config = {}
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
msg_to_websockets = ws_mod.broadcast
|
||||||
|
|
||||||
|
# Module-level state set via setup()
|
||||||
|
_config: dict = {}
|
||||||
|
|
||||||
|
# Tracks which channels fired a WARNING/CRITICAL per host.
|
||||||
|
# {host_name: set of channel_names} — used to route RECOVER to the same channels.
|
||||||
|
_alerted_channels: dict = {}
|
||||||
|
|
||||||
logf = None
|
logf = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Level ordering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_LEVEL_ORDER = {"RECOVER": 0, "INFO": 0, "WARNING": 1, "CRITICAL": 2}
|
||||||
|
|
||||||
|
def _level_value(level: str) -> int:
|
||||||
|
return _LEVEL_ORDER.get(level.upper(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Notification dataclass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Notification:
|
||||||
|
"""Structured notification payload."""
|
||||||
|
title: str # e.g. "[CRITICAL] webserver01"
|
||||||
|
body: str # detail message
|
||||||
|
level: str # RECOVER | WARNING | CRITICAL | INFO
|
||||||
|
url: str = "" # link to plugin metrics page
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def setup(cfg: dict, loop: Optional[asyncio.AbstractEventLoop] = None):
|
||||||
|
"""Initialize notifier from configuration dict."""
|
||||||
|
global _config
|
||||||
|
_config = dict(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def reload_config(cfg: dict):
|
||||||
|
"""Reload notification configuration on SIGHUP."""
|
||||||
|
global _config
|
||||||
|
_config = dict(cfg)
|
||||||
|
logger.info("Notification configuration reloaded")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event log (websocket + file + in-memory)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def initlog(logfile):
|
def initlog(logfile):
|
||||||
global logf
|
global logf
|
||||||
try:
|
try:
|
||||||
logf = open(logfile, "a+")
|
logf = open(logfile, "a+")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import sys
|
|
||||||
|
|
||||||
print("cannot open logfile %s, using STDERR: %s" % (logfile, e))
|
print("cannot open logfile %s, using STDERR: %s" % (logfile, e))
|
||||||
logf = sys.stderr
|
logf = sys.stderr
|
||||||
return logf
|
return logf
|
||||||
|
|
||||||
|
|
||||||
def closelog():
|
def closelog():
|
||||||
global logf
|
global logf
|
||||||
if logf and logf != sys.stderr:
|
if logf and logf != sys.stderr:
|
||||||
@@ -40,13 +103,21 @@ def closelog():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def eventlog(host, lvl, m, service=None):
|
def eventlog(host, lvl, m, service=None):
|
||||||
ts = time.time()
|
ts = time.time()
|
||||||
|
msg = {
|
||||||
|
"ts": ts,
|
||||||
|
"host": host or None,
|
||||||
|
"level": lvl,
|
||||||
|
"service": service,
|
||||||
|
"message": m,
|
||||||
|
}
|
||||||
|
data.msgs.append(msg)
|
||||||
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {lvl} "
|
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {lvl} "
|
||||||
if host:
|
if host:
|
||||||
s += f"{host} "
|
s += f"{host} "
|
||||||
s += m
|
s += m
|
||||||
data.msgs.append(s)
|
|
||||||
logger.info(s)
|
logger.info(s)
|
||||||
if logf:
|
if logf:
|
||||||
try:
|
try:
|
||||||
@@ -54,93 +125,33 @@ def eventlog(host, lvl, m, service=None):
|
|||||||
logf.flush()
|
logf.flush()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("failed to write to logfile: %s", e)
|
logger.warning("failed to write to logfile: %s", e)
|
||||||
msg_to_websockets("message", s)
|
msg_to_websockets("message", msg)
|
||||||
|
|
||||||
def setup(cfg: dict):
|
|
||||||
"""Initialize notifier defaults from a configuration dict."""
|
|
||||||
global _config
|
|
||||||
_config = dict(cfg)
|
|
||||||
|
|
||||||
|
|
||||||
def reload_config(cfg: dict):
|
# ---------------------------------------------------------------------------
|
||||||
"""Reload notification configuration.
|
# Low-level channel drivers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
This function updates the module-level notification configuration
|
|
||||||
during runtime config reloads.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cfg: New configuration dictionary
|
|
||||||
"""
|
|
||||||
global _config
|
|
||||||
_config = dict(cfg)
|
|
||||||
logger.info("Notification configuration reloaded")
|
|
||||||
|
|
||||||
|
def _send_pushover(channel_cfg: dict, notif: Notification) -> bool:
|
||||||
def send_email(toaddrs, smtpserver, sender, subject, body, debug=0):
|
import http.client
|
||||||
"""Send a plain email via SMTP. Returns True on success."""
|
import urllib.parse
|
||||||
try:
|
token = channel_cfg.get("token", "")
|
||||||
smtpport = _config.get("smtpport", 587)
|
user = channel_cfg.get("user", "")
|
||||||
server = smtplib.SMTP(smtpserver, smtpport)
|
if not token or not user:
|
||||||
if debug > 0:
|
logger.warning("pushover: missing token or user")
|
||||||
server.set_debuglevel(1)
|
|
||||||
if smtpport == 587:
|
|
||||||
server.starttls()
|
|
||||||
server.ehlo()
|
|
||||||
smtpuser = _config.get("smtpuser", None)
|
|
||||||
smtppassword = _config.get("smtppassword", None)
|
|
||||||
if smtpuser and smtppassword:
|
|
||||||
server.login(smtpuser, smtppassword)
|
|
||||||
server.sendmail(sender, toaddrs, body)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("email send failed: %s", e)
|
|
||||||
try:
|
|
||||||
server.quit()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
return False
|
||||||
try:
|
params: dict = {"token": token, "user": user, "title": notif.title, "message": notif.body}
|
||||||
server.quit()
|
if channel_cfg.get("sound"):
|
||||||
except Exception:
|
params["sound"] = channel_cfg["sound"]
|
||||||
pass
|
if notif.url:
|
||||||
return True
|
params["url"] = notif.url
|
||||||
|
params["url_title"] = "Heartbeat"
|
||||||
|
|
||||||
def email(subject: str, msg: str, debug: int = 0) -> bool:
|
|
||||||
"""Convenience wrapper exposed to the rest of the application.
|
|
||||||
|
|
||||||
Uses module-level configuration to supply recipient list, smtp server
|
|
||||||
and sender address.
|
|
||||||
"""
|
|
||||||
toaddrs = _config.get("toemail")
|
|
||||||
fromemail = _config.get("fromemail")
|
|
||||||
smtpserver = _config.get("smtpserver")
|
|
||||||
if not toaddrs or not fromemail or not smtpserver:
|
|
||||||
logger.warning(
|
|
||||||
"email config incomplete: toemail=%s, fromemail=%s, smtpserver=%s",
|
|
||||||
toaddrs,
|
|
||||||
fromemail,
|
|
||||||
smtpserver,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
date = time.strftime("%a, %d %b %Y %H:%M:%S %z", time.localtime())
|
|
||||||
body = "To: %s\nFrom: %s\nSubject: %s\nDate: %s\n\n%s" % (
|
|
||||||
toaddrs[0] if toaddrs else "",
|
|
||||||
fromemail,
|
|
||||||
subject,
|
|
||||||
date,
|
|
||||||
msg,
|
|
||||||
)
|
|
||||||
return send_email(toaddrs, smtpserver, fromemail, subject, body, debug=debug)
|
|
||||||
|
|
||||||
|
|
||||||
def pushover(token: str, user: str, msg: str, debug: int = 0) -> bool:
|
|
||||||
"""Send message via Pushover API."""
|
|
||||||
conn = http.client.HTTPSConnection("api.pushover.net:443")
|
conn = http.client.HTTPSConnection("api.pushover.net:443")
|
||||||
try:
|
try:
|
||||||
conn.request(
|
conn.request(
|
||||||
"POST",
|
"POST",
|
||||||
"/1/messages.json",
|
"/1/messages.json",
|
||||||
urllib.parse.urlencode({"token": token, "user": user, "message": msg}),
|
urllib.parse.urlencode(params),
|
||||||
{"Content-type": "application/x-www-form-urlencoded"},
|
{"Content-type": "application/x-www-form-urlencoded"},
|
||||||
)
|
)
|
||||||
r = conn.getresponse()
|
r = conn.getresponse()
|
||||||
@@ -151,176 +162,331 @@ def pushover(token: str, user: str, msg: str, debug: int = 0) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def pushmattermost(
|
def _send_email(channel_cfg: dict, notif: Notification) -> bool:
|
||||||
host: str,
|
recipients = channel_cfg.get("recipients", [])
|
||||||
token: str,
|
sender = channel_cfg.get("sender", "")
|
||||||
channel: str,
|
smtp_server = channel_cfg.get("smtp_server", "")
|
||||||
msg: str,
|
smtp_port = channel_cfg.get("smtp_port", 587)
|
||||||
username: str = "hbd",
|
smtp_user = channel_cfg.get("smtp_user")
|
||||||
icon: Optional[str] = None,
|
smtp_password = channel_cfg.get("smtp_password")
|
||||||
debug: int = 0,
|
|
||||||
) -> bool:
|
|
||||||
"""Send a message to Mattermost via simple webhook driver if available.
|
|
||||||
|
|
||||||
This helper tries to import mattermostdriver.Driver and uses webhooks if present.
|
if not recipients or not sender or not smtp_server:
|
||||||
If the import fails it returns False.
|
logger.warning("email: missing recipients, sender, or smtp_server")
|
||||||
"""
|
return False
|
||||||
|
|
||||||
|
date = time.strftime("%a, %d %b %Y %H:%M:%S %z", time.localtime())
|
||||||
|
body_text = notif.body
|
||||||
|
if notif.url:
|
||||||
|
body_text += f"\n\n{notif.url}"
|
||||||
|
raw = "To: %s\nFrom: %s\nSubject: %s\nDate: %s\n\n%s" % (
|
||||||
|
recipients[0] if isinstance(recipients, list) else recipients,
|
||||||
|
sender,
|
||||||
|
notif.title,
|
||||||
|
date,
|
||||||
|
body_text,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
server = smtplib.SMTP(smtp_server, smtp_port)
|
||||||
|
if smtp_port == 587:
|
||||||
|
server.starttls()
|
||||||
|
server.ehlo()
|
||||||
|
if smtp_user and smtp_password:
|
||||||
|
server.login(smtp_user, smtp_password)
|
||||||
|
server.sendmail(sender, recipients, raw)
|
||||||
|
server.quit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("email send failed: %s", e)
|
||||||
|
try:
|
||||||
|
server.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _send_mattermost(channel_cfg: dict, notif: Notification) -> bool:
|
||||||
try:
|
try:
|
||||||
from mattermostdriver import Driver
|
from mattermostdriver import Driver
|
||||||
except Exception:
|
except ImportError:
|
||||||
|
logger.error("mattermostdriver not installed")
|
||||||
return False
|
return False
|
||||||
|
host = channel_cfg.get("host", "")
|
||||||
|
token = channel_cfg.get("token", "")
|
||||||
|
channel = channel_cfg.get("channel", "")
|
||||||
|
if not host or not token or not channel:
|
||||||
|
logger.warning("mattermost: missing host, token, or channel")
|
||||||
|
return False
|
||||||
|
text = f"**{notif.title}**\n{notif.body}"
|
||||||
|
if notif.url:
|
||||||
|
text += f"\n[Plugin metrics] {notif.url}"
|
||||||
ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065}
|
ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065}
|
||||||
mm = Driver(ses)
|
mm = Driver(ses)
|
||||||
payload = {"text": msg, "channel": channel, "username": username}
|
payload: dict = {"text": text, "channel": channel, "username": channel_cfg.get("username", "hbd")}
|
||||||
|
icon = channel_cfg.get("icon")
|
||||||
if icon:
|
if icon:
|
||||||
payload["icon_url"] = icon
|
payload["icon_url"] = icon
|
||||||
try:
|
try:
|
||||||
rc = mm.webhooks.call_webhook(token, payload)
|
rc = mm.webhooks.call_webhook(token, payload)
|
||||||
logger.debug("mattermost rc: %s", rc)
|
|
||||||
return bool(rc is None or rc == "")
|
return bool(rc is None or rc == "")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("mattermost error: %s", e)
|
logger.error("mattermost error: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def pushsignal(
|
def _send_signal(channel_cfg: dict, notif: Notification) -> bool:
|
||||||
signal_cli_bin: str, user: str, recipient: str, msg: str, debug: int = 0
|
cli = channel_cfg.get("cli_path", "/usr/local/bin/signal-cli")
|
||||||
) -> bool:
|
user = channel_cfg.get("user", "")
|
||||||
"""Send a message via signal-cli (requires local installation).
|
recipient = channel_cfg.get("recipient", "")
|
||||||
|
if not user or not recipient:
|
||||||
Uses subprocess to call signal-cli. Returns True if the command succeeded.
|
logger.warning("signal: missing user or recipient")
|
||||||
"""
|
return False
|
||||||
CLI = [signal_cli_bin, "-u", user, "send", "-m", msg, recipient]
|
msg = f"{notif.title}\n{notif.body}"
|
||||||
logger.debug("signal cli: %s", CLI)
|
if notif.url:
|
||||||
|
msg += f"\n{notif.url}"
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(CLI, capture_output=True)
|
res = subprocess.run([cli, "-u", user, "send", "-m", msg, recipient], capture_output=True)
|
||||||
if res.returncode != 0:
|
if res.returncode != 0:
|
||||||
logger.error("signal failed: %s".res.stderr.decode())
|
logger.error("signal failed: %s", res.stderr.decode())
|
||||||
return False
|
return False
|
||||||
logger.debug("signal sent: %s", res.stdout.decode())
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("signal exception: %s", e)
|
logger.exception("signal exception: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _dispatch_to_channel(channel_name: str, channel_config: dict, msg: str, debug: int = 0) -> bool:
|
async def _send_sms_voipms_async(channel_cfg: dict, notif: Notification) -> bool:
|
||||||
"""Dispatch a message to a specific notification channel.
|
"""Send SMS via voip.ms REST API using multipart form-data POST."""
|
||||||
|
import json
|
||||||
Args:
|
import aiohttp
|
||||||
channel_name: Name of the channel (for logging)
|
|
||||||
channel_config: Channel configuration dictionary with 'type' and type-specific fields
|
api_user = channel_cfg.get("api_user", "")
|
||||||
msg: Message to send
|
api_password = channel_cfg.get("api_password", "")
|
||||||
debug: Debug level
|
did = channel_cfg.get("did", "")
|
||||||
|
dst = channel_cfg.get("dst", "")
|
||||||
Returns:
|
if not api_user or not api_password or not did or not dst:
|
||||||
True if notification sent successfully, False otherwise
|
logger.warning("sms_voipms: missing api_user, api_password, did, or dst")
|
||||||
"""
|
return False
|
||||||
channel_type = channel_config.get("type")
|
|
||||||
|
# SMS body: title + body, truncated to 160 chars
|
||||||
if channel_type == "pushover":
|
text = f"{notif.title}: {notif.body}"
|
||||||
return pushover(
|
if len(text) > 160:
|
||||||
channel_config.get("token", ""),
|
text = text[:157] + "..."
|
||||||
channel_config.get("user", ""),
|
|
||||||
msg,
|
form_data = {
|
||||||
debug=debug
|
"api_username": api_user,
|
||||||
)
|
"api_password": api_password,
|
||||||
|
"method": "sendSMS",
|
||||||
elif channel_type == "email":
|
"did": did,
|
||||||
# Build email from channel config
|
"dst": dst,
|
||||||
recipients = channel_config.get("recipients", [])
|
"message": text,
|
||||||
sender = channel_config.get("sender", "")
|
}
|
||||||
smtp_server = channel_config.get("smtp_server", "")
|
|
||||||
smtp_port = channel_config.get("smtp_port", 587)
|
try:
|
||||||
smtp_user = channel_config.get("smtp_user")
|
async with aiohttp.ClientSession() as session:
|
||||||
smtp_password = channel_config.get("smtp_password")
|
with aiohttp.MultipartWriter("form-data") as mp:
|
||||||
|
for key, value in form_data.items():
|
||||||
if not recipients or not sender or not smtp_server:
|
part = mp.append(value)
|
||||||
logger.warning(
|
part.set_content_disposition("form-data", name=key)
|
||||||
"Email channel '%s' missing required fields: recipients=%s, sender=%s, smtp_server=%s",
|
async with session.post("https://voip.ms/api/v1/rest.php", data=mp) as resp:
|
||||||
channel_name, recipients, sender, smtp_server
|
body = await resp.text()
|
||||||
)
|
if resp.status != 200:
|
||||||
return False
|
logger.error("sms_voipms HTTP %s: %s", resp.status, body)
|
||||||
|
return False
|
||||||
# Temporarily update _config for email() function
|
result = json.loads(body)
|
||||||
old_config = dict(_config)
|
if result.get("status") == "success":
|
||||||
_config["toemail"] = recipients
|
return True
|
||||||
_config["fromemail"] = sender
|
logger.error("sms_voipms error: %s", result.get("status"))
|
||||||
_config["smtpserver"] = smtp_server
|
return False
|
||||||
_config["smtpport"] = smtp_port
|
except Exception as e:
|
||||||
if smtp_user:
|
logger.error("sms_voipms exception: %s", e)
|
||||||
_config["smtpuser"] = smtp_user
|
|
||||||
if smtp_password:
|
|
||||||
_config["smtppassword"] = smtp_password
|
|
||||||
|
|
||||||
result = email("Heartbeat notification", msg, debug=debug)
|
|
||||||
|
|
||||||
# Restore config
|
|
||||||
_config.clear()
|
|
||||||
_config.update(old_config)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
elif channel_type == "signal":
|
|
||||||
return pushsignal(
|
|
||||||
channel_config.get("cli_path", "/usr/local/bin/signal-cli"),
|
|
||||||
channel_config.get("user", ""),
|
|
||||||
channel_config.get("recipient", ""),
|
|
||||||
msg,
|
|
||||||
debug=debug
|
|
||||||
)
|
|
||||||
|
|
||||||
elif channel_type == "mattermost":
|
|
||||||
return pushmattermost(
|
|
||||||
channel_config.get("host", ""),
|
|
||||||
channel_config.get("token", ""),
|
|
||||||
channel_config.get("channel", ""),
|
|
||||||
msg,
|
|
||||||
username=channel_config.get("username", "hbd"),
|
|
||||||
icon=channel_config.get("icon"),
|
|
||||||
debug=debug
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning("Unknown channel type '%s' for channel '%s'", channel_type, channel_name)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def pushmsg_for_host(hostname: str, msg: str, debug: int = 0) -> dict:
|
|
||||||
"""Send notification for a specific host using its configured channels.
|
|
||||||
|
async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
|
||||||
This function looks up the host's notification channels from the config
|
"""Send a Matrix message using matrix-nio."""
|
||||||
and sends the message to those channels.
|
try:
|
||||||
|
from nio import AsyncClient, RoomMessageText # noqa: F401
|
||||||
Args:
|
except ImportError:
|
||||||
hostname: Name of the host to send notification for
|
logger.error("matrix-nio not installed; pip install matrix-nio")
|
||||||
msg: Message to send
|
return False
|
||||||
debug: Debug level
|
|
||||||
|
from nio import AsyncClient
|
||||||
Returns:
|
homeserver = channel_cfg.get("homeserver", "")
|
||||||
Dictionary of results per channel: {"channel_name": True/False}
|
access_token = channel_cfg.get("access_token", "")
|
||||||
|
room_id = channel_cfg.get("room_id", "")
|
||||||
|
if not homeserver or not access_token or not room_id:
|
||||||
|
logger.warning("matrix: missing homeserver, access_token, or room_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
text = f"{notif.title}\n{notif.body}"
|
||||||
|
if notif.url:
|
||||||
|
text += f"\n{notif.url}"
|
||||||
|
html = f"<strong>{notif.title}</strong><br>{notif.body}"
|
||||||
|
if notif.url:
|
||||||
|
html += f'<br><a href="{notif.url}">Plugin metrics</a>'
|
||||||
|
|
||||||
|
client = AsyncClient(homeserver)
|
||||||
|
client.access_token = access_token
|
||||||
|
try:
|
||||||
|
from nio import RoomSendResponse
|
||||||
|
content = {
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": text,
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
"formatted_body": html,
|
||||||
|
}
|
||||||
|
resp = await client.room_send(room_id, "m.room.message", content)
|
||||||
|
if hasattr(resp, "event_id"):
|
||||||
|
return True
|
||||||
|
logger.error("matrix send failed: %s", resp)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("matrix exception: %s", e)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Channel dispatcher (all async — sync drivers run in a thread executor)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Sync drivers kept for `hbd notify` CLI usage (asyncio.run wraps them there).
|
||||||
|
_DRIVERS = {
|
||||||
|
"pushover": _send_pushover,
|
||||||
|
"email": _send_email,
|
||||||
|
"mattermost": _send_mattermost,
|
||||||
|
"signal": _send_signal,
|
||||||
|
}
|
||||||
|
|
||||||
|
_TIMEOUT = 15 # seconds per channel send
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
||||||
|
"""Send *notif* to a single named channel, honouring min_level."""
|
||||||
|
level = notif.level.upper()
|
||||||
|
if level != "RECOVER":
|
||||||
|
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
||||||
|
if _level_value(level) < _level_value(min_level):
|
||||||
|
logger.debug(
|
||||||
|
"channel '%s': skipping level %s (min_level=%s)", channel_name, level, min_level
|
||||||
|
)
|
||||||
|
return True # filtered intentionally
|
||||||
|
|
||||||
|
ch_type = channel_cfg.get("type", "")
|
||||||
|
try:
|
||||||
|
if ch_type == "matrix":
|
||||||
|
return await asyncio.wait_for(_send_matrix_async(channel_cfg, notif), timeout=_TIMEOUT)
|
||||||
|
if ch_type == "sms_voipms":
|
||||||
|
return await asyncio.wait_for(_send_sms_voipms_async(channel_cfg, notif), timeout=_TIMEOUT)
|
||||||
|
sync_driver = _DRIVERS.get(ch_type)
|
||||||
|
if sync_driver is None:
|
||||||
|
logger.warning("unknown channel type '%s' for channel '%s'", ch_type, channel_name)
|
||||||
|
return False
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(sync_driver, channel_cfg, notif), timeout=_TIMEOUT
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("channel '%s' timed out after %ds", channel_name, _TIMEOUT)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Central dispatch function
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_url(host_name: str) -> str:
|
||||||
|
base_url = _config.get("base_url", "").rstrip("/")
|
||||||
|
if not base_url:
|
||||||
|
return ""
|
||||||
|
return f"{base_url}/alerts?filter={host_name}"
|
||||||
|
|
||||||
|
|
||||||
|
async def send_notification(host_name: str, notif: Notification) -> dict:
|
||||||
|
"""Dispatch *notif* to all managers/owner of *host_name*.
|
||||||
|
|
||||||
|
Looks up the host's owner + managers, resolves each user's
|
||||||
|
notification_channels, and dispatches. Silently does nothing if
|
||||||
|
no users are configured.
|
||||||
|
|
||||||
|
Returns a dict of {channel_name: bool} results.
|
||||||
"""
|
"""
|
||||||
from . import config as config_mod
|
from . import users as users_mod
|
||||||
|
from . import hbdclass
|
||||||
# Get notification channels for this host
|
|
||||||
channels = config_mod.get_notification_channels_config(_config, hostname)
|
if not users_mod.users_enabled():
|
||||||
|
|
||||||
if not channels:
|
|
||||||
logger.warning("No notification channels configured for host '%s'", hostname)
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Dispatch to each channel
|
# Collect recipient usernames: owner + managers
|
||||||
results = {}
|
host = hbdclass.Host.hosts.get(host_name)
|
||||||
for channel_name, channel_config in channels:
|
if host is None:
|
||||||
try:
|
logger.debug("send_notification: host '%s' not found", host_name)
|
||||||
success = _dispatch_to_channel(channel_name, channel_config, msg, debug=debug)
|
return {}
|
||||||
results[channel_name] = success
|
|
||||||
if success:
|
recipients: set[str] = set()
|
||||||
logger.info("Notification sent to channel '%s': %s", channel_name, msg)
|
owner = getattr(host, "owner", None)
|
||||||
else:
|
if owner:
|
||||||
logger.warning("Failed to send notification to channel '%s'", channel_name)
|
recipients.add(owner)
|
||||||
except Exception as e:
|
for m in getattr(host, "managers", []):
|
||||||
logger.error("Error sending to channel '%s': %s", channel_name, e)
|
recipients.add(m)
|
||||||
results[channel_name] = False
|
|
||||||
|
if not recipients:
|
||||||
|
logger.debug("send_notification: no owner/managers for '%s'", host_name)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Fill url if not already set
|
||||||
|
if not notif.url:
|
||||||
|
notif.url = _build_url(host_name)
|
||||||
|
|
||||||
|
global_channels: dict = _config.get("notification_channels", {})
|
||||||
|
results: dict = {}
|
||||||
|
level = notif.level.upper()
|
||||||
|
is_alert = level in ("WARNING", "CRITICAL")
|
||||||
|
is_recover = level in ("RECOVER",)
|
||||||
|
|
||||||
|
# For RECOVER: send to every channel that previously fired an alert for this host,
|
||||||
|
# regardless of that channel's min_level.
|
||||||
|
if is_recover and host_name in _alerted_channels:
|
||||||
|
for channel_name in list(_alerted_channels[host_name]):
|
||||||
|
channel_cfg = global_channels.get(channel_name)
|
||||||
|
if not channel_cfg:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ok = await _dispatch_to_channel(channel_name, channel_cfg, notif)
|
||||||
|
results[channel_name] = ok
|
||||||
|
if ok:
|
||||||
|
logger.info("recover sent to channel '%s': %s", channel_name, notif.title)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("error sending recover to channel '%s': %s", channel_name, e)
|
||||||
|
del _alerted_channels[host_name]
|
||||||
|
return results
|
||||||
|
|
||||||
|
for username in recipients:
|
||||||
|
user = users_mod.get_user(username)
|
||||||
|
if user is None:
|
||||||
|
logger.debug("send_notification: user '%s' not found", username)
|
||||||
|
continue
|
||||||
|
for channel_name in user.notification_channels:
|
||||||
|
if channel_name in results:
|
||||||
|
continue
|
||||||
|
channel_cfg = global_channels.get(channel_name)
|
||||||
|
if not channel_cfg:
|
||||||
|
logger.warning("channel '%s' not defined in notification_channels", channel_name)
|
||||||
|
results[channel_name] = False
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ok = await _dispatch_to_channel(channel_name, channel_cfg, notif)
|
||||||
|
results[channel_name] = ok
|
||||||
|
if ok:
|
||||||
|
logger.info("notification sent to channel '%s': %s", channel_name, notif.title)
|
||||||
|
if is_alert:
|
||||||
|
_alerted_channels.setdefault(host_name, set()).add(channel_name)
|
||||||
|
else:
|
||||||
|
logger.warning("failed to send notification to channel '%s'", channel_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("error sending to channel '%s': %s", channel_name, e)
|
||||||
|
results[channel_name] = False
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""OAuth2 provider support.
|
||||||
|
|
||||||
|
Config shape (in ~/.hb.yaml):
|
||||||
|
|
||||||
|
oauth:
|
||||||
|
my-gitea: # route slug → /login/oauth/my-gitea
|
||||||
|
type: gitea # "gitea" | "github" | "nextcloud"
|
||||||
|
# omit type to default to "gitea"
|
||||||
|
url: https://git.example.com # required for gitea and nextcloud
|
||||||
|
client_id: <client-id>
|
||||||
|
client_secret: <client-secret>
|
||||||
|
label: "Work Gitea" # optional display name on login button
|
||||||
|
logo: https://example.com/logo.png # optional logo URL
|
||||||
|
|
||||||
|
github:
|
||||||
|
type: github
|
||||||
|
client_id: <client-id>
|
||||||
|
client_secret: <client-secret>
|
||||||
|
|
||||||
|
nextcloud:
|
||||||
|
type: nextcloud
|
||||||
|
url: https://cloud.example.com
|
||||||
|
client_id: <client-id>
|
||||||
|
client_secret: <client-secret>
|
||||||
|
|
||||||
|
Register the OAuth app with each provider and set the redirect URI to:
|
||||||
|
https://<hbd-host>/login/oauth/<name>/callback
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATE_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
|
# state_token -> expiry timestamp
|
||||||
|
_states: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Remove all expired CSRF state tokens from the in-memory store."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, exp in list(_states.items()) if exp < now]
|
||||||
|
for k in expired:
|
||||||
|
del _states[k]
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthError(Exception):
|
||||||
|
"""Raised when the OAuth2 flow fails for any reason."""
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_DEFS: dict = {
|
||||||
|
"gitea": {
|
||||||
|
"authorize_url_tmpl": "{url}/login/oauth/authorize",
|
||||||
|
"token_url_tmpl": "{url}/login/oauth/access_token",
|
||||||
|
"profile_url_tmpl": "{url}/api/v1/user",
|
||||||
|
"scope": "user:email",
|
||||||
|
"field_map": {"username": "login", "full_name": "full_name", "avatar": "avatar_url"},
|
||||||
|
"profile_data_path": [],
|
||||||
|
"requires_url": True,
|
||||||
|
"default_label": "Gitea",
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"authorize_url_tmpl": "https://github.com/login/oauth/authorize",
|
||||||
|
"token_url_tmpl": "https://github.com/login/oauth/access_token",
|
||||||
|
"profile_url_tmpl": "https://api.github.com/user",
|
||||||
|
"scope": "read:user",
|
||||||
|
"field_map": {"username": "login", "full_name": "name", "avatar": "avatar_url"},
|
||||||
|
"profile_data_path": [],
|
||||||
|
"requires_url": False,
|
||||||
|
"default_label": "GitHub",
|
||||||
|
},
|
||||||
|
"nextcloud": {
|
||||||
|
"authorize_url_tmpl": "{url}/apps/oauth2/authorize",
|
||||||
|
"token_url_tmpl": "{url}/apps/oauth2/api/v1/token",
|
||||||
|
"profile_url_tmpl": "{url}/ocs/v2.php/cloud/user?format=json",
|
||||||
|
"scope": "",
|
||||||
|
"field_map": {"username": "id", "full_name": "display-name", "avatar": None},
|
||||||
|
"profile_data_path": ["ocs", "data"],
|
||||||
|
"requires_url": True,
|
||||||
|
"default_label": "Nextcloud",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedProvider:
|
||||||
|
"""A fully resolved OAuth2 provider instance, ready to use."""
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
label: str
|
||||||
|
logo: str
|
||||||
|
authorize_url: str
|
||||||
|
token_url: str
|
||||||
|
profile_url: str
|
||||||
|
scope: str
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
field_map: dict
|
||||||
|
profile_data_path: list
|
||||||
|
|
||||||
|
|
||||||
|
def get_providers(config: dict) -> list[ResolvedProvider]:
|
||||||
|
"""Return a ResolvedProvider for every valid entry in config['oauth'].
|
||||||
|
|
||||||
|
Entries with missing required fields or unknown types are skipped with
|
||||||
|
a warning log. Order follows config declaration order.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
oauth_cfg = config.get("oauth", {})
|
||||||
|
if not isinstance(oauth_cfg, dict):
|
||||||
|
return result
|
||||||
|
for name, entry in oauth_cfg.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
provider_type = entry.get("type", "gitea")
|
||||||
|
defn = PROVIDER_DEFS.get(provider_type)
|
||||||
|
if defn is None:
|
||||||
|
logger.warning("OAuth: unknown provider type %r for %r, skipping", provider_type, name)
|
||||||
|
continue
|
||||||
|
client_id = entry.get("client_id", "")
|
||||||
|
client_secret = entry.get("client_secret", "")
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
logger.warning("OAuth: %r missing client_id or client_secret, skipping", name)
|
||||||
|
continue
|
||||||
|
url = entry.get("url", "").rstrip("/")
|
||||||
|
if defn["requires_url"] and not url:
|
||||||
|
logger.warning("OAuth: %r requires url but none configured, skipping", name)
|
||||||
|
continue
|
||||||
|
label = entry.get("label") or defn["default_label"]
|
||||||
|
logo = entry.get("logo", "")
|
||||||
|
result.append(ResolvedProvider(
|
||||||
|
name=name,
|
||||||
|
type=provider_type,
|
||||||
|
label=label,
|
||||||
|
logo=logo,
|
||||||
|
authorize_url=defn["authorize_url_tmpl"].format(url=url),
|
||||||
|
token_url=defn["token_url_tmpl"].format(url=url),
|
||||||
|
profile_url=defn["profile_url_tmpl"].format(url=url),
|
||||||
|
scope=defn["scope"],
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
field_map=dict(defn["field_map"]),
|
||||||
|
profile_data_path=list(defn["profile_data_path"]),
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled(config: dict) -> bool:
|
||||||
|
"""Return True when at least one OAuth provider is fully configured."""
|
||||||
|
return bool(get_providers(config))
|
||||||
|
|
||||||
|
|
||||||
|
def build_auth_url(provider: ResolvedProvider, state: str, redirect_uri: str) -> str:
|
||||||
|
"""Return the provider's OAuth2 authorization URL to redirect the browser to."""
|
||||||
|
params: dict = {
|
||||||
|
"client_id": provider.client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"response_type": "code",
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
if provider.scope:
|
||||||
|
params["scope"] = provider.scope
|
||||||
|
return f"{provider.authorize_url}?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def exchange_code(provider: ResolvedProvider, code: str, redirect_uri: str) -> str:
|
||||||
|
"""Exchange an authorization *code* for an access token.
|
||||||
|
|
||||||
|
Returns the access token string. Raises OAuthError on any failure.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"client_id": provider.client_id,
|
||||||
|
"client_secret": provider.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(
|
||||||
|
provider.token_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()
|
||||||
|
token = data.get("access_token")
|
||||||
|
if not token:
|
||||||
|
raise OAuthError(f"No access_token in response: {data}")
|
||||||
|
except aiohttp.ClientError as exc:
|
||||||
|
raise OAuthError(f"Token exchange network error: {exc}") from exc
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_user(provider: ResolvedProvider, token: str) -> dict:
|
||||||
|
"""Fetch the authenticated user's profile from the provider.
|
||||||
|
|
||||||
|
Returns a dict with keys: login, full_name, avatar_url.
|
||||||
|
Raises OAuthError on any failure.
|
||||||
|
"""
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(
|
||||||
|
provider.profile_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
) 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
|
||||||
|
|
||||||
|
try:
|
||||||
|
for key in provider.profile_data_path:
|
||||||
|
data = data.get(key, {})
|
||||||
|
avatar_field = provider.field_map.get("avatar")
|
||||||
|
return {
|
||||||
|
"login": data.get(provider.field_map["username"], ""),
|
||||||
|
"full_name": data.get(provider.field_map["full_name"], ""),
|
||||||
|
"avatar_url": data.get(avatar_field, "") if avatar_field else "",
|
||||||
|
}
|
||||||
|
except AttributeError:
|
||||||
|
raise OAuthError(f"Unexpected profile response structure from {provider.type}")
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""Settings descriptor: maps config keys to display metadata.
|
||||||
|
|
||||||
|
``get_settings_sections(config)`` returns an ordered list of sections, each
|
||||||
|
containing a list of field descriptors. The template iterates this structure
|
||||||
|
generically, so adding editability later is a matter of:
|
||||||
|
|
||||||
|
1. Setting ``"editable": True`` on a field.
|
||||||
|
2. Adding the matching ``<input>``/``<select>`` in the template
|
||||||
|
(guided by ``"type"``).
|
||||||
|
3. Wiring a POST handler in http.py.
|
||||||
|
|
||||||
|
Field descriptor keys
|
||||||
|
---------------------
|
||||||
|
key str Config key (for future form POST matching)
|
||||||
|
label str Human-readable label
|
||||||
|
description str One-line help text shown below the value
|
||||||
|
value any Sanitized display value (secrets replaced with "•••")
|
||||||
|
type str One of: text | number | port | boolean | path | duration |
|
||||||
|
list | secret | size | select
|
||||||
|
editable bool Reserved for future use — currently always False
|
||||||
|
sensitive bool True when the raw value must never be shown
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Credential field names that should always be masked.
|
||||||
|
_SECRET_KEYS = frozenset({
|
||||||
|
"password", "token", "user_key", "api_key", "secret",
|
||||||
|
"smtp_password", "smtp_user", "api_password", "access_token",
|
||||||
|
})
|
||||||
|
|
||||||
|
_CHANNEL_TYPE_LABELS = {
|
||||||
|
"pushover": "Pushover",
|
||||||
|
"email": "E-mail",
|
||||||
|
"signal": "Signal",
|
||||||
|
"mattermost": "Mattermost",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mask(value):
|
||||||
|
"""Return a masked placeholder for sensitive values."""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
return "•••"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_size(n):
|
||||||
|
"""Format a byte count as a human-readable string."""
|
||||||
|
try:
|
||||||
|
n = int(n)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(n)
|
||||||
|
for unit in ("B", "KB", "MB", "GB"):
|
||||||
|
if n < 1024:
|
||||||
|
return f"{n} {unit}"
|
||||||
|
n //= 1024
|
||||||
|
return f"{n} TB"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_duration(seconds):
|
||||||
|
"""Format seconds into a human-readable duration string."""
|
||||||
|
try:
|
||||||
|
s = int(seconds)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(seconds)
|
||||||
|
if s < 60:
|
||||||
|
return f"{s}s"
|
||||||
|
if s < 3600:
|
||||||
|
m, sec = divmod(s, 60)
|
||||||
|
return f"{m}m {sec}s" if sec else f"{m}m"
|
||||||
|
h, rem = divmod(s, 3600)
|
||||||
|
m = rem // 60
|
||||||
|
return f"{h}h {m}m" if m else f"{h}h"
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_channel(name, cfg):
|
||||||
|
"""Return a sanitized copy of a notification channel config."""
|
||||||
|
result = {}
|
||||||
|
for k, v in cfg.items():
|
||||||
|
if k in _SECRET_KEYS:
|
||||||
|
result[k] = _mask(v)
|
||||||
|
elif isinstance(v, list):
|
||||||
|
result[k] = v
|
||||||
|
else:
|
||||||
|
result[k] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
||||||
|
"""Return ordered list of setting sections for the settings page.
|
||||||
|
|
||||||
|
Each section:
|
||||||
|
{
|
||||||
|
"title": str,
|
||||||
|
"description": str,
|
||||||
|
"fields": [ field_descriptor, ... ]
|
||||||
|
}
|
||||||
|
|
||||||
|
Each field_descriptor:
|
||||||
|
{
|
||||||
|
"key": str,
|
||||||
|
"label": str,
|
||||||
|
"description": str,
|
||||||
|
"value": display_value,
|
||||||
|
"raw": raw_config_value, # None for sensitive
|
||||||
|
"type": str,
|
||||||
|
"editable": bool,
|
||||||
|
"sensitive": bool,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
def field(key, label, ftype, description="", editable=False, sensitive=False):
|
||||||
|
raw = config.get(key)
|
||||||
|
if sensitive:
|
||||||
|
display = _mask(raw)
|
||||||
|
raw_out = None
|
||||||
|
elif ftype == "size":
|
||||||
|
display = _fmt_size(raw)
|
||||||
|
raw_out = raw
|
||||||
|
elif ftype == "duration":
|
||||||
|
display = _fmt_duration(raw)
|
||||||
|
raw_out = raw
|
||||||
|
elif ftype == "boolean":
|
||||||
|
display = bool(raw)
|
||||||
|
raw_out = raw
|
||||||
|
elif ftype == "list":
|
||||||
|
val = raw or []
|
||||||
|
display = list(val) if not isinstance(val, list) else val
|
||||||
|
raw_out = display
|
||||||
|
else:
|
||||||
|
display = raw if raw is not None else ""
|
||||||
|
raw_out = raw
|
||||||
|
return {
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"description": description,
|
||||||
|
"value": display,
|
||||||
|
"raw": raw_out,
|
||||||
|
"type": ftype,
|
||||||
|
"editable": editable,
|
||||||
|
"sensitive": sensitive,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Notification channels (complex, built separately) ----------------
|
||||||
|
notif_channels = []
|
||||||
|
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items():
|
||||||
|
if not isinstance(ch_cfg, dict):
|
||||||
|
continue
|
||||||
|
ch_type = ch_cfg.get("type", "")
|
||||||
|
fields = []
|
||||||
|
for k, v in ch_cfg.items():
|
||||||
|
if k == "type":
|
||||||
|
continue
|
||||||
|
sensitive = k in _SECRET_KEYS
|
||||||
|
fields.append({
|
||||||
|
"key": k,
|
||||||
|
"label": k.replace("_", " ").title(),
|
||||||
|
"value": _mask(v) if sensitive else (
|
||||||
|
", ".join(v) if isinstance(v, list) else str(v)
|
||||||
|
),
|
||||||
|
"sensitive": sensitive,
|
||||||
|
})
|
||||||
|
notif_channels.append({
|
||||||
|
"name": ch_name,
|
||||||
|
"type": ch_type,
|
||||||
|
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
||||||
|
"fields": fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- Users (show metadata only, never password hashes) ----------------
|
||||||
|
users_list = []
|
||||||
|
for username, attrs in (config.get("users") or {}).items():
|
||||||
|
if not isinstance(attrs, dict):
|
||||||
|
continue
|
||||||
|
users_list.append({
|
||||||
|
"username": username,
|
||||||
|
"full_name": attrs.get("full_name", ""),
|
||||||
|
"admin": bool(attrs.get("admin", False)),
|
||||||
|
"avatar": attrs.get("avatar", ""),
|
||||||
|
"notification_channels": attrs.get("notification_channels", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- Threshold configurations -----------------------------------------
|
||||||
|
def _tc_to_row(tc):
|
||||||
|
return {
|
||||||
|
"metric": tc.metric_path,
|
||||||
|
"operator": tc.operator.value,
|
||||||
|
"warning": tc.warning,
|
||||||
|
"critical": tc.critical,
|
||||||
|
"hysteresis": tc.hysteresis,
|
||||||
|
"count": tc.count,
|
||||||
|
"enabled": tc.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
threshold_config_list = []
|
||||||
|
if threshold_checker is not None:
|
||||||
|
if threshold_checker.threshold_configs:
|
||||||
|
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
|
||||||
|
# For the default config use the merged effective set;
|
||||||
|
# for named overrides use only the explicitly defined metrics
|
||||||
|
# (threshold_raw_configs) so inherited defaults are not repeated.
|
||||||
|
if cfg_name == "default":
|
||||||
|
display_metrics = cfg_metrics
|
||||||
|
else:
|
||||||
|
display_metrics = threshold_checker.threshold_raw_configs.get(cfg_name, cfg_metrics)
|
||||||
|
metrics = sorted(
|
||||||
|
[_tc_to_row(tc) for tc in display_metrics.values()],
|
||||||
|
key=lambda m: m["metric"],
|
||||||
|
)
|
||||||
|
threshold_config_list.append({"name": cfg_name, "metrics": metrics})
|
||||||
|
elif threshold_checker.thresholds:
|
||||||
|
metrics = sorted(
|
||||||
|
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
|
||||||
|
key=lambda m: m["metric"],
|
||||||
|
)
|
||||||
|
threshold_config_list.append({"name": "default", "metrics": metrics})
|
||||||
|
|
||||||
|
# ---- Hosts summary ----------------------------------------------------
|
||||||
|
hosts_list = []
|
||||||
|
for hname, hcfg in (config.get("hosts") or {}).items():
|
||||||
|
if not isinstance(hcfg, dict):
|
||||||
|
continue
|
||||||
|
hosts_list.append({
|
||||||
|
"name": hname,
|
||||||
|
"watch": bool(hcfg.get("watch", True)),
|
||||||
|
"dyndns": bool(hcfg.get("dyndns", False)),
|
||||||
|
"owner": hcfg.get("owner", ""),
|
||||||
|
"managers": hcfg.get("managers", []),
|
||||||
|
"monitors": hcfg.get("monitors", []),
|
||||||
|
"threshold_config": hcfg.get("threshold_config", ""),
|
||||||
|
"notification_channels": hcfg.get("notification_channels", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- OAuth providers -------------------------------------------------------
|
||||||
|
oauth_providers = []
|
||||||
|
for pname, pattrs in (config.get("oauth") or {}).items():
|
||||||
|
if not isinstance(pattrs, dict):
|
||||||
|
continue
|
||||||
|
cs = pattrs.get("client_secret", "")
|
||||||
|
oauth_providers.append({
|
||||||
|
"name": pname,
|
||||||
|
"type": pattrs.get("type", "gitea"),
|
||||||
|
"url": pattrs.get("url", ""),
|
||||||
|
"client_id": pattrs.get("client_id", ""),
|
||||||
|
"client_secret": "•••" if cs else "",
|
||||||
|
"label": pattrs.get("label", ""),
|
||||||
|
"logo": pattrs.get("logo", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "network",
|
||||||
|
"title": "Network",
|
||||||
|
"description": "Ports and bind addresses for all server sockets.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "server",
|
||||||
|
"fields": [
|
||||||
|
field("hb_port", "Heartbeat UDP port", "port",
|
||||||
|
"UDP port the server listens on for heartbeat datagrams.", editable=True),
|
||||||
|
field("hbd_host", "HTTP bind address", "text",
|
||||||
|
"Interface to bind the HTTP server to. Empty = all interfaces.", editable=True),
|
||||||
|
field("hbd_port", "HTTP API port", "port",
|
||||||
|
"TCP port for the HTTP API and web UI.", editable=True),
|
||||||
|
field("ws_port", "WebSocket port", "port",
|
||||||
|
"TCP port for the plain WebSocket server.", editable=True),
|
||||||
|
field("wss_port", "Secure WebSocket port", "port",
|
||||||
|
"TCP port for WSS (TLS WebSocket). Leave empty to disable.", editable=True),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tls",
|
||||||
|
"title": "TLS / WebSocket Security",
|
||||||
|
"description": "Certificate paths used when wss_port is set.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": None,
|
||||||
|
"fields": [
|
||||||
|
field("cert_path", "Certificate directory", "path",
|
||||||
|
"Directory containing the TLS certificate and key files."),
|
||||||
|
field("wss_pem", "Certificate file", "text",
|
||||||
|
"Filename of the TLS certificate chain (PEM format)."),
|
||||||
|
field("wss_key", "Key file", "text",
|
||||||
|
"Filename of the TLS private key (PEM format)."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "monitoring",
|
||||||
|
"title": "Monitoring",
|
||||||
|
"description": "Heartbeat timing and alert re-notification behaviour.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "server",
|
||||||
|
"fields": [
|
||||||
|
field("interval", "Heartbeat interval", "duration",
|
||||||
|
"Expected time between heartbeat messages from each client.", editable=True),
|
||||||
|
field("grace", "Grace period", "number",
|
||||||
|
"Extra seconds to wait after a missed heartbeat before sending notifications.", editable=True),
|
||||||
|
field("threshold_renotify_interval", "Re-notify interval", "duration",
|
||||||
|
"How often to re-send notifications for ongoing threshold alerts.", editable=True),
|
||||||
|
field("autosave_interval", "Autosave interval", "duration",
|
||||||
|
"How often the server saves its state to disk."),
|
||||||
|
field("base_url", "Base URL", "text",
|
||||||
|
"Base URL for notification links.", editable=True),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "persistence",
|
||||||
|
"title": "Persistence & Logging",
|
||||||
|
"description": "State file and event log settings.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "server",
|
||||||
|
"fields": [
|
||||||
|
field("pickfile", "State file", "path",
|
||||||
|
"Path to the pickle file used to persist host state across restarts.", editable=True),
|
||||||
|
field("logfile", "Event log", "path",
|
||||||
|
"Path to the event log file.", editable=True),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal",
|
||||||
|
"title": "Message Journal",
|
||||||
|
"description": "All received heartbeat and plugin messages are journalled here.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "server",
|
||||||
|
"fields": [
|
||||||
|
field("journal_enabled", "Enabled", "boolean",
|
||||||
|
"Turn journalling on or off.", editable=True),
|
||||||
|
field("journal_dir", "Journal directory","path",
|
||||||
|
"Directory where journal files are written.", editable=True),
|
||||||
|
field("journal_file", "Journal filename", "text",
|
||||||
|
"Base filename for the journal (rotated copies get a numeric suffix)."),
|
||||||
|
field("journal_max_size", "Max file size", "size",
|
||||||
|
"Rotate the journal when it exceeds this size.", editable=True),
|
||||||
|
field("journal_max_backups", "Backup count", "number",
|
||||||
|
"Number of rotated journal files to keep.", editable=True),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dns",
|
||||||
|
"title": "Dynamic DNS",
|
||||||
|
"description": "nsupdate-based DNS registration — edit raw YAML.",
|
||||||
|
"section_mode": "yaml",
|
||||||
|
"api_section": "dns",
|
||||||
|
"fields": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "users",
|
||||||
|
"title": "Users",
|
||||||
|
"description": "Accounts defined in the config file. Password hashes are never shown.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "users",
|
||||||
|
"users": users_list,
|
||||||
|
"fields": [
|
||||||
|
field("default_owner", "Default owner", "text",
|
||||||
|
"Username that owns hosts with no explicit owner. "
|
||||||
|
"Falls back to the first admin user.", editable=True),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "oauth",
|
||||||
|
"title": "OAuth Providers",
|
||||||
|
"description": "OAuth2 login providers. Client secrets are masked.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "oauth",
|
||||||
|
"providers": oauth_providers,
|
||||||
|
"fields": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "channels",
|
||||||
|
"title": "Notification Channels",
|
||||||
|
"description": "Named notification providers. Credentials are masked.",
|
||||||
|
"section_mode": "yaml",
|
||||||
|
"api_section": "notification_channels",
|
||||||
|
"channels": notif_channels,
|
||||||
|
"fields": [
|
||||||
|
field("default_notification_channels", "Default channels", "list",
|
||||||
|
"Channels used when a host does not specify its own."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hosts",
|
||||||
|
"title": "Hosts",
|
||||||
|
"description": "Host definitions loaded from the config file.",
|
||||||
|
"section_mode": "yaml",
|
||||||
|
"api_section": "hosts",
|
||||||
|
"hosts": hosts_list,
|
||||||
|
"fields": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "thresholds",
|
||||||
|
"title": "Threshold Configurations",
|
||||||
|
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
||||||
|
"section_mode": "yaml",
|
||||||
|
"api_section": "thresholds",
|
||||||
|
"threshold_configs": threshold_config_list,
|
||||||
|
"fields": [
|
||||||
|
field("default_threshold_config", "Default config", "text",
|
||||||
|
"Threshold config used for hosts with no explicit mapping."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "runtime",
|
||||||
|
"title": "Runtime",
|
||||||
|
"description": "Flags set at startup (require restart to change).",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": None,
|
||||||
|
"fields": [
|
||||||
|
field("foreground", "Foreground mode", "boolean",
|
||||||
|
"Run in the foreground instead of daemonising."),
|
||||||
|
field("verbose", "Verbose logging", "boolean",
|
||||||
|
"Enable verbose log output."),
|
||||||
|
field("debug", "Debug level", "number",
|
||||||
|
"0 = off. Higher values increase log verbosity."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_data(config: dict, threshold_checker=None) -> dict:
|
||||||
|
"""Return sections list + auxiliary data for the settings template."""
|
||||||
|
sections = get_settings_sections(config, threshold_checker=threshold_checker)
|
||||||
|
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
|
||||||
|
return {"sections": sections, "all_channel_names": all_channel_names}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 181 KiB |
@@ -139,4 +139,69 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive / mobile ── */
|
||||||
|
|
||||||
|
/* Suppress the global transition on mobile to avoid sluggish feel */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
* { transition: none !important; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
overflow: auto;
|
||||||
|
height: auto;
|
||||||
|
font-size: 16px; /* prevent iOS auto-zoom on inputs */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pages that use flex-column full-viewport layout need to relax on mobile */
|
||||||
|
body[style*="height: 100vh"],
|
||||||
|
body {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Containers: full width, no fixed heights */
|
||||||
|
.container {
|
||||||
|
max-width: 100% !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log section: fixed reasonable height instead of flex-grow */
|
||||||
|
.log-section {
|
||||||
|
flex: none !important;
|
||||||
|
max-height: 40vh !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table section: allow vertical scroll, cap height */
|
||||||
|
.table-section {
|
||||||
|
max-height: 55vh !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slightly larger tap targets in tables */
|
||||||
|
#ntable td, #ntable th {
|
||||||
|
padding: 4px 6px !important;
|
||||||
|
font-size: 0.82em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards on plugin/alerts pages */
|
||||||
|
.host-card, .alert-card, .card {
|
||||||
|
padding: 10px !important;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings page tables */
|
||||||
|
table { width: 100%; }
|
||||||
|
|
||||||
|
h1 { font-size: 1.2em !important; }
|
||||||
|
h2 { font-size: 1em !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suppress nav-username text on very narrow screens — avatar/initials is enough */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.nav-username { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{% include 'head.html' %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body { overflow: visible; }
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.info-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
width: 160px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #222;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.info-value a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 12px;
|
||||||
|
background: #e8f0fe;
|
||||||
|
color: #1a73e8;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-logo {
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0066cc;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-tagline {
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text { flex: 1; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>{{ header }}</h1>
|
||||||
|
<p class="subtitle">Heartbeat monitoring system</p>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="logo-section">
|
||||||
|
<div class="logo-text">
|
||||||
|
<div class="hb-logo">Heartbeat</div>
|
||||||
|
<div class="hb-tagline">Lightweight host monitoring over UDP</div>
|
||||||
|
</div>
|
||||||
|
<span class="version-badge">v{{ hbd_version }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Version</h2>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Server version</span>
|
||||||
|
<span class="info-value">{{ hbd_version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Python</span>
|
||||||
|
<span class="info-value">{{ python_version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">License</span>
|
||||||
|
<span class="info-value">MIT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Runtime</h2>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Host</span>
|
||||||
|
<span class="info-value">{{ server_hostname }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Started</span>
|
||||||
|
<span class="info-value">{{ start_time_str }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Uptime</span>
|
||||||
|
<span class="info-value" id="uptime-value">{{ uptime_str }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Hosts monitored</span>
|
||||||
|
<span class="info-value">{{ host_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Contact & Source</h2>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Author</span>
|
||||||
|
<span class="info-value">Andreas Wrede</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Email</span>
|
||||||
|
<span class="info-value"><a href="mailto:aew@wrede.ca">aew@wrede.ca</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Repository</span>
|
||||||
|
<span class="info-value"><a href="https://git.wrede.ca/andreas/heartbeat" target="_blank" rel="noopener">git.wrede.ca/andreas/heartbeat</a></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var startEpoch = {{ start_epoch }};
|
||||||
|
var el = document.getElementById('uptime-value');
|
||||||
|
if (!el) return;
|
||||||
|
function fmt(s) {
|
||||||
|
var d = Math.floor(s / 86400);
|
||||||
|
var h = Math.floor((s % 86400) / 3600);
|
||||||
|
var m = Math.floor((s % 3600) / 60);
|
||||||
|
var sec = s % 60;
|
||||||
|
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
|
||||||
|
if (h > 0) return h + 'h ' + m + 'm ' + sec + 's';
|
||||||
|
return m + 'm ' + sec + 's';
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
var up = Math.floor(Date.now() / 1000 - startEpoch);
|
||||||
|
el.textContent = fmt(up);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,33 +3,10 @@
|
|||||||
{% include 'head.html' %}
|
{% include 'head.html' %}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
|
||||||
margin: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
html, body {
|
||||||
background: #fff;
|
height: auto;
|
||||||
padding: 15px;
|
overflow-y: auto;
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a {
|
|
||||||
margin-right: 20px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: #0066cc;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a.active {
|
|
||||||
color: #333;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -37,10 +14,7 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
||||||
color: #333;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: #666;
|
color: #666;
|
||||||
@@ -48,55 +22,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-cards {
|
.summary-cards {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 20px;
|
gap: 10px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card {
|
.summary-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
padding: 20px;
|
padding: 6px 14px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card.critical {
|
.summary-card.critical { border-left-color: #ea1e0f; }
|
||||||
border-left: 5px solid #f44336;
|
.summary-card.warning { border-left-color: #ff9800; }
|
||||||
}
|
.summary-card.ok { border-left-color: #4caf50; }
|
||||||
|
|
||||||
.summary-card.warning {
|
|
||||||
border-left: 5px solid #ff9800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card.ok {
|
|
||||||
border-left: 5px solid #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-number {
|
.summary-number {
|
||||||
font-size: 3em;
|
font-size: 1.4em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin: 10px 0;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-number.critical {
|
.summary-number.critical { color: #ea1e0f; }
|
||||||
color: #f44336;
|
.summary-number.warning { color: #ff9800; }
|
||||||
}
|
.summary-number.ok { color: #4caf50; }
|
||||||
|
|
||||||
.summary-number.warning {
|
|
||||||
color: #ff9800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-number.ok {
|
|
||||||
color: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
.summary-label {
|
||||||
color: #666;
|
color: #666;
|
||||||
text-transform: uppercase;
|
font-size: 0.85em;
|
||||||
font-size: 0.9em;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
@@ -135,6 +94,24 @@
|
|||||||
border-color: #2196f3;
|
border-color: #2196f3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
outline: none;
|
||||||
|
width: 200px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input.invalid {
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
.alerts-container {
|
.alerts-container {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -155,7 +132,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alert-item.acknowledged {
|
.alert-item.acknowledged {
|
||||||
opacity: 0.6;
|
opacity: 0.8;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,14 +193,18 @@
|
|||||||
|
|
||||||
.alert-hostname {
|
.alert-hostname {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #333;
|
color: #0066cc;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.alert-hostname:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-metric {
|
.alert-metric {
|
||||||
color: #666;
|
color: #0066cc;
|
||||||
font-family: 'Courier New', monospace;
|
font-size: 1.1em;
|
||||||
font-size: 0.9em;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-details {
|
.alert-details {
|
||||||
@@ -327,11 +308,7 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
{% include 'nav.html' %}
|
||||||
<a href="/live">Live Dashboard</a>
|
|
||||||
<a href="/plugins">Plugin Metrics</a>
|
|
||||||
<a href="/alerts" class="active">Alerts</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{{ header }}</h1>
|
<h1>{{ header }}</h1>
|
||||||
@@ -357,6 +334,7 @@
|
|||||||
<button class="filter-button active" onclick="filterAlerts('all')">All</button>
|
<button class="filter-button active" onclick="filterAlerts('all')">All</button>
|
||||||
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button>
|
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button>
|
||||||
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button>
|
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button>
|
||||||
|
<input id="host-filter" class="filter-input" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alerts-container">
|
<div class="alerts-container">
|
||||||
@@ -373,6 +351,7 @@
|
|||||||
<script>
|
<script>
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let allAlerts = [];
|
let allAlerts = [];
|
||||||
|
let hostFilterRe = null;
|
||||||
|
|
||||||
async function loadAlerts() {
|
async function loadAlerts() {
|
||||||
try {
|
try {
|
||||||
@@ -407,10 +386,13 @@
|
|||||||
// Filter alerts based on current filter
|
// Filter alerts based on current filter
|
||||||
let filteredAlerts = alerts;
|
let filteredAlerts = alerts;
|
||||||
if (currentFilter !== 'all') {
|
if (currentFilter !== 'all') {
|
||||||
filteredAlerts = alerts.filter(alert =>
|
filteredAlerts = filteredAlerts.filter(alert =>
|
||||||
alert.level.toLowerCase() === currentFilter
|
alert.level.toLowerCase() === currentFilter
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (hostFilterRe) {
|
||||||
|
filteredAlerts = filteredAlerts.filter(alert => hostFilterRe.test(alert.hostname));
|
||||||
|
}
|
||||||
|
|
||||||
if (filteredAlerts.length === 0) {
|
if (filteredAlerts.length === 0) {
|
||||||
if (currentFilter === 'all' && alerts.length === 0) {
|
if (currentFilter === 'all' && alerts.length === 0) {
|
||||||
@@ -450,6 +432,10 @@
|
|||||||
} else if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
|
} else if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
|
||||||
valueText += ` <span class="threshold-info">(threshold: ${alert.operator} ${formatValue(alert.threshold_value)})</span>`;
|
valueText += ` <span class="threshold-info">(threshold: ${alert.operator} ${formatValue(alert.threshold_value)})</span>`;
|
||||||
}
|
}
|
||||||
|
if (alert.recovery_threshold !== undefined && alert.recovery_threshold !== null) {
|
||||||
|
const recOp = (alert.operator === '>' || alert.operator === '>=') ? '<' : '>';
|
||||||
|
valueText += ` <span class="threshold-info" style="color:#888">(recovers ${recOp} ${formatValue(alert.recovery_threshold)})</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Build actions section
|
// Build actions section
|
||||||
let actionsHtml = '';
|
let actionsHtml = '';
|
||||||
@@ -474,9 +460,9 @@
|
|||||||
<div class="alert-main">
|
<div class="alert-main">
|
||||||
<div class="alert-header">
|
<div class="alert-header">
|
||||||
<span class="alert-level ${level}">${alert.level}</span>
|
<span class="alert-level ${level}">${alert.level}</span>
|
||||||
<span class="alert-hostname">${alert.hostname}</span>
|
<a class="alert-hostname" href="/plugins#${alert.hostname}">${alert.hostname}</a>
|
||||||
|
<span class="alert-metric">${(alert.metric_path.includes('.') ? alert.metric_path.slice(alert.metric_path.indexOf('.') + 1) : alert.metric_path).replace(/_status_code$/, '')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert-metric">${alert.metric_path}</div>
|
|
||||||
<div class="alert-details">
|
<div class="alert-details">
|
||||||
<span>${valueText}</span>
|
<span>${valueText}</span>
|
||||||
<span class="alert-duration">Active for ${duration}</span>
|
<span class="alert-duration">Active for ${duration}</span>
|
||||||
@@ -575,9 +561,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onHostFilterInput(input) {
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (!val) {
|
||||||
|
hostFilterRe = null;
|
||||||
|
input.classList.remove('invalid');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
hostFilterRe = new RegExp(val, 'i');
|
||||||
|
input.classList.remove('invalid');
|
||||||
|
} catch (_) {
|
||||||
|
hostFilterRe = null;
|
||||||
|
input.classList.add('invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderAlerts(allAlerts);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-refresh every 15 seconds
|
// Auto-refresh every 15 seconds
|
||||||
setInterval(loadAlerts, 15000);
|
setInterval(loadAlerts, 15000);
|
||||||
|
|
||||||
|
// Initialise filter from URL query string (?filter=...)
|
||||||
|
(function () {
|
||||||
|
const param = new URLSearchParams(window.location.search).get('filter');
|
||||||
|
if (param) {
|
||||||
|
const input = document.getElementById('host-filter');
|
||||||
|
input.value = param;
|
||||||
|
onHostFilterInput(input);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
loadAlerts();
|
loadAlerts();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,287 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="/static/style.css" type="text/css" />
|
<link rel="stylesheet" href="/static/style.css" type="text/css" />
|
||||||
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<script src="{{ extra_scripts }}"></script>
|
{% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
|
||||||
|
<style>
|
||||||
|
/* ── Reset / shared baseline ── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html {
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
padding-top: 60px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5em; color: #333; margin: 0 0 5px; }
|
||||||
|
h2 { font-size: 1.1em; color: #333; margin: 0 0 8px; }
|
||||||
|
p { margin: 0; }
|
||||||
|
|
||||||
|
/* Navigation bar — shared across all pages */
|
||||||
|
.nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: #fff;
|
||||||
|
padding: 6px 12px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.nav-links { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.nav a {
|
||||||
|
margin-right: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #0066cc;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.nav a:hover { text-decoration: underline; }
|
||||||
|
.nav a.active { color: #333; font-weight: bold; }
|
||||||
|
.nav-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.nav-user:hover { background: #f0f4ff; text-decoration: none; }
|
||||||
|
.nav-username {
|
||||||
|
max-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-width 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.nav-user:hover .nav-username {
|
||||||
|
max-width: 160px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.nav-avatar {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-initials {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #0066cc;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile nav: hamburger toggle ── */
|
||||||
|
.nav-hamburger {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 26px; height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.nav-hamburger span {
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.nav-hamburger { display: flex; }
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
.nav-links.nav-open { display: flex; }
|
||||||
|
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swiss railway clock — nav */
|
||||||
|
.nav-pie {
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 4px 4px 4px 0;
|
||||||
|
}
|
||||||
|
#alert-pie { display: block; cursor: default; }
|
||||||
|
.nav-clock {
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 0;
|
||||||
|
padding: 4px 4px 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#swiss-clock { display: block; }
|
||||||
|
|
||||||
|
/* Swiss railway clock — full-page overlay */
|
||||||
|
#clock-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: #1a1a1a;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#clock-overlay.visible { display: flex; }
|
||||||
|
#swiss-clock-overlay { display: block; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
/* ── Swiss Federal Railway (SBB) clock ── */
|
||||||
|
|
||||||
|
/* Draw one frame of the clock onto any canvas element. */
|
||||||
|
function drawSwissClock(canvas) {
|
||||||
|
var SIZE = canvas.width;
|
||||||
|
var R = SIZE / 2;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var now = new Date();
|
||||||
|
var h = now.getHours() % 12;
|
||||||
|
var m = now.getMinutes();
|
||||||
|
var s = now.getSeconds();
|
||||||
|
var ms = now.getMilliseconds();
|
||||||
|
|
||||||
|
/* Seconds hand idles ~1.5 s at 12 before advancing (SBB behaviour) */
|
||||||
|
var sFrac = s + ms / 1000;
|
||||||
|
var sAngle = sFrac >= 58.5 ? 0 : (sFrac / 58.5) * Math.PI * 2;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
|
/* face */
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(R, R, R - 1, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#333';
|
||||||
|
ctx.lineWidth = SIZE * 0.018;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
/* tick marks */
|
||||||
|
for (var i = 0; i < 60; i++) {
|
||||||
|
var a = (i / 60) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
var isHour = (i % 5 === 0);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(R + Math.cos(a) * (isHour ? R * 0.72 : R * 0.88),
|
||||||
|
R + Math.sin(a) * (isHour ? R * 0.72 : R * 0.88));
|
||||||
|
ctx.lineTo(R + Math.cos(a) * R * 0.94,
|
||||||
|
R + Math.sin(a) * R * 0.94);
|
||||||
|
ctx.strokeStyle = '#222';
|
||||||
|
ctx.lineWidth = isHour ? SIZE * 0.027 : SIZE * 0.011;
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hands */
|
||||||
|
function hand(angle, tip, tail, width, color) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(R, R);
|
||||||
|
ctx.rotate(angle);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tail, 0);
|
||||||
|
ctx.lineTo(tip, 0);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.lineCap = 'square';
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
hand((sFrac >= 58.5 ? m + 1 : m) / 60 * Math.PI * 2 - Math.PI / 2,
|
||||||
|
R * 0.88, -R * 0.12, SIZE * 0.027, '#222'); /* minute */
|
||||||
|
hand((h + m / 60) / 12 * Math.PI * 2 - Math.PI / 2,
|
||||||
|
R * 0.58, -R * 0.12, SIZE * 0.039, '#222'); /* hour */
|
||||||
|
hand(sAngle - Math.PI / 2, R * 0.78, -R * 0.22,
|
||||||
|
SIZE * 0.013, '#e00'); /* second tail+tip */
|
||||||
|
|
||||||
|
/* round dot at tip of second hand */
|
||||||
|
var dotR = SIZE * 0.028;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(R, R);
|
||||||
|
ctx.rotate(sAngle - Math.PI / 2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(R * 0.78, 0, dotR, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#e00';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
/* centre cap */
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(R, R, R * 0.04, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#222';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize the overlay canvas to fit the viewport, keeping it square. */
|
||||||
|
function resizeOverlayClock() {
|
||||||
|
var oc = document.getElementById('swiss-clock-overlay');
|
||||||
|
if (!oc) return;
|
||||||
|
var size = Math.min(window.innerWidth, window.innerHeight) * 0.88;
|
||||||
|
size = Math.floor(size);
|
||||||
|
oc.width = size;
|
||||||
|
oc.height = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main tick — redraws both nav clock and (if visible) overlay clock. */
|
||||||
|
function clockTick() {
|
||||||
|
var nav = document.getElementById('swiss-clock');
|
||||||
|
if (nav) drawSwissClock(nav);
|
||||||
|
var overlay = document.getElementById('clock-overlay');
|
||||||
|
if (overlay && overlay.classList.contains('visible')) {
|
||||||
|
var oc = document.getElementById('swiss-clock-overlay');
|
||||||
|
if (oc) drawSwissClock(oc);
|
||||||
|
}
|
||||||
|
var delay = 100 - (Date.now() % 100);
|
||||||
|
setTimeout(clockTick, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
/* Start the shared tick loop */
|
||||||
|
clockTick();
|
||||||
|
|
||||||
|
/* Overlay toggle — clicking the nav clock opens it */
|
||||||
|
var navClock = document.querySelector('.nav-clock');
|
||||||
|
var overlay = document.getElementById('clock-overlay');
|
||||||
|
if (navClock && overlay) {
|
||||||
|
navClock.addEventListener('click', function() {
|
||||||
|
resizeOverlayClock();
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
});
|
||||||
|
overlay.addEventListener('click', function() {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
if (overlay.classList.contains('visible')) resizeOverlayClock();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script src="static/sorttable.js"></script>
|
||||||
</head>
|
</head>
|
||||||
+148
-58
@@ -4,47 +4,48 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 10px;
|
display: flex;
|
||||||
background: #f5f5f5;
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
@media (max-width: 640px) {
|
||||||
background: #fff;
|
body {
|
||||||
padding: 10px 15px;
|
height: auto;
|
||||||
margin-bottom: 10px;
|
min-height: 100vh;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
overflow: auto;
|
||||||
border-radius: 4px;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.container {
|
||||||
.nav a {
|
max-height: none;
|
||||||
margin-right: 20px;
|
overflow: visible;
|
||||||
text-decoration: none;
|
}
|
||||||
color: #0066cc;
|
.table-section {
|
||||||
font-weight: 500;
|
max-height: 55vh;
|
||||||
font-size: 0.9em;
|
}
|
||||||
}
|
.log-section {
|
||||||
|
flex: none;
|
||||||
.nav a:hover {
|
max-height: 40vh;
|
||||||
text-decoration: underline;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.nav a.active {
|
|
||||||
color: #333;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-height: calc(100vh - 120px);
|
display: flex;
|
||||||
overflow-y: auto;
|
flex-direction: column;
|
||||||
padding-right: 10px;
|
gap: 15px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: #333;
|
color: #333;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
margin-top: 15px;
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,14 +76,18 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-section {
|
.log-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +101,8 @@
|
|||||||
#ntable th {
|
#ntable th {
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid #e0e0e0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 8px 10px;
|
padding: 2px 4px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ntable tr:nth-child(even) {
|
#ntable tr:nth-child(even) {
|
||||||
@@ -107,8 +113,24 @@
|
|||||||
background-color: #e3f2fd;
|
background-color: #e3f2fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ntable tbody tr.row-warning {
|
||||||
|
background-color: #fff8c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ntable tbody tr.row-critical {
|
||||||
|
background-color: #fde8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ntable tbody tr.row-warning:hover {
|
||||||
|
background-color: #fff0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ntable tbody tr.row-critical:hover {
|
||||||
|
background-color: #f9c8c8;
|
||||||
|
}
|
||||||
|
|
||||||
#ntable th {
|
#ntable th {
|
||||||
padding: 12px 10px;
|
padding: 6px 8px;
|
||||||
background-color: #2196f3;
|
background-color: #2196f3;
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -137,24 +159,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
.container::-webkit-scrollbar,
|
|
||||||
.log-section::-webkit-scrollbar {
|
.log-section::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container::-webkit-scrollbar-track,
|
|
||||||
.log-section::-webkit-scrollbar-track {
|
.log-section::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: #f1f1f1;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container::-webkit-scrollbar-thumb,
|
|
||||||
.log-section::-webkit-scrollbar-thumb {
|
.log-section::-webkit-scrollbar-thumb {
|
||||||
background: #888;
|
background: #888;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container::-webkit-scrollbar-thumb:hover,
|
|
||||||
.log-section::-webkit-scrollbar-thumb:hover {
|
.log-section::-webkit-scrollbar-thumb:hover {
|
||||||
background: #555;
|
background: #555;
|
||||||
}
|
}
|
||||||
@@ -162,14 +180,27 @@
|
|||||||
/* Message styling */
|
/* Message styling */
|
||||||
#messages {
|
#messages {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1.6;
|
line-height: 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#messages div {
|
#messages .log-entry {
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5em;
|
||||||
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-ts { color: #888; white-space: nowrap; }
|
||||||
|
.log-level { font-weight: bold; min-width: 6em; }
|
||||||
|
.log-host { font-weight: 600; }
|
||||||
|
.log-service { color: #888; }
|
||||||
|
|
||||||
|
.log-warning .log-level { color: #b8860b; }
|
||||||
|
.log-critical .log-level { color: #c00; }
|
||||||
|
.log-recover .log-level { color: #2a7a2a; }
|
||||||
|
.log-info .log-level { color: #555; }
|
||||||
|
|
||||||
/* Modal for connection status messages */
|
/* Modal for connection status messages */
|
||||||
.connection-modal {
|
.connection-modal {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -218,21 +249,47 @@
|
|||||||
color: #ff9800;
|
color: #ff9800;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
#ntable a.host-link { color: inherit; text-decoration: none; }
|
||||||
|
#ntable a.host-link:hover { text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var cnt = 0;
|
var cnt = 0;
|
||||||
var nTable = document;
|
var nTable = document;
|
||||||
var name_idx = {};
|
var name_idx = {};
|
||||||
var c = 0;
|
var c = 0;
|
||||||
|
var HBD_VERSION = "{{ hbd_version }}";
|
||||||
|
|
||||||
|
function hostNameHtml(data) {
|
||||||
|
var rawName = data.raw_name || data.name.replace(/<[^>]+>/g, '').replace('*', '').trim();
|
||||||
|
var nameHtml = data.name;
|
||||||
|
if (!data.hbc_version || data.hbc_version !== HBD_VERSION) {
|
||||||
|
nameHtml += ' 🥀';
|
||||||
|
}
|
||||||
|
var display = data.dyn ? '<b>' + nameHtml + '</b>' : nameHtml;
|
||||||
|
return '<a class="host-link" href="/plugins#' + encodeURIComponent(rawName) + '">' + display + '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
name_idx = {};
|
name_idx = {};
|
||||||
nTable = document.getElementById("ntable");
|
nTable = document.getElementById("ntable");
|
||||||
for (var i = 0, row; (row = nTable.rows[i]); i++) {
|
for (var i = 0, row; (row = nTable.rows[i]); i++) {
|
||||||
if (i == 0) continue;
|
if (i == 0) continue;
|
||||||
name = nTable.rows[i].cells[0].innerText;
|
var cell = nTable.rows[i].cells[0];
|
||||||
|
var name = cell.dataset.name || cell.innerText.replace(/\s*🥀\s*$/, '').trim();
|
||||||
name_idx[name] = nTable.rows[i];
|
name_idx[name] = nTable.rows[i];
|
||||||
/* console.log("name_Id[" + name + "]: " + name_idx[name].innerText); */
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRowAlert(row, data) {
|
||||||
|
var criticalUnacked = data.alert_critical_unacked || 0;
|
||||||
|
var criticalAcked = data.alert_critical_acked || 0;
|
||||||
|
var warningUnacked = data.alert_warning_unacked || 0;
|
||||||
|
var warningAcked = data.alert_warning_acked || 0;
|
||||||
|
row.classList.remove('row-warning', 'row-critical');
|
||||||
|
if (criticalUnacked > 0 || criticalAcked > 0) {
|
||||||
|
row.classList.add('row-critical');
|
||||||
|
} else if (warningUnacked > 0 || warningAcked > 0) {
|
||||||
|
row.classList.add('row-warning');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,11 +327,8 @@
|
|||||||
row.appendChild(c_ipv6state);
|
row.appendChild(c_ipv6state);
|
||||||
row.appendChild(c_ipv6latency);
|
row.appendChild(c_ipv6latency);
|
||||||
row.appendChild(c_ipv6statets);
|
row.appendChild(c_ipv6statets);
|
||||||
if (data.dyn) {
|
c_name.dataset.name = data.name;
|
||||||
c_name.innerHTML = "<b>" + data.name + "</b>";
|
c_name.innerHTML = hostNameHtml(data);
|
||||||
} else {
|
|
||||||
c_name.innerHTML = data.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set alert counts in "x/y" format (unacked/acked)
|
// Set alert counts in "x/y" format (unacked/acked)
|
||||||
var warningUnacked = data.alert_warning_unacked || 0;
|
var warningUnacked = data.alert_warning_unacked || 0;
|
||||||
@@ -303,12 +357,31 @@
|
|||||||
var table = document.getElementById("ntablebody"); // find table to append to
|
var table = document.getElementById("ntablebody"); // find table to append to
|
||||||
table.appendChild(row); // append row to table
|
table.appendChild(row); // append row to table
|
||||||
name_idx[c_name] = row;
|
name_idx[c_name] = row;
|
||||||
|
updateRowAlert(row, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTS(ts) {
|
function formatTS(ts) {
|
||||||
const milliseconds = ts * 1000;
|
const now = new Date();
|
||||||
const dateObject = new Date(milliseconds);
|
const d = new Date(ts * 1000);
|
||||||
return dateObject.toLocaleString("de-DE");
|
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
|
||||||
|
// Same calendar day → show time only
|
||||||
|
if (d.toDateString() === now.toDateString()) {
|
||||||
|
return timeStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within 8 days → show "-X d hh:mm:ss"
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const dStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
const diffDays = Math.round((todayStart - dStart) / 86400000);
|
||||||
|
if (diffDays < 8) {
|
||||||
|
return `-${diffDays}d ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older → date only
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function update_table(data) {
|
function update_table(data) {
|
||||||
@@ -317,6 +390,11 @@
|
|||||||
setup();
|
setup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update name cell (version indicator)
|
||||||
|
var nameCell = name_idx[data.name].cells[0];
|
||||||
|
nameCell.dataset.name = data.name;
|
||||||
|
nameCell.innerHTML = hostNameHtml(data);
|
||||||
|
|
||||||
// Update warning and critical counts in "x/y" format (unacked/acked)
|
// Update warning and critical counts in "x/y" format (unacked/acked)
|
||||||
var warningUnacked = data.alert_warning_unacked || 0;
|
var warningUnacked = data.alert_warning_unacked || 0;
|
||||||
var warningAcked = data.alert_warning_acked || 0;
|
var warningAcked = data.alert_warning_acked || 0;
|
||||||
@@ -343,7 +421,7 @@
|
|||||||
);
|
);
|
||||||
if (data.connections[i].state == "up") {
|
if (data.connections[i].state == "up") {
|
||||||
state = '<span class="state-up">up</span>';
|
state = '<span class="state-up">up</span>';
|
||||||
latency = Number.parseFloat(data.connections[i].rtts[0]).toFixed(2);
|
latency = String(Math.round(Number.parseFloat(data.connections[i].rtts[0])));
|
||||||
} else {
|
} else {
|
||||||
if (data.connections[i].state == "unknown") {
|
if (data.connections[i].state == "unknown") {
|
||||||
state = "";
|
state = "";
|
||||||
@@ -364,6 +442,7 @@
|
|||||||
name_idx[data.name].cells[4 + i * 4].innerHTML = state;
|
name_idx[data.name].cells[4 + i * 4].innerHTML = state;
|
||||||
name_idx[data.name].cells[5 + i * 4].innerHTML = latency;
|
name_idx[data.name].cells[5 + i * 4].innerHTML = latency;
|
||||||
}
|
}
|
||||||
|
updateRowAlert(name_idx[data.name], data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WS_Connect() {
|
function WS_Connect() {
|
||||||
@@ -394,7 +473,20 @@
|
|||||||
update_table(state.data);
|
update_table(state.data);
|
||||||
} else if (state.type == "message") {
|
} else if (state.type == "message") {
|
||||||
var msgs = document.getElementById("messages");
|
var msgs = document.getElementById("messages");
|
||||||
msgs.insertAdjacentHTML("afterbegin", "<div>" + state.data + "</div>");
|
var msg = state.data;
|
||||||
|
var _d = new Date(msg.ts * 1000);
|
||||||
|
function _p(n) { return n < 10 ? '0' + n : '' + n; }
|
||||||
|
var ts_str = _d.getFullYear() + '-' + _p(_d.getMonth()+1) + '-' + _p(_d.getDate())
|
||||||
|
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
||||||
|
var lvl = (msg.level || "INFO").toLowerCase();
|
||||||
|
var html = '<div class="log-entry log-' + lvl + '">';
|
||||||
|
html += '<span class="log-ts">' + ts_str + '</span>';
|
||||||
|
html += '<span class="log-level">' + (msg.level || "") + '</span>';
|
||||||
|
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
|
||||||
|
if (msg.service) html += '<span class="log-service">' + msg.service + '</span>';
|
||||||
|
html += '<span class="log-msg">' + msg.message + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
msgs.insertAdjacentHTML("afterbegin", html);
|
||||||
}
|
}
|
||||||
cnt++;
|
cnt++;
|
||||||
};
|
};
|
||||||
@@ -419,17 +511,15 @@
|
|||||||
WS_Connect();
|
WS_Connect();
|
||||||
</script>
|
</script>
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
{% include 'nav.html' %}
|
||||||
<a href="/live" class="active">Live Dashboard</a>
|
|
||||||
<a href="/plugins">Plugin Metrics</a>
|
|
||||||
<a href="/alerts">Alerts</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include 'menu.html' %}
|
{% include 'menu.html' %}
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{{ header }}</h1>
|
<div>
|
||||||
<p class="subtitle">Real-time host monitoring and event log</p>
|
<h1>{{ header }}</h1>
|
||||||
|
<p class="subtitle">Real-time host monitoring and event log</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-section">
|
<div class="table-section">
|
||||||
<table id="ntable" class="sortable">
|
<table id="ntable" class="sortable">
|
||||||
@@ -450,8 +540,8 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="ntablebody">
|
<tbody id="ntablebody">
|
||||||
{% for host in hosts %}
|
{% for host in hosts %}
|
||||||
<tr>
|
<tr class="{% if host.alert_critical_unacked > 0 or host.alert_critical_acked > 0 %}row-critical{% elif host.alert_warning_unacked > 0 or host.alert_warning_acked > 0 %}row-warning{% endif %}">
|
||||||
<td>{{ host.name }}</td>
|
<td data-name="{{ host.name }}"><a class="host-link" href="/plugins#{{ host.raw_name | urlencode }}">{{ host.name }}{% if not host.hbc_version or host.hbc_version != hbd_version %} 🥀{% endif %}</a></td>
|
||||||
<td style="text-align: center; color: #ff9800; font-weight: bold;">
|
<td style="text-align: center; color: #ff9800; font-weight: bold;">
|
||||||
{%- set warning_unacked = host.alert_warning_unacked -%}
|
{%- set warning_unacked = host.alert_warning_unacked -%}
|
||||||
{%- set warning_acked = host.alert_warning_acked -%}
|
{%- set warning_acked = host.alert_warning_acked -%}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
|
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
|
||||||
s<header>{{ header }}</header> -->
|
s<header>{{ header }}</header> -->
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<div class="nav">
|
||||||
|
<button class="nav-hamburger" id="nav-hamburger-btn" aria-label="Menu" aria-expanded="false">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
<div class="nav-links" id="nav-links">
|
||||||
|
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
|
||||||
|
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Host Overview</a>
|
||||||
|
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
|
||||||
|
{% if current_user and current_user.admin %}
|
||||||
|
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-pie" title="Host alert status">
|
||||||
|
<canvas id="alert-pie" width="44" height="44"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="nav-clock" title="Click for full-screen clock">
|
||||||
|
<canvas id="swiss-clock" width="44" height="44"></canvas>
|
||||||
|
</div>
|
||||||
|
{% if current_user %}
|
||||||
|
<a href="/profile" class="nav-user{% if active_page == 'profile' %} active{% endif %}" title="{{ current_user.full_name or current_user.username }}">
|
||||||
|
{% if current_user.avatar %}
|
||||||
|
<img class="nav-avatar" src="{{ current_user.avatar_url }}" alt="{{ current_user.full_name or current_user.username }}">
|
||||||
|
{% else %}
|
||||||
|
<span class="nav-initials">{{ (current_user.full_name or current_user.username)[:1] | upper }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="nav-username">{{ current_user.full_name or current_user.username }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full-page clock overlay (click anywhere to dismiss) -->
|
||||||
|
<div id="clock-overlay">
|
||||||
|
<canvas id="swiss-clock-overlay" width="400" height="400"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var btn = document.getElementById('nav-hamburger-btn');
|
||||||
|
var links = document.getElementById('nav-links');
|
||||||
|
if (btn && links) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var open = links.classList.toggle('nav-open');
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function drawAlertPie(critical, warning, ok) {
|
||||||
|
var canvas = document.getElementById('alert-pie');
|
||||||
|
if (!canvas) return;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var SIZE = canvas.width;
|
||||||
|
var R = SIZE / 2;
|
||||||
|
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||||
|
var total = critical + warning + ok;
|
||||||
|
if (total === 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(R, R, R - 1, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#ccc';
|
||||||
|
ctx.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var slices = [
|
||||||
|
{ value: critical, color: '#e53935' },
|
||||||
|
{ value: warning, color: '#ffb300' },
|
||||||
|
{ value: ok, color: '#43a047' }
|
||||||
|
];
|
||||||
|
var start = -Math.PI / 2;
|
||||||
|
slices.forEach(function(s) {
|
||||||
|
if (s.value === 0) return;
|
||||||
|
var sweep = (s.value / total) * Math.PI * 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(R, R);
|
||||||
|
ctx.arc(R, R, R - 1, start, start + sweep);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = s.color;
|
||||||
|
ctx.fill();
|
||||||
|
start += sweep;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAlertPie() {
|
||||||
|
fetch('/api/0/alert_summary').then(function(r) {
|
||||||
|
if (!r.ok) return;
|
||||||
|
return r.json();
|
||||||
|
}).then(function(d) {
|
||||||
|
if (d) drawAlertPie(d.critical || 0, d.warning || 0, d.ok || 0);
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateAlertPie();
|
||||||
|
setInterval(updateAlertPie, 30000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
+1117
-909
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{% include 'head.html' %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body { overflow: visible; }
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Profile card ---- */
|
||||||
|
.profile-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
|
||||||
|
padding: 28px 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-large {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-initials-large {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #0066cc;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info { flex: 1; }
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: 1.4em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-username {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.78em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-admin { background: #e8f0fe; color: #1a73e8; }
|
||||||
|
.badge-user { background: #f1f3f4; color: #555; }
|
||||||
|
|
||||||
|
.profile-logout {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f44336;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-logout:hover { background: #d32f2f; text-decoration: none; }
|
||||||
|
|
||||||
|
/* ---- Section cards ---- */
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Settings rows ---- */
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.settings-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
width: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-value { color: #222; }
|
||||||
|
|
||||||
|
.settings-empty { color: #aaa; font-style: italic; }
|
||||||
|
|
||||||
|
/* ---- Host lists ---- */
|
||||||
|
.host-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-chip.owner { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.host-chip.manager { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.host-chip.monitor { background: #f3e5f5; color: #6a1b9a; }
|
||||||
|
|
||||||
|
.host-chip-dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
}
|
||||||
|
.owner .host-chip-dot { background: #2e7d32; }
|
||||||
|
.manager .host-chip-dot { background: #1565c0; }
|
||||||
|
.monitor .host-chip-dot { background: #6a1b9a; }
|
||||||
|
|
||||||
|
.no-hosts {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Notification channels ---- */
|
||||||
|
.channel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.channel-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.channel-type {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.78em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #f1f3f4;
|
||||||
|
color: #555;
|
||||||
|
min-width: 70px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name { color: #333; }
|
||||||
|
|
||||||
|
.edit-section { margin-top: 20px; }
|
||||||
|
.edit-section h4 { font-size: .88em; font-weight: 600; color: #333; margin: 0 0 10px; text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid #eee; padding-bottom: 6px; }
|
||||||
|
.edit-field { margin-bottom: 10px; }
|
||||||
|
.edit-field label { display: block; font-size: .82em; color: #666; margin-bottom: 3px; }
|
||||||
|
.edit-input { width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px; font-size: .88em; box-sizing: border-box; }
|
||||||
|
.edit-input:focus { border-color: #0066cc; outline: none; }
|
||||||
|
.status-msg { font-size: .82em; margin-left: 8px; }
|
||||||
|
.save-row { display: flex; align-items: center; margin-top: 8px; }
|
||||||
|
.btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
|
||||||
|
.btn-save:hover { background: #0055aa; }
|
||||||
|
.channel-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; }
|
||||||
|
.channel-item:last-child { border-bottom: none; }
|
||||||
|
.channel-item label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; font-size: .88em; }
|
||||||
|
.channel-item .ch-name { font-weight: 500; color: #222; }
|
||||||
|
.channel-item .ch-meta { font-size: .8em; color: #888; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>{{ header }}</h1>
|
||||||
|
<p class="subtitle">Your account settings and host access</p>
|
||||||
|
|
||||||
|
<!-- Profile card -->
|
||||||
|
<div class="profile-card">
|
||||||
|
{% if current_user and current_user.avatar %}
|
||||||
|
<img class="avatar-large" src="{{ current_user.avatar_url }}" alt="">
|
||||||
|
{% else %}
|
||||||
|
<div class="avatar-initials-large">
|
||||||
|
{{ ((current_user.full_name if current_user else '') or (current_user.username if current_user else '?'))[:1] | upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="profile-info">
|
||||||
|
<div class="profile-name">{{ current_user.full_name if current_user and current_user.full_name else (current_user.username if current_user else '—') }}</div>
|
||||||
|
<div class="profile-username">@{{ current_user.username if current_user else '—' }}</div>
|
||||||
|
{% if current_user and current_user.admin %}
|
||||||
|
<span class="badge badge-admin">Admin</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-user">User</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="profile-logout">
|
||||||
|
<a href="/logout" class="btn-logout">Sign out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account settings -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Account</h2>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Username</span>
|
||||||
|
<span class="settings-value">{{ current_user.username if current_user else '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Full name</span>
|
||||||
|
{% if current_user and current_user.full_name %}
|
||||||
|
<span class="settings-value">{{ current_user.full_name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="settings-empty">Not set</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Role</span>
|
||||||
|
<span class="settings-value">{{ 'Administrator' if current_user and current_user.admin else 'User' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Avatar</span>
|
||||||
|
{% if current_user and current_user.avatar %}
|
||||||
|
<span class="settings-value" style="word-break:break-all;">{{ current_user.avatar }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="settings-empty">Not set (initials used)</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user %}
|
||||||
|
<!-- ---- Editable identity ---- -->
|
||||||
|
<div class="section edit-section">
|
||||||
|
<h4>Identity</h4>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="profile-fullname">Display name</label>
|
||||||
|
<input id="profile-fullname" class="edit-input" type="text" value="{{ current_user.full_name | e }}" placeholder="Full name">
|
||||||
|
</div>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="profile-avatar">Avatar URL or path</label>
|
||||||
|
<input id="profile-avatar" class="edit-input" type="text" value="{{ current_user.avatar | e }}" placeholder="/path/to/avatar.png or https://…">
|
||||||
|
</div>
|
||||||
|
<div class="save-row">
|
||||||
|
<button class="btn-save" onclick="saveIdentity()">Save</button>
|
||||||
|
<span id="identity-status" class="status-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ---- Change password ---- -->
|
||||||
|
<div class="section edit-section">
|
||||||
|
<h4>Change password</h4>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="profile-current-pw">Current password</label>
|
||||||
|
<input id="profile-current-pw" class="edit-input" type="password" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="profile-new-pw">New password</label>
|
||||||
|
<input id="profile-new-pw" class="edit-input" type="password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="save-row">
|
||||||
|
<button class="btn-save" onclick="changePassword()">Change password</button>
|
||||||
|
<span id="password-status" class="status-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Notification channels -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Notification Channels</h2>
|
||||||
|
{% if current_user %}
|
||||||
|
<p style="font-size:.82em;color:#888;margin:0 0 10px">Select which channels send you alerts. Channels are defined by the administrator.</p>
|
||||||
|
{% if all_channel_names %}
|
||||||
|
<div id="channel-checkboxes">
|
||||||
|
{% for ch_name in all_channel_names %}
|
||||||
|
<div class="channel-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" class="channel-checkbox" value="{{ ch_name | e }}"
|
||||||
|
{% if ch_name in (current_user.notification_channels or []) %}checked{% endif %}>
|
||||||
|
<div>
|
||||||
|
<div class="ch-name">{{ ch_name | e }}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels configured.</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="save-row" style="margin-top:10px">
|
||||||
|
<button class="btn-save" onclick="saveChannels()">Save channels</button>
|
||||||
|
<span id="channels-status" class="status-msg"></span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="no-hosts">No personal notification channels configured.</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Host access -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Host Access</h2>
|
||||||
|
|
||||||
|
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
|
||||||
|
<span class="settings-label" style="padding-top: 2px;">Owner</span>
|
||||||
|
<div class="host-grid">
|
||||||
|
{% if owned_hosts %}
|
||||||
|
{% for h in owned_hosts %}
|
||||||
|
<span class="host-chip owner"><span class="host-chip-dot"></span>{{ h }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="no-hosts">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
|
||||||
|
<span class="settings-label" style="padding-top: 2px;">Manager</span>
|
||||||
|
<div class="host-grid">
|
||||||
|
{% if managed_hosts %}
|
||||||
|
{% for h in managed_hosts %}
|
||||||
|
<span class="host-chip manager"><span class="host-chip-dot"></span>{{ h }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="no-hosts">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row" style="align-items: flex-start; padding-bottom: 4px;">
|
||||||
|
<span class="settings-label" style="padding-top: 2px;">Monitor</span>
|
||||||
|
<div class="host-grid">
|
||||||
|
{% if monitored_hosts %}
|
||||||
|
{% for h in monitored_hosts %}
|
||||||
|
<span class="host-chip monitor"><span class="host-chip-dot"></span>{{ h }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="no-hosts">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function saveIdentity() {
|
||||||
|
const full_name = document.getElementById('profile-fullname').value;
|
||||||
|
const avatar = document.getElementById('profile-avatar').value;
|
||||||
|
const resp = await fetch('/api/0/users/me', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({full_name, avatar}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
showStatus('identity-status', 'Saved', '#2e7d32');
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
showStatus('identity-status', err.error || 'Error saving', '#c62828');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
const current = document.getElementById('profile-current-pw').value;
|
||||||
|
const newpw = document.getElementById('profile-new-pw').value;
|
||||||
|
if (!current || !newpw) {
|
||||||
|
showStatus('password-status', 'Both fields are required', '#c62828');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await fetch('/api/0/users/me', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({password: {current, new: newpw}}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('profile-current-pw').value = '';
|
||||||
|
document.getElementById('profile-new-pw').value = '';
|
||||||
|
showStatus('password-status', 'Password changed', '#2e7d32');
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
showStatus('password-status', err.error || 'Error', '#c62828');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChannels() {
|
||||||
|
const notification_channels = [...document.querySelectorAll('.channel-checkbox:checked')]
|
||||||
|
.map(cb => cb.value);
|
||||||
|
const resp = await fetch('/api/0/users/me', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({notification_channels}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
showStatus('channels-status', 'Saved', '#2e7d32');
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
showStatus('channels-status', err.error || 'Error saving', '#c62828');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(id, msg, color) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.color = color;
|
||||||
|
setTimeout(() => { el.textContent = ''; }, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,863 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{% include 'head.html' %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body { overflow: visible; }
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
||||||
|
.subtitle { color: #666; margin-bottom: 24px; font-size: 0.9em; }
|
||||||
|
|
||||||
|
/* ---- Sidebar + content layout ---- */
|
||||||
|
.settings-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a {
|
||||||
|
display: block;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.sidebar-nav a:hover { background: #e8eaf6; color: #1a237e; }
|
||||||
|
.sidebar-nav a.active { background: #e3f2fd; color: #0066cc; font-weight: 600; }
|
||||||
|
|
||||||
|
.settings-main { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* ---- Section card ---- */
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.08);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 14px 20px 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.95em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #222;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: #888;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Field rows ---- */
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.field-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.88em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.field-value {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #222;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-desc {
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Value type renderers ---- */
|
||||||
|
.val-boolean {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.val-boolean.on { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.val-boolean.off { background: #fce4ec; color: #c62828; }
|
||||||
|
|
||||||
|
.val-masked {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #bbb;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.val-list { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.val-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 9px;
|
||||||
|
background: #e8eaf6;
|
||||||
|
color: #283593;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
.val-empty { color: #ccc; font-style: italic; font-size: 0.88em; }
|
||||||
|
|
||||||
|
/* ---- Users table ---- */
|
||||||
|
.mini-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.mini-table th {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 7px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.82em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.mini-table td {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.mini-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.mini-table tbody tr:hover { background: #fafafa; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-admin { background: #e8f0fe; color: #1a73e8; }
|
||||||
|
.badge-user { background: #f1f3f4; color: #666; }
|
||||||
|
|
||||||
|
/* ---- Notification channels ---- */
|
||||||
|
.channel-card {
|
||||||
|
border: 1px solid #e8eaf6;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 12px 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
background: #f8f9ff;
|
||||||
|
border-bottom: 1px solid #e8eaf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name-text { font-weight: 600; font-size: 0.9em; color: #222; }
|
||||||
|
|
||||||
|
.ch-type-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #e8eaf6;
|
||||||
|
color: #3949ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-fields { padding: 6px 0; }
|
||||||
|
|
||||||
|
.channel-field {
|
||||||
|
display: flex;
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.channel-field:last-child { border-bottom: none; }
|
||||||
|
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
|
||||||
|
.channel-field-value { color: #333; word-break: break-all; }
|
||||||
|
|
||||||
|
/* ---- Hosts table ---- */
|
||||||
|
/* ---- Mobile: collapsible sidebar ---- */
|
||||||
|
.sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #e8eaf6;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #283593;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.sidebar-toggle::after { content: ' ▾'; float: right; }
|
||||||
|
.sidebar-toggle.open::after { content: ' ▴'; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sidebar-toggle { display: block; }
|
||||||
|
|
||||||
|
.settings-layout { flex-direction: column; gap: 0; }
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: none;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.1);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.sidebar-nav.open { display: block; }
|
||||||
|
.sidebar-nav a { padding: 10px 16px; font-size: 1em; }
|
||||||
|
|
||||||
|
.field-row { flex-direction: column; gap: 4px; }
|
||||||
|
.field-label { width: 100%; font-size: 0.82em; color: #888; }
|
||||||
|
}
|
||||||
|
.host-bool { text-align: center; }
|
||||||
|
.dot-yes { color: #2e7d32; font-size: 1.1em; }
|
||||||
|
.dot-no { color: #ddd; font-size: 1.1em; }
|
||||||
|
|
||||||
|
/* ---- Threshold configurations ---- */
|
||||||
|
.thresh-config { margin: 12px 20px 20px; }
|
||||||
|
.thresh-config-name {
|
||||||
|
font-weight: 600; font-size: 0.9em; color: #1a237e;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.mini-table .warn { color: #e65100; font-weight: 600; }
|
||||||
|
.mini-table .crit { color: #b71c1c; font-weight: 600; }
|
||||||
|
.mini-table .dim { color: #aaa; }
|
||||||
|
.mini-table .metric-path { font-family: monospace; font-size: 0.88em; }
|
||||||
|
|
||||||
|
/* ---- Editable inputs ---- */
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.88em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.field-input:focus { border-color: #0066cc; outline: none; box-shadow: 0 0 0 2px rgba(0,102,204,.15); }
|
||||||
|
|
||||||
|
/* ---- Section footer (Stage Changes button) ---- */
|
||||||
|
.section-footer {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Pending changes banner ---- */
|
||||||
|
.pending-banner {
|
||||||
|
position: sticky;
|
||||||
|
top: 8px;
|
||||||
|
z-index: 100;
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px solid #e8c840;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.87em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.pending-banner .pending-msg { color: #7a6000; }
|
||||||
|
.pending-banner .pending-actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* ---- YAML editor ---- */
|
||||||
|
.yaml-editor {
|
||||||
|
width: 100%;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.83em;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fafafa;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
.yaml-editor:focus { border-color: #0066cc; outline: none; }
|
||||||
|
|
||||||
|
/* ---- Button styles ---- */
|
||||||
|
.btn { border: none; border-radius: 4px; padding: 5px 12px; font-size: 0.85em; cursor: pointer; }
|
||||||
|
.btn-primary { background: #0066cc; color: #fff; }
|
||||||
|
.btn-primary:hover { background: #0055aa; }
|
||||||
|
.btn-success { background: #2a7a2a; color: #fff; }
|
||||||
|
.btn-success:hover { background: #226622; }
|
||||||
|
.btn-secondary { background: #888; color: #fff; }
|
||||||
|
.btn-secondary:hover { background: #666; }
|
||||||
|
.btn-danger { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: 0.82em; cursor: pointer; }
|
||||||
|
.btn-danger:hover { background: #fce4ec; }
|
||||||
|
|
||||||
|
/* ---- CRUD table for users / oauth ---- */
|
||||||
|
.crud-table { width: 100%; border-collapse: collapse; font-size: 0.83em; }
|
||||||
|
.crud-table th { background: #f5f5f5; padding: 6px 10px; text-align: left; font-weight: 600; color: #555; font-size: .78em; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid #e0e0e0; }
|
||||||
|
.crud-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
|
||||||
|
.crud-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.crud-table .field-input { max-width: none; }
|
||||||
|
|
||||||
|
/* ---- Rollback modal ---- */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
background: #fff; border-radius: 8px; padding: 24px;
|
||||||
|
min-width: 340px; max-width: 520px; width: 90%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.18);
|
||||||
|
}
|
||||||
|
.modal-box h3 { margin: 0 0 12px; font-size: 1em; }
|
||||||
|
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
|
||||||
|
.backup-row:last-child { border-bottom: none; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<p class="subtitle">Edit server configuration — changes are staged until you publish them to <code>.hb.yaml</code>.</p>
|
||||||
|
|
||||||
|
<!-- Pending changes banner (hidden until something is staged) -->
|
||||||
|
<div id="pending-banner" class="pending-banner" style="display:none">
|
||||||
|
<span class="pending-msg">⚠ <strong id="pending-count">0</strong> section(s) with pending changes — not yet saved to .hb.yaml</span>
|
||||||
|
<span class="pending-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="discardAll()">Discard all</button>
|
||||||
|
<button class="btn btn-success" onclick="publishAll()">Publish to .hb.yaml</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rollback modal -->
|
||||||
|
<div id="rollback-modal" class="modal-overlay" style="display:none" onclick="if(event.target===this)closeRollbackModal()">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3>Backups / Rollback</h3>
|
||||||
|
<div id="rollback-list" style="max-height:300px;overflow-y:auto">Loading…</div>
|
||||||
|
<div style="margin-top:14px;text-align:right">
|
||||||
|
<button class="btn btn-secondary" onclick="closeRollbackModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar navigation -->
|
||||||
|
<nav class="settings-sidebar">
|
||||||
|
<button class="sidebar-toggle" id="sidebar-toggle" aria-expanded="false">Sections</button>
|
||||||
|
<div class="sidebar-nav" id="sidebar-nav">
|
||||||
|
{% for section in sections %}
|
||||||
|
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
<hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;">
|
||||||
|
<a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="settings-main">
|
||||||
|
{% for section in sections %}
|
||||||
|
<div class="section" id="{{ section.id }}">
|
||||||
|
<div class="section-header">
|
||||||
|
<p class="section-title">{{ section.title }}</p>
|
||||||
|
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Users CRUD ---- #}
|
||||||
|
{% if section.id == 'users' %}
|
||||||
|
<div style="padding: 12px 20px 0">
|
||||||
|
{% for f in section.fields %}
|
||||||
|
{% if f.editable %}
|
||||||
|
<div class="field-row" style="border-bottom: 1px solid #eee; margin-bottom: 8px">
|
||||||
|
<div class="field-label" style="font-size:.85em;color:#555">{{ f.label }}</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<input type="text" class="field-input"
|
||||||
|
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
|
||||||
|
value="{{ f.raw if f.raw is not none else '' }}">
|
||||||
|
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;padding:0 20px">
|
||||||
|
<table class="crud-table" id="users-editor">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Username</th><th>Display name</th><th>Avatar URL</th>
|
||||||
|
<th>Admin</th><th>Channels</th><th style="min-width:110px">New password</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="users-tbody">
|
||||||
|
{% for u in section.users %}
|
||||||
|
<tr data-user-row="true" data-username="{{ u.username | e }}">
|
||||||
|
<td style="font-family:monospace;font-size:.9em">{{ u.username | e }}</td>
|
||||||
|
<td><input class="field-input user-full-name" value="{{ u.full_name | e }}"></td>
|
||||||
|
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
|
||||||
|
<td style="min-width:120px">
|
||||||
|
{% for ch in all_channel_names %}
|
||||||
|
<label style="display:block;font-size:.82em;white-space:nowrap">
|
||||||
|
<input type="checkbox" class="user-ch" value="{{ ch | e }}" {% if ch in u.notification_channels %}checked{% endif %}> {{ ch | e }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
|
||||||
|
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="addUserRow()" style="margin-right:auto">+ Add user</button>
|
||||||
|
<button class="btn btn-primary" onclick="stageUsersSection()">Stage changes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- OAuth CRUD ---- #}
|
||||||
|
{% elif section.id == 'oauth' %}
|
||||||
|
<div style="overflow-x:auto;padding:0 20px">
|
||||||
|
<table class="crud-table" id="oauth-editor">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Name (slug)</th><th>Type</th><th>URL</th><th>Client ID</th>
|
||||||
|
<th>Client Secret</th><th>Label</th><th>Logo URL</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="oauth-tbody">
|
||||||
|
{% for p in section.providers %}
|
||||||
|
<tr data-oauth-row="true" data-name="{{ p.name | e }}">
|
||||||
|
<td style="font-family:monospace;font-size:.9em">{{ p.name | e }}</td>
|
||||||
|
<td>
|
||||||
|
<select class="field-input oauth-type">
|
||||||
|
{% for t in ['gitea', 'github', 'nextcloud'] %}
|
||||||
|
<option value="{{ t }}" {% if p.type == t %}selected{% endif %}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input class="field-input oauth-url" value="{{ p.url | e }}"></td>
|
||||||
|
<td><input class="field-input oauth-client-id" value="{{ p.client_id | e }}"></td>
|
||||||
|
<td><input type="password" class="field-input oauth-secret" value="{{ p.client_secret | e }}"></td>
|
||||||
|
<td><input class="field-input oauth-label" value="{{ p.label | e }}"></td>
|
||||||
|
<td><input class="field-input oauth-logo" value="{{ p.logo | e }}"></td>
|
||||||
|
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="addOAuthRow()" style="margin-right:auto">+ Add provider</button>
|
||||||
|
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- YAML editor section ---- #}
|
||||||
|
{% elif section.section_mode == 'yaml' %}
|
||||||
|
<div style="padding: 12px 20px">
|
||||||
|
<textarea id="yaml-{{ section.id }}" class="yaml-editor" rows="12"></textarea>
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:6px">
|
||||||
|
<button class="btn btn-secondary" onclick="loadYamlSection('{{ section.api_section }}', 'yaml-{{ section.id }}')">Reload from file</button>
|
||||||
|
<button class="btn btn-primary" onclick="stageYamlSection('{{ section.api_section }}', 'yaml-{{ section.id }}')">Stage changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Form section (generic fields) ---- #}
|
||||||
|
{% else %}
|
||||||
|
{% for f in section.fields %}
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-label">{{ f.label }}</div>
|
||||||
|
<div class="field-body">
|
||||||
|
{% if f.editable and section.api_section %}
|
||||||
|
{% if f.type == 'boolean' %}
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||||
|
<input type="checkbox" class="user-admin"
|
||||||
|
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
|
||||||
|
{% if f.value %}checked{% endif %}>
|
||||||
|
<span style="font-size:.88em">{{ 'Enabled' if f.value else 'Disabled' }}</span>
|
||||||
|
</label>
|
||||||
|
{% elif f.type in ('number', 'port', 'size') %}
|
||||||
|
<input type="number" class="field-input"
|
||||||
|
data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ section.api_section }}"
|
||||||
|
value="{{ f.raw if f.raw is not none else '' }}">
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="field-input"
|
||||||
|
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
|
||||||
|
value="{{ f.raw if f.raw is not none else '' }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||||||
|
{% elif f.sensitive %}
|
||||||
|
<div class="field-value"><span class="val-masked">••••••••</span></div>
|
||||||
|
{% elif f.type == 'boolean' %}
|
||||||
|
<div class="field-value">
|
||||||
|
<span class="val-boolean {{ 'on' if f.value else 'off' }}">{{ 'Enabled' if f.value else 'Disabled' }}</span>
|
||||||
|
</div>
|
||||||
|
{% elif f.type == 'list' %}
|
||||||
|
<div class="field-value">
|
||||||
|
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
|
||||||
|
{% else %}<span class="val-empty">None</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if f.description and not f.editable %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if section.api_section %}
|
||||||
|
<div class="section-footer">
|
||||||
|
<button class="btn btn-primary" onclick="stageFormSection('{{ section.id }}', '{{ section.api_section }}')">Stage changes</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>{# /settings-main #}
|
||||||
|
</div>{# /settings-layout #}
|
||||||
|
</div>{# /container #}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ---- Channel names for add-user row ----
|
||||||
|
const _allChannels = {{ all_channel_names | tojson }};
|
||||||
|
|
||||||
|
// ---- Staged changes accumulator ----
|
||||||
|
const _staged = {};
|
||||||
|
|
||||||
|
function updatePendingBanner() {
|
||||||
|
const count = Object.keys(_staged).length;
|
||||||
|
const banner = document.getElementById('pending-banner');
|
||||||
|
if (count > 0) {
|
||||||
|
document.getElementById('pending-count').textContent = count;
|
||||||
|
banner.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageFormSection(sectionId, apiSection) {
|
||||||
|
const section = document.getElementById(sectionId);
|
||||||
|
if (!_staged[apiSection] || typeof _staged[apiSection] !== 'object') {
|
||||||
|
_staged[apiSection] = {};
|
||||||
|
}
|
||||||
|
section.querySelectorAll('[data-key][data-section="' + apiSection + '"]').forEach(el => {
|
||||||
|
const key = el.dataset.key;
|
||||||
|
if (el.type === 'checkbox') {
|
||||||
|
_staged[apiSection][key] = el.checked;
|
||||||
|
} else if (el.dataset.type === 'number' || el.dataset.type === 'port') {
|
||||||
|
const v = parseInt(el.value, 10);
|
||||||
|
_staged[apiSection][key] = isNaN(v) ? null : v;
|
||||||
|
} else {
|
||||||
|
_staged[apiSection][key] = el.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updatePendingBanner();
|
||||||
|
flashStaged(sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageYamlSection(apiSection, textareaId) {
|
||||||
|
_staged[apiSection] = document.getElementById(textareaId).value;
|
||||||
|
updatePendingBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageUsersSection() {
|
||||||
|
const users = {};
|
||||||
|
document.querySelectorAll('[data-user-row]').forEach(row => {
|
||||||
|
if (row.dataset.deleted === 'true') return;
|
||||||
|
const username = row.dataset.username;
|
||||||
|
const entry = {
|
||||||
|
full_name: row.querySelector('.user-full-name').value,
|
||||||
|
avatar: row.querySelector('.user-avatar').value,
|
||||||
|
admin: row.querySelector('.user-admin').checked,
|
||||||
|
notification_channels: [...row.querySelectorAll('.user-ch:checked')].map(cb => cb.value),
|
||||||
|
};
|
||||||
|
const pw = row.querySelector('.user-password').value;
|
||||||
|
if (pw) entry.password = pw;
|
||||||
|
users[username] = entry;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-new-user]').forEach(row => {
|
||||||
|
if (row.dataset.deleted === 'true') return;
|
||||||
|
const uname = (row.querySelector('.new-username') || {value: ''}).value.trim();
|
||||||
|
if (!uname) return;
|
||||||
|
const entry = {
|
||||||
|
full_name: row.querySelector('.user-full-name').value,
|
||||||
|
avatar: row.querySelector('.user-avatar').value,
|
||||||
|
admin: row.querySelector('.user-admin').checked,
|
||||||
|
notification_channels: [...row.querySelectorAll('.user-ch:checked')].map(cb => cb.value),
|
||||||
|
};
|
||||||
|
const pw = row.querySelector('.user-password').value;
|
||||||
|
if (pw) entry.password = pw;
|
||||||
|
users[uname] = entry;
|
||||||
|
});
|
||||||
|
const defOwner = document.querySelector('[data-key="default_owner"]');
|
||||||
|
if (defOwner) {
|
||||||
|
if (!_staged['server']) _staged['server'] = {};
|
||||||
|
_staged['server']['default_owner'] = defOwner.value;
|
||||||
|
}
|
||||||
|
_staged['users'] = users;
|
||||||
|
updatePendingBanner();
|
||||||
|
flashStaged('users');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageOAuthSection() {
|
||||||
|
const oauth = {};
|
||||||
|
document.querySelectorAll('[data-oauth-row]').forEach(row => {
|
||||||
|
if (row.dataset.deleted === 'true') return;
|
||||||
|
let name = row.dataset.name;
|
||||||
|
if (!name) {
|
||||||
|
const ni = row.querySelector('.oauth-name-input');
|
||||||
|
if (ni) name = ni.value.trim();
|
||||||
|
}
|
||||||
|
if (!name) return;
|
||||||
|
const entry = {
|
||||||
|
type: row.querySelector('.oauth-type').value,
|
||||||
|
url: row.querySelector('.oauth-url').value,
|
||||||
|
client_id: row.querySelector('.oauth-client-id').value,
|
||||||
|
};
|
||||||
|
const label = row.querySelector('.oauth-label').value;
|
||||||
|
if (label) entry.label = label;
|
||||||
|
const logo = row.querySelector('.oauth-logo').value;
|
||||||
|
if (logo) entry.logo = logo;
|
||||||
|
const secret = row.querySelector('.oauth-secret').value;
|
||||||
|
if (secret && secret !== '•••') entry.client_secret = secret;
|
||||||
|
oauth[name] = entry;
|
||||||
|
});
|
||||||
|
_staged['oauth'] = oauth;
|
||||||
|
updatePendingBanner();
|
||||||
|
flashStaged('oauth');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishAll() {
|
||||||
|
const btn = document.querySelector('[onclick="publishAll()"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/0/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(_staged),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
alert('Error: ' + (err.error || resp.statusText));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Publish to .hb.yaml';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Network error: ' + e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Publish to .hb.yaml';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardAll() {
|
||||||
|
Object.keys(_staged).forEach(k => delete _staged[k]);
|
||||||
|
updatePendingBanner();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadYamlSection(apiSection, textareaId) {
|
||||||
|
const ta = document.getElementById(textareaId);
|
||||||
|
ta.value = 'Loading…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/0/config/section/' + apiSection);
|
||||||
|
const data = await resp.json();
|
||||||
|
ta.value = data.yaml || '';
|
||||||
|
} catch (e) {
|
||||||
|
ta.value = '# Error loading: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
|
||||||
|
const sectionId = ta.id.replace('yaml-', '');
|
||||||
|
const section = document.getElementById(sectionId);
|
||||||
|
if (section) {
|
||||||
|
const btn = section.querySelector('[onclick^="stageYamlSection"]');
|
||||||
|
if (btn) {
|
||||||
|
const m = btn.getAttribute('onclick').match(/stageYamlSection\('([^']+)'/);
|
||||||
|
if (m) loadYamlSection(m[1], ta.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleDeleteRow(btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
const deleted = row.dataset.deleted === 'true';
|
||||||
|
row.dataset.deleted = deleted ? 'false' : 'true';
|
||||||
|
row.style.opacity = deleted ? '1' : '0.4';
|
||||||
|
row.querySelectorAll('input, select').forEach(el => { el.disabled = !deleted; });
|
||||||
|
btn.textContent = deleted ? '✕' : '↩';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUserRow() {
|
||||||
|
const tbody = document.getElementById('users-tbody');
|
||||||
|
const chHtml = _allChannels.map(ch =>
|
||||||
|
`<label style="display:block;font-size:.82em;white-space:nowrap"><input type="checkbox" class="user-ch" value="${escHtml(ch)}"> ${escHtml(ch)}</label>`
|
||||||
|
).join('');
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.setAttribute('data-new-user', 'true');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input class="field-input new-username" placeholder="username" required></td>
|
||||||
|
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
|
||||||
|
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
|
||||||
|
<td>${chHtml}</td>
|
||||||
|
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
|
||||||
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOAuthRow() {
|
||||||
|
const tbody = document.getElementById('oauth-tbody');
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.setAttribute('data-oauth-row', 'true');
|
||||||
|
row.setAttribute('data-name', '');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input class="field-input oauth-name-input" placeholder="slug (e.g. gitea)"></td>
|
||||||
|
<td><select class="field-input oauth-type">
|
||||||
|
<option value="gitea">gitea</option>
|
||||||
|
<option value="github">github</option>
|
||||||
|
<option value="nextcloud">nextcloud</option>
|
||||||
|
</select></td>
|
||||||
|
<td><input class="field-input oauth-url" placeholder="https://…"></td>
|
||||||
|
<td><input class="field-input oauth-client-id" placeholder="client_id"></td>
|
||||||
|
<td><input type="password" class="field-input oauth-secret" placeholder="client_secret"></td>
|
||||||
|
<td><input class="field-input oauth-label" placeholder="Sign in with…"></td>
|
||||||
|
<td><input class="field-input oauth-logo" placeholder="/path/to/logo.png"></td>
|
||||||
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showRollbackModal() {
|
||||||
|
document.getElementById('rollback-modal').style.display = 'flex';
|
||||||
|
const el = document.getElementById('rollback-list');
|
||||||
|
el.innerHTML = 'Loading…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/0/config/backups');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.backups || !data.backups.length) {
|
||||||
|
el.innerHTML = '<p style="color:#888;font-size:.88em">No backups available.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = data.backups.map(b => {
|
||||||
|
const m = b.match(/\.bak\.(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
|
||||||
|
const label = m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : b;
|
||||||
|
const safe = b.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||||
|
return `<div class="backup-row"><span>${label}</span><button class="btn btn-secondary" style="font-size:.8em" onclick="doRollback('${safe}')">Restore</button></div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = '<p style="color:#c62828">Error loading backups: ' + e.message + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRollbackModal() {
|
||||||
|
document.getElementById('rollback-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRollback(backupPath) {
|
||||||
|
if (!confirm('Restore this backup? The current config will be backed up first.')) return;
|
||||||
|
const resp = await fetch('/api/0/config/rollback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({backup: backupPath}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
closeRollbackModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
alert('Rollback failed: ' + (err.error || resp.statusText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashStaged(sectionId) {
|
||||||
|
const sec = document.getElementById(sectionId);
|
||||||
|
if (!sec) return;
|
||||||
|
sec.style.outline = '2px solid #e8c840';
|
||||||
|
setTimeout(() => { sec.style.outline = ''; }, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight sidebar link for the section currently in view
|
||||||
|
const sections = document.querySelectorAll('.section');
|
||||||
|
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const id = entry.target.id;
|
||||||
|
navLinks.forEach(a => {
|
||||||
|
a.classList.toggle('active', a.getAttribute('href') === '#' + id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.25 });
|
||||||
|
|
||||||
|
sections.forEach(s => observer.observe(s));
|
||||||
|
|
||||||
|
// Collapsible sidebar on mobile
|
||||||
|
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||||||
|
var sidebarNav = document.getElementById('sidebar-nav');
|
||||||
|
if (sidebarToggle && sidebarNav) {
|
||||||
|
sidebarToggle.addEventListener('click', function() {
|
||||||
|
var open = sidebarNav.classList.toggle('open');
|
||||||
|
sidebarToggle.classList.toggle('open', open);
|
||||||
|
sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
var sidebarNav = document.getElementById('sidebar-nav');
|
||||||
|
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||||||
|
if (sidebarNav) { sidebarNav.classList.remove('open'); }
|
||||||
|
if (sidebarToggle) {
|
||||||
|
sidebarToggle.classList.remove('open');
|
||||||
|
sidebarToggle.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+630
-200
File diff suppressed because it is too large
Load Diff
+289
-78
@@ -1,9 +1,14 @@
|
|||||||
"""UDP listener and datagram processing."""
|
"""UDP listener and datagram processing."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
import zlib
|
import zlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from platform import system as platform_system
|
||||||
|
|
||||||
from ..common.proto import stodict, oldmtodict
|
from ..common.proto import stodict, oldmtodict
|
||||||
from ..common.utils import dur
|
from ..common.utils import dur
|
||||||
from . import notify as notify_mod
|
from . import notify as notify_mod
|
||||||
@@ -11,6 +16,108 @@ from . import notify as notify_mod
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
eventlog = notify_mod.eventlog
|
eventlog = notify_mod.eventlog
|
||||||
|
|
||||||
|
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
|
||||||
|
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
|
||||||
|
# Python's socket module on all platforms
|
||||||
|
platform = platform_system()
|
||||||
|
if platform == "Darwin":
|
||||||
|
_SO_TIMESTAMP = 1024 # SO_TIMESTAMP on macOS (not in Python's socket module)
|
||||||
|
elif platform == "Linux":
|
||||||
|
_SO_TIMESTAMP = 29 # Linux value (not in older Python versions)
|
||||||
|
elif platform == "FreeBSD":
|
||||||
|
_SO_TIMESTAMP = 32 # FreeBSD value (not in older Python versions)
|
||||||
|
else:
|
||||||
|
logger.warning("SO_TIMESTAMP may not be supported on this platform (%s)", platform)
|
||||||
|
_SO_TIMESTAMP = None
|
||||||
|
|
||||||
|
# struct timeval uses two native C longs: tv_sec and tv_usec
|
||||||
|
_TIMEVAL = struct.Struct('@ll')
|
||||||
|
|
||||||
|
|
||||||
|
def enable_kernel_timestamps(sock) -> bool:
|
||||||
|
"""Try to enable SO_TIMESTAMP on *sock*.
|
||||||
|
|
||||||
|
Returns True if the kernel will supply receive timestamps, False otherwise
|
||||||
|
(unsupported platform, older kernel, or insufficient permissions).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, _SO_TIMESTAMP, 1)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_kernel_ts(ancdata) -> float | None:
|
||||||
|
"""Parse recvmsg ancillary data and return the kernel receive time.
|
||||||
|
|
||||||
|
Returns seconds as a float, or None if no SO_TIMESTAMP cmsg is present.
|
||||||
|
"""
|
||||||
|
for cmsg_level, cmsg_type, cmsg_data in ancdata:
|
||||||
|
if cmsg_level == socket.SOL_SOCKET and cmsg_type == _SO_TIMESTAMP:
|
||||||
|
if len(cmsg_data) >= _TIMEVAL.size:
|
||||||
|
sec, usec = _TIMEVAL.unpack_from(cmsg_data)
|
||||||
|
return sec + usec * 1e-6
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RecvmsgTransport:
|
||||||
|
"""Thin wrapper used when SO_TIMESTAMP is active (add_reader path).
|
||||||
|
|
||||||
|
Exposes the same sendto() / close() interface as asyncio's DatagramTransport
|
||||||
|
so the rest of the code does not need to know which path is in use.
|
||||||
|
"""
|
||||||
|
def __init__(self, loop, sock):
|
||||||
|
self._loop = loop
|
||||||
|
self._sock = sock
|
||||||
|
|
||||||
|
def sendto(self, data, addr):
|
||||||
|
try:
|
||||||
|
self._sock.sendto(data, addr)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("sendto failed: %s", e)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self._loop.remove_reader(self._sock.fileno())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self._sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_recvmsg_reader(sock, handler, transport):
|
||||||
|
"""Return a callback suitable for loop.add_reader().
|
||||||
|
|
||||||
|
Reads one datagram per call using recvmsg() so that kernel timestamps in
|
||||||
|
the ancillary data are accessible. Falls back to time.time() if the
|
||||||
|
cmsg is missing.
|
||||||
|
|
||||||
|
handler(msg, addr, transport, kernel_ts) – same signature as udp_handler
|
||||||
|
in main.py with the optional kernel_ts argument.
|
||||||
|
"""
|
||||||
|
BUFSIZE = 65536
|
||||||
|
ANCBUFSIZE = 128 # enough for one struct timespec cmsg
|
||||||
|
|
||||||
|
def _read():
|
||||||
|
try:
|
||||||
|
data, ancdata, _, addr = sock.recvmsg(BUFSIZE, ANCBUFSIZE)
|
||||||
|
except BlockingIOError:
|
||||||
|
return
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("recvmsg error: %s", e)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
kernel_ts = _extract_kernel_ts(ancdata)
|
||||||
|
msg = parse_message(data)
|
||||||
|
if msg:
|
||||||
|
handler(msg, addr, transport, kernel_ts)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error processing datagram from %s", addr)
|
||||||
|
|
||||||
|
return _read
|
||||||
|
|
||||||
|
|
||||||
class EchoServerProtocol(asyncio.DatagramProtocol):
|
class EchoServerProtocol(asyncio.DatagramProtocol):
|
||||||
def __init__(self, config=None, handler=None):
|
def __init__(self, config=None, handler=None):
|
||||||
@@ -61,6 +168,126 @@ def dicttos(ID, d):
|
|||||||
return opk
|
return opk
|
||||||
|
|
||||||
|
|
||||||
|
DROPOVERDUE = 7 * 24 * 3600 # seconds before an overdue host becomes UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def _set_connectivity_alert(host, afam, level_name):
|
||||||
|
"""Update (or clear) a connectivity alert_state entry for a host/address-family.
|
||||||
|
|
||||||
|
level_name is "CRITICAL", "WARNING", or "OK". "OK" removes the entry so
|
||||||
|
that recovered hosts don't clutter the Alerts Dashboard.
|
||||||
|
"""
|
||||||
|
from .threshold import AlertState, AlertLevel
|
||||||
|
metric_path = f"connectivity.{afam}"
|
||||||
|
level = getattr(AlertLevel, level_name, AlertLevel.OK)
|
||||||
|
if level == AlertLevel.OK:
|
||||||
|
host.alert_states.pop(metric_path, None)
|
||||||
|
return
|
||||||
|
if metric_path not in host.alert_states:
|
||||||
|
host.alert_states[metric_path] = AlertState(metric_path)
|
||||||
|
state = host.alert_states[metric_path]
|
||||||
|
state.update(level, level_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_timer_callbacks(uname, host, ctx):
|
||||||
|
"""Return (on_overdue, on_unknown) async callbacks for connection timer logic.
|
||||||
|
|
||||||
|
Captured values are bound at call time so callbacks are safe to use in loops.
|
||||||
|
"""
|
||||||
|
msg_to_websockets = ctx.get("msg_to_websockets")
|
||||||
|
threshold_checker = ctx.get("threshold_checker")
|
||||||
|
cfg = ctx.get("config", {})
|
||||||
|
|
||||||
|
async def on_unknown(connection):
|
||||||
|
connection.newstate(connection.__class__.UNKNOWN, connection.lastbeat)
|
||||||
|
# Keep connectivity alert active when host transitions to unknown
|
||||||
|
if msg_to_websockets:
|
||||||
|
msg_to_websockets("host", host.stateinfo())
|
||||||
|
|
||||||
|
async def on_overdue(connection):
|
||||||
|
if connection.getstate() != connection.__class__.UP:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
connection.newstate(connection.__class__.OVERDUE, now, cfg.get("grace", 2))
|
||||||
|
msg = f"{connection.afam} overdue"
|
||||||
|
eventlog(uname, "CRITICAL", msg)
|
||||||
|
if host.watched:
|
||||||
|
asyncio.create_task(notify_mod.send_notification(
|
||||||
|
uname,
|
||||||
|
notify_mod.Notification(title=f"[CRITICAL] {uname}", body=msg, level="CRITICAL"),
|
||||||
|
))
|
||||||
|
# Track in alert_states so the Alerts Dashboard shows this
|
||||||
|
_set_connectivity_alert(host, connection.afam, "CRITICAL")
|
||||||
|
if threshold_checker:
|
||||||
|
threshold_checker.check_value(
|
||||||
|
host_name=uname,
|
||||||
|
metric_path="rtt",
|
||||||
|
value=float("inf"),
|
||||||
|
alert_states=host.alert_states,
|
||||||
|
)
|
||||||
|
if msg_to_websockets:
|
||||||
|
msg_to_websockets("host", host.stateinfo())
|
||||||
|
connection.reset_overdue_timer(DROPOVERDUE, on_unknown)
|
||||||
|
|
||||||
|
return on_overdue, on_unknown
|
||||||
|
|
||||||
|
|
||||||
|
def restore_connection_timers(hbdclass, ctx):
|
||||||
|
"""Restore overdue timers for all loaded connections after a pickle restore.
|
||||||
|
|
||||||
|
For UP connections, the remaining time until overdue is calculated from
|
||||||
|
lastbeat so that clients that vanished during hbd's downtime are detected.
|
||||||
|
For OVERDUE connections, the UNKNOWN drop timer is restored.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
cfg = ctx.get("config", {})
|
||||||
|
grace = cfg.get("grace", 2)
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
for uname, host in list(hbdclass.Host.hosts.items()):
|
||||||
|
interval = host.interval
|
||||||
|
for afam, conn in list(host.connections.items()):
|
||||||
|
state = conn.getstate()
|
||||||
|
if state == hbdclass.Connection.DOWN:
|
||||||
|
continue
|
||||||
|
|
||||||
|
on_overdue, on_unknown = _make_timer_callbacks(uname, host, ctx)
|
||||||
|
|
||||||
|
if state == hbdclass.Connection.UP and interval > 0:
|
||||||
|
elapsed = now - conn.lastbeat
|
||||||
|
# Give hosts one full (interval + grace) of extra time on startup
|
||||||
|
# so hosts that were silent while hbd was down are not immediately
|
||||||
|
# flagged as overdue before they have a chance to check in.
|
||||||
|
startup_grace = interval + grace
|
||||||
|
remaining = max(startup_grace, 2 * startup_grace - elapsed)
|
||||||
|
conn.reset_overdue_timer(remaining, on_overdue)
|
||||||
|
logger.debug(
|
||||||
|
"Restored UP timer %s/%s: %.0fs remaining (elapsed %.0fs, startup grace %.0fs)",
|
||||||
|
uname, afam, remaining, elapsed, startup_grace,
|
||||||
|
)
|
||||||
|
restored += 1
|
||||||
|
|
||||||
|
elif state == hbdclass.Connection.OVERDUE:
|
||||||
|
elapsed_overdue = now - conn.statetime
|
||||||
|
remaining = DROPOVERDUE - elapsed_overdue
|
||||||
|
if remaining <= 1:
|
||||||
|
# Already past the drop window — mark UNKNOWN immediately
|
||||||
|
conn.newstate(hbdclass.Connection.UNKNOWN, conn.lastbeat)
|
||||||
|
logger.info(
|
||||||
|
"Marking %s/%s UNKNOWN (overdue %.1f days)",
|
||||||
|
uname, afam, elapsed_overdue / 86400,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.reset_overdue_timer(remaining, on_unknown)
|
||||||
|
logger.debug(
|
||||||
|
"Restored OVERDUE timer %s/%s: %.0fs remaining",
|
||||||
|
uname, afam, remaining,
|
||||||
|
)
|
||||||
|
restored += 1
|
||||||
|
|
||||||
|
logger.info("Restored timers for %d connection(s)", restored)
|
||||||
|
|
||||||
|
|
||||||
def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||||
"""Handle a parsed datagram message.
|
"""Handle a parsed datagram message.
|
||||||
|
|
||||||
@@ -74,7 +301,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
"""
|
"""
|
||||||
if not msg:
|
if not msg:
|
||||||
return
|
return
|
||||||
now = __import__("time").time()
|
now = ctx.get("recv_ts") or time.time()
|
||||||
|
|
||||||
# Log message to journal
|
# Log message to journal
|
||||||
msg_journal = ctx.get("msg_journal")
|
msg_journal = ctx.get("msg_journal")
|
||||||
@@ -89,7 +316,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
|
|
||||||
cfg = ctx.get("config", {})
|
cfg = ctx.get("config", {})
|
||||||
hbdcls = ctx.get("hbdclass")
|
hbdcls = ctx.get("hbdclass")
|
||||||
log = ctx.get("log")
|
|
||||||
msg_to_websockets = ctx.get("msg_to_websockets")
|
msg_to_websockets = ctx.get("msg_to_websockets")
|
||||||
DEBUG = ctx.get("DEBUG", 0)
|
DEBUG = ctx.get("DEBUG", 0)
|
||||||
verbose = ctx.get("verbose", False)
|
verbose = ctx.get("verbose", False)
|
||||||
@@ -107,16 +333,15 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
# Use new config function to check dyndns
|
# Use new config function to check dyndns
|
||||||
dyndnshosts = config_mod.get_dyndnshosts(cfg)
|
dyndnshosts = config_mod.get_dyndnshosts(cfg)
|
||||||
host.dyn = uname in dyndnshosts
|
host.dyn = uname in dyndnshosts
|
||||||
if verbose:
|
# Apply user-access settings from config
|
||||||
print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts))))
|
access = config_mod.get_host_access(cfg, uname)
|
||||||
|
host.apply_access(access["owner"], access["managers"], access["monitors"])
|
||||||
|
logger.info("New host signed on: %s (dyn=%s, access=%s)", uname, host.dyn, access)
|
||||||
newh = True
|
newh = True
|
||||||
else:
|
else:
|
||||||
host = hbdcls.Host.hosts[uname]
|
host = hbdcls.Host.hosts[uname]
|
||||||
newh = False
|
newh = False
|
||||||
|
|
||||||
# Get watchhosts once for use throughout message handling
|
|
||||||
watchhosts = config_mod.get_watchhosts(cfg)
|
|
||||||
|
|
||||||
cid = msg.get("id", 0)
|
cid = msg.get("id", 0)
|
||||||
try:
|
try:
|
||||||
rtt = float(msg.get("rtt"))
|
rtt = float(msg.get("rtt"))
|
||||||
@@ -125,8 +350,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
|
|
||||||
if msg.get("ID") == "HTB":
|
if msg.get("ID") == "HTB":
|
||||||
host.doesack = msg.get("acks", -1)
|
host.doesack = msg.get("acks", -1)
|
||||||
# send ACK back
|
# send ACK back; ask client to resend plugin info when we have none yet
|
||||||
rmsg = {"time": __import__("time").time()}
|
rmsg = {"time": time.time()}
|
||||||
|
if not host.plugin_data:
|
||||||
|
rmsg["request_update"] = 1
|
||||||
opkt = dicttos("ACK", rmsg)
|
opkt = dicttos("ACK", rmsg)
|
||||||
try:
|
try:
|
||||||
transport.sendto(opkt, addr)
|
transport.sendto(opkt, addr)
|
||||||
@@ -138,10 +365,19 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
# Handle plugin data message
|
# Handle plugin data message
|
||||||
plugin_name = msg.get("plugin")
|
plugin_name = msg.get("plugin")
|
||||||
if plugin_name:
|
if plugin_name:
|
||||||
# Extract all fields except ID and plugin name
|
# Extract plugin fields, dropping protocol metadata fields
|
||||||
plugin_data = {k: v for k, v in msg.items() if k not in ["ID", "plugin"]}
|
plugin_data = {k: v for k, v in msg.items()
|
||||||
|
if k not in ("ID", "plugin", "id", "name")}
|
||||||
# Store plugin data with timestamp
|
# Store plugin data with timestamp
|
||||||
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
||||||
|
|
||||||
|
# If os_info reports an owner and none is configured server-side, apply it
|
||||||
|
if plugin_name == "os_info":
|
||||||
|
config_owner = config_mod.get_host_access(cfg, uname).get("owner")
|
||||||
|
default_owner = config_mod.get_default_owner(cfg)
|
||||||
|
inferred_owner = plugin_data.get("owner", config_owner or default_owner)
|
||||||
|
host.owner = inferred_owner
|
||||||
|
logger.info(f"owner for {uname} is '{host.owner}")
|
||||||
if DEBUG > 1:
|
if DEBUG > 1:
|
||||||
print(f"Stored plugin data for {uname}: {plugin_name}")
|
print(f"Stored plugin data for {uname}: {plugin_name}")
|
||||||
|
|
||||||
@@ -181,8 +417,11 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
|
|
||||||
if res:
|
if res:
|
||||||
eventlog(uname, "WARNING", res)
|
eventlog(uname, "WARNING", res)
|
||||||
if uname in watchhosts:
|
if host.watched:
|
||||||
notify_mod.pushmsg_for_host(uname, "%s %s" % (host.name, res))
|
asyncio.create_task(notify_mod.send_notification(
|
||||||
|
uname,
|
||||||
|
notify_mod.Notification(title=f"[WARNING] {uname}", body=res, level="WARNING"),
|
||||||
|
))
|
||||||
|
|
||||||
interval = int(msg.get("interval", 0) or 0)
|
interval = int(msg.get("interval", 0) or 0)
|
||||||
shutdown = msg.get("shutdown", 0)
|
shutdown = msg.get("shutdown", 0)
|
||||||
@@ -192,24 +431,36 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
|
|
||||||
if boot:
|
if boot:
|
||||||
eventlog(uname, "INFO", "booted")
|
eventlog(uname, "INFO", "booted")
|
||||||
if uname in watchhosts:
|
if host.watched:
|
||||||
m = "%s booted" % (host.name)
|
asyncio.create_task(notify_mod.send_notification(
|
||||||
notify_mod.pushmsg_for_host(uname, m)
|
uname,
|
||||||
|
notify_mod.Notification(title=f"[INFO] {uname}", body=f"{host.name} booted", level="INFO"),
|
||||||
|
))
|
||||||
if message:
|
if message:
|
||||||
eventlog(uname, "INFO", "msg: %s" % message, service=service)
|
eventlog(uname, "INFO", "msg: %s" % message, service=service)
|
||||||
if uname in watchhosts:
|
|
||||||
notify_mod.pushmsg_for_host(uname, message)
|
|
||||||
|
|
||||||
if conn.getstate() != hbdcls.Connection.UP:
|
if conn.getstate() != hbdcls.Connection.UP:
|
||||||
lasts = conn.state
|
lasts = conn.state
|
||||||
d = conn.newstate(hbdcls.Connection.UP, now)
|
d = conn.newstate(hbdcls.Connection.UP, now)
|
||||||
if d == 0 or lasts == "unknown":
|
# Clear connectivity alert now that the host is back up
|
||||||
m = "%s is up" % (conn.afam)
|
_set_connectivity_alert(host, conn.afam, "OK")
|
||||||
else:
|
# Don't log/notify RECOVER for a brand-new host seen for the first time —
|
||||||
m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d))
|
# it was never down, it just hasn't been seen before.
|
||||||
eventlog(uname, "RECOVER", m)
|
if not newh:
|
||||||
if uname in watchhosts:
|
if d == 0 or lasts == "unknown":
|
||||||
notify_mod.pushmsg_for_host(uname, "%s %s is back" % (uname, conn.afam))
|
m = "%s is up" % (conn.afam)
|
||||||
|
elif d < 4:
|
||||||
|
# Transient blip (likely client restart) — skip log and notification
|
||||||
|
m = None
|
||||||
|
else:
|
||||||
|
m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d))
|
||||||
|
if m:
|
||||||
|
eventlog(uname, "RECOVER", m)
|
||||||
|
if host.watched:
|
||||||
|
asyncio.create_task(notify_mod.send_notification(
|
||||||
|
uname,
|
||||||
|
notify_mod.Notification(title=f"[RECOVER] {uname}", body=m, level="RECOVER"),
|
||||||
|
))
|
||||||
|
|
||||||
if boot or newh:
|
if boot or newh:
|
||||||
host.upcount = host.doesack
|
host.upcount = host.doesack
|
||||||
@@ -217,63 +468,25 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
host.upcount += 1
|
host.upcount += 1
|
||||||
|
|
||||||
if shutdown:
|
if shutdown:
|
||||||
eventlog(uname, "INFO", "%s shutdown" % conn.afam)
|
m = "%s shutdown" % conn.afam
|
||||||
if uname in watchhosts:
|
eventlog(uname, "INFO", m)
|
||||||
notify_mod.pushmsg_for_host(uname, "%s %s shutdown" % (uname, conn.afam))
|
if host.watched:
|
||||||
|
asyncio.create_task(notify_mod.send_notification(
|
||||||
|
uname,
|
||||||
|
notify_mod.Notification(title=f"[INFO] {uname}", body=m, level="INFO"),
|
||||||
|
))
|
||||||
conn.newstate(hbdcls.Connection.DOWN, now)
|
conn.newstate(hbdcls.Connection.DOWN, now)
|
||||||
|
_set_connectivity_alert(host, conn.afam, "CRITICAL")
|
||||||
|
|
||||||
if interval > 0:
|
if interval > 0:
|
||||||
host.interval = interval
|
host.interval = interval
|
||||||
|
|
||||||
# Timer-based reachability monitoring
|
# Timer-based reachability monitoring
|
||||||
# Reset overdue timer on every heartbeat
|
# Reset overdue timer on every heartbeat
|
||||||
if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN:
|
if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN:
|
||||||
grace = cfg.get("grace", 2)
|
grace = cfg.get("grace", 2)
|
||||||
timeout_seconds = (interval + grace) if interval > 0 else 30
|
timeout_seconds = interval + grace
|
||||||
|
on_overdue, _ = _make_timer_callbacks(uname, host, ctx)
|
||||||
# Create callback for timer expiration
|
|
||||||
async def on_overdue(connection):
|
|
||||||
"""Called when connection timer expires (no heartbeat received)."""
|
|
||||||
import time
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
# Only mark as overdue if still in UP state (not already marked)
|
|
||||||
if connection.getstate() == hbdcls.Connection.UP:
|
|
||||||
connection.newstate(hbdcls.Connection.OVERDUE, now, cfg.get("grace", 2))
|
|
||||||
|
|
||||||
msg = f"{connection.afam} overdue"
|
|
||||||
eventlog(uname, "CRITICAL" if uname in watchhosts else "WARNING", msg)
|
|
||||||
|
|
||||||
if uname in watchhosts:
|
|
||||||
notify_mod.pushmsg_for_host(uname, f"{uname} {msg}")
|
|
||||||
|
|
||||||
# Check RTT thresholds with infinite RTT for overdue hosts
|
|
||||||
threshold_checker = ctx.get("threshold_checker")
|
|
||||||
if threshold_checker:
|
|
||||||
metric_path = "rtt"
|
|
||||||
threshold_checker.check_value(
|
|
||||||
host_name=uname,
|
|
||||||
metric_path=metric_path,
|
|
||||||
value=float('inf'),
|
|
||||||
alert_states=host.alert_states
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify websockets
|
|
||||||
if msg_to_websockets:
|
|
||||||
msg_to_websockets("host", host.stateinfo())
|
|
||||||
|
|
||||||
# Set a longer timer for marking as UNKNOWN (7 days)
|
|
||||||
DROPOVERDUE = 7 * 24 * 3600
|
|
||||||
|
|
||||||
async def on_unknown(connection):
|
|
||||||
"""Mark connection as unknown after extended absence."""
|
|
||||||
connection.newstate(hbdcls.Connection.UNKNOWN, connection.lastbeat)
|
|
||||||
if msg_to_websockets:
|
|
||||||
msg_to_websockets("host", host.stateinfo())
|
|
||||||
|
|
||||||
connection.reset_overdue_timer(DROPOVERDUE, on_unknown)
|
|
||||||
|
|
||||||
# Reset the timer
|
|
||||||
conn.reset_overdue_timer(timeout_seconds, on_overdue)
|
conn.reset_overdue_timer(timeout_seconds, on_overdue)
|
||||||
|
|
||||||
# Check RTT thresholds using the threshold checker
|
# Check RTT thresholds using the threshold checker
|
||||||
@@ -295,12 +508,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
op, rmsg = host.cmds[0]
|
op, rmsg = host.cmds[0]
|
||||||
if op == "CMD":
|
if op == "CMD":
|
||||||
del host.cmds[0]
|
del host.cmds[0]
|
||||||
if log:
|
eventlog(uname, "INFO", "command sent")
|
||||||
log(uname, "command sent")
|
|
||||||
elif op == "UPD":
|
elif op == "UPD":
|
||||||
del host.cmds[0]
|
del host.cmds[0]
|
||||||
if log:
|
eventlog(uname, "INFO", "update initiated")
|
||||||
log(uname, "update initiated")
|
|
||||||
opkt = dicttos(op, rmsg)
|
opkt = dicttos(op, rmsg)
|
||||||
try:
|
try:
|
||||||
transport.sendto(opkt, addr)
|
transport.sendto(opkt, addr)
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""User management: loading, authentication, and session tracking.
|
||||||
|
|
||||||
|
Users are defined in the config file under the ``users`` key:
|
||||||
|
|
||||||
|
users:
|
||||||
|
alice:
|
||||||
|
full_name: Alice Smith
|
||||||
|
avatar: /path/to/avatar.png # file path, URL, or base64 data URI
|
||||||
|
password: pbkdf2:sha256:... # generated with: hbd passwd
|
||||||
|
admin: true # optional server-level admin
|
||||||
|
notification_channels: [pushover_standard]
|
||||||
|
|
||||||
|
Roles are assigned per-host:
|
||||||
|
|
||||||
|
hosts:
|
||||||
|
webserver01:
|
||||||
|
owner: alice
|
||||||
|
managers: [bob]
|
||||||
|
monitors: [carol]
|
||||||
|
|
||||||
|
If no users are defined the server runs in unauthenticated mode (backwards
|
||||||
|
compatible). When users are defined every API call must carry a valid session
|
||||||
|
token in an ``Authorization: Bearer <token>`` or ``X-Auth-Token`` header,
|
||||||
|
obtained via ``POST /api/0/auth/login``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Session lifetime in seconds (24 hours).
|
||||||
|
SESSION_TTL = 86400
|
||||||
|
|
||||||
|
# Global session store: token -> {"username": str, "expires": float, "created": float}
|
||||||
|
_sessions: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class User:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
full_name: str = "",
|
||||||
|
avatar: str = "",
|
||||||
|
password_hash: str = "",
|
||||||
|
admin: bool = False,
|
||||||
|
notification_channels: list | None = None,
|
||||||
|
):
|
||||||
|
self.username = username
|
||||||
|
self.full_name = full_name
|
||||||
|
self.avatar = avatar
|
||||||
|
self.password_hash = password_hash
|
||||||
|
self.admin = admin
|
||||||
|
self.notification_channels: list = notification_channels or []
|
||||||
|
|
||||||
|
def check_password(self, password: str) -> bool:
|
||||||
|
if not self.password_hash:
|
||||||
|
return False
|
||||||
|
return _verify_password(password, self.password_hash)
|
||||||
|
|
||||||
|
def avatar_is_local(self) -> bool:
|
||||||
|
"""Return True when the avatar is a local filesystem path (starts with '/')."""
|
||||||
|
return bool(self.avatar and self.avatar.startswith("/"))
|
||||||
|
|
||||||
|
def avatar_url(self) -> str:
|
||||||
|
"""Return the URL to use as an <img src>.
|
||||||
|
|
||||||
|
Local file paths are served via the /api/0/users/{username}/avatar
|
||||||
|
endpoint. External URLs and data URIs are returned as-is.
|
||||||
|
"""
|
||||||
|
if self.avatar_is_local():
|
||||||
|
return f"/api/0/users/{self.username}/avatar"
|
||||||
|
return self.avatar
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"username": self.username,
|
||||||
|
"full_name": self.full_name,
|
||||||
|
"avatar": self.avatar,
|
||||||
|
"avatar_url": self.avatar_url(),
|
||||||
|
"admin": self.admin,
|
||||||
|
"notification_channels": self.notification_channels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Password hashing (PBKDF2-HMAC-SHA256, stdlib only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Return a storable hash for *password*.
|
||||||
|
|
||||||
|
Format: ``pbkdf2:sha256:<iterations>:<salt>:<hex-digest>``
|
||||||
|
|
||||||
|
Use this to generate the ``password`` value in the config file::
|
||||||
|
|
||||||
|
python -c "from hbd.server.users import hash_password; print(hash_password('secret'))"
|
||||||
|
|
||||||
|
Or via the CLI::
|
||||||
|
|
||||||
|
hbd passwd
|
||||||
|
"""
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
iterations = 260_000
|
||||||
|
dk = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations
|
||||||
|
)
|
||||||
|
return f"pbkdf2:sha256:{iterations}:{salt}:{dk.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_password(password: str, stored_hash: str) -> bool:
|
||||||
|
"""Return True if *password* matches *stored_hash*."""
|
||||||
|
try:
|
||||||
|
parts = stored_hash.split(":")
|
||||||
|
if len(parts) != 5 or parts[0] != "pbkdf2" or parts[1] != "sha256":
|
||||||
|
return False
|
||||||
|
_, _, iterations_str, salt, expected_hex = parts
|
||||||
|
iterations = int(iterations_str)
|
||||||
|
dk = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(dk.hex(), expected_hex)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Global user registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# username -> User
|
||||||
|
users: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_users(config: dict) -> dict:
|
||||||
|
"""Populate the global user registry from *config*.
|
||||||
|
|
||||||
|
Called once at startup and again on SIGHUP config reload.
|
||||||
|
Returns the new ``users`` dict.
|
||||||
|
"""
|
||||||
|
global users
|
||||||
|
old_users = dict(users) # snapshot before rebuild
|
||||||
|
users_cfg = config.get("users", {})
|
||||||
|
if not isinstance(users_cfg, dict):
|
||||||
|
users = {}
|
||||||
|
# Preserve OAuth-provisioned users (password_hash == "") that aren't in config.
|
||||||
|
for username, existing_user in old_users.items():
|
||||||
|
if not existing_user.password_hash and username not in users:
|
||||||
|
users[username] = existing_user
|
||||||
|
return users
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
for username, attrs in users_cfg.items():
|
||||||
|
if not isinstance(attrs, dict):
|
||||||
|
logger.warning("Skipping user %r: expected a mapping", username)
|
||||||
|
continue
|
||||||
|
result[username] = User(
|
||||||
|
username=username,
|
||||||
|
full_name=attrs.get("full_name", ""),
|
||||||
|
avatar=attrs.get("avatar", ""),
|
||||||
|
password_hash=attrs.get("password", ""),
|
||||||
|
admin=bool(attrs.get("admin", False)),
|
||||||
|
notification_channels=attrs.get("notification_channels", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
users = result
|
||||||
|
# Preserve OAuth-provisioned users (password_hash == "") that aren't in config.
|
||||||
|
for username, existing_user in old_users.items():
|
||||||
|
if not existing_user.password_hash and username not in users:
|
||||||
|
users[username] = existing_user
|
||||||
|
logger.info("Loaded %d user(s) from config", len(users))
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
def users_enabled() -> bool:
|
||||||
|
"""Return True if at least one user is configured (auth-required mode)."""
|
||||||
|
return bool(users)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(username: str) -> "User | None":
|
||||||
|
return users.get(username)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(username: str, password: str) -> "User | None":
|
||||||
|
"""Return the User if credentials are valid, else None."""
|
||||||
|
user = users.get(username)
|
||||||
|
if user and user.check_password(password):
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_session(username: str) -> str:
|
||||||
|
"""Create a new session for *username* and return the opaque token."""
|
||||||
|
_purge_expired_sessions()
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
_sessions[token] = {
|
||||||
|
"username": username,
|
||||||
|
"expires": time.time() + SESSION_TTL,
|
||||||
|
"created": time.time(),
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_user(token: str) -> "User | None":
|
||||||
|
"""Return the User for a valid *token*, or None if missing/expired."""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
session = _sessions.get(token)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
if session["expires"] < time.time():
|
||||||
|
del _sessions[token]
|
||||||
|
return None
|
||||||
|
return get_user(session["username"])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_session(token: str) -> None:
|
||||||
|
"""Invalidate *token* (logout)."""
|
||||||
|
_sessions.pop(token, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_expired_sessions() -> None:
|
||||||
|
now = time.time()
|
||||||
|
expired = [t for t, s in list(_sessions.items()) if s["expires"] < now]
|
||||||
|
for t in expired:
|
||||||
|
del _sessions[t]
|
||||||
|
|
||||||
|
|
||||||
|
def save_sessions() -> dict:
|
||||||
|
"""Return a snapshot of non-expired sessions suitable for pickling."""
|
||||||
|
_purge_expired_sessions()
|
||||||
|
return dict(_sessions)
|
||||||
|
|
||||||
|
|
||||||
|
def load_sessions(snapshot: dict) -> None:
|
||||||
|
"""Restore sessions from a pickled snapshot, dropping any that have expired."""
|
||||||
|
global _sessions
|
||||||
|
now = time.time()
|
||||||
|
_sessions = {t: s for t, s in snapshot.items() if s.get("expires", 0) > now}
|
||||||
|
logger.debug("Restored %d session(s) from pickle", len(_sessions))
|
||||||
+128
-125
@@ -1,7 +1,8 @@
|
|||||||
"""WebSocket server and broadcast helpers for hbd.
|
"""WebSocket handler and broadcast helpers for hbd.
|
||||||
|
|
||||||
Provides an asyncio-based WebSocket server and a thread-safe broadcast
|
WebSocket connections are served through the regular HTTP port via the
|
||||||
function that other threads or synchronous code can call.
|
/ws route registered in http.py (aiohttp WebSocketResponse upgrade).
|
||||||
|
The separate standalone WebSocket server on ws_port is no longer used.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -10,144 +11,146 @@ import logging
|
|||||||
from typing import Callable, Iterable, Optional
|
from typing import Callable, Iterable, Optional
|
||||||
from . import data
|
from . import data
|
||||||
|
|
||||||
import websockets
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
_connections = set()
|
# Map of WebSocket → User object (or None when auth is disabled)
|
||||||
|
_connections: dict = {}
|
||||||
_loop: Optional[asyncio.AbstractEventLoop] = None
|
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
_get_hosts: Optional[Callable[[], Iterable]] = None
|
_get_hosts: Optional[Callable[[], Iterable]] = None
|
||||||
#_get_msgs: Optional[Callable[[], Iterable]] = None
|
_verbose: bool = False
|
||||||
_verbose = False
|
|
||||||
|
|
||||||
|
|
||||||
async def _handler(websocket, path=None):
|
def setup(
|
||||||
_connections.add(websocket)
|
loop: asyncio.AbstractEventLoop,
|
||||||
remote_address = websocket.remote_address
|
get_hosts: Optional[Callable[[], Iterable]] = None,
|
||||||
if path is None:
|
verbose: bool = False,
|
||||||
path = getattr(websocket, "path", None)
|
|
||||||
logger.info("WebSocket connection from %s: %s", remote_address, path)
|
|
||||||
try:
|
|
||||||
# send initial hosts
|
|
||||||
if _get_hosts:
|
|
||||||
try:
|
|
||||||
hosts = list(_get_hosts())
|
|
||||||
logger.debug("Sending %d hosts to new WebSocket client", len(hosts))
|
|
||||||
for h in hosts:
|
|
||||||
jmsg = json.dumps({"type": "host", "data": h})
|
|
||||||
await websocket.send(jmsg)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error sending initial hosts: %s", e, exc_info=True)
|
|
||||||
# send recent messages
|
|
||||||
if data.msgs:
|
|
||||||
try:
|
|
||||||
# msgs = list(_get_msgs())[-100:]
|
|
||||||
logger.debug("Sending %d recent messages to new WebSocket client", len(data.msgs))
|
|
||||||
for m in data.msgs:
|
|
||||||
jmsg = json.dumps({"type": "message", "data": m})
|
|
||||||
await websocket.send(jmsg)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error sending initial messages: %s", e, exc_info=True)
|
|
||||||
|
|
||||||
# keep connection open until client disconnects
|
|
||||||
async for _ in websocket:
|
|
||||||
# we don't expect meaningful incoming messages besides the initial
|
|
||||||
# client 'hello' that some clients send; ignore for now
|
|
||||||
if _verbose:
|
|
||||||
logger.debug("received ws data: %s", _)
|
|
||||||
|
|
||||||
except (
|
|
||||||
websockets.exceptions.ConnectionClosedOK,
|
|
||||||
websockets.exceptions.ConnectionClosedError,
|
|
||||||
) as e:
|
|
||||||
logger.info("WebSocket closed from %s: %r", remote_address, e)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("WebSocket handler exception from %s: %s", remote_address, e)
|
|
||||||
finally:
|
|
||||||
logger.debug("Removing WebSocket connection from %s", remote_address)
|
|
||||||
try:
|
|
||||||
_connections.remove(websocket)
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
await websocket.wait_closed()
|
|
||||||
|
|
||||||
|
|
||||||
async def start(
|
|
||||||
host: str,
|
|
||||||
ws_port: int,
|
|
||||||
wss_port: Optional[int] = None,
|
|
||||||
ssl_context=None,
|
|
||||||
get_hosts: Optional[Callable] = None,
|
|
||||||
# get_msgs: Optional[Callable] = None,
|
|
||||||
config: dict = {},
|
|
||||||
):
|
):
|
||||||
"""Start WebSocket servers and block until cancelled.
|
"""Register the running loop and initial-state callback.
|
||||||
|
|
||||||
This is intended to be awaited inside the main asyncio event loop.
|
Call this once from _run_async before starting the HTTP server.
|
||||||
If `wss_port` and `ssl_context` are provided, a WSS server will also be
|
|
||||||
started.
|
|
||||||
"""
|
"""
|
||||||
global _loop, _get_hosts, _verbose
|
global _loop, _get_hosts, _verbose
|
||||||
_loop = asyncio.get_running_loop()
|
_loop = loop
|
||||||
_get_hosts = get_hosts
|
_get_hosts = get_hosts
|
||||||
_verbose = config.get("verbose", False),
|
_verbose = verbose
|
||||||
_debug = config.get("debug", 0),
|
|
||||||
|
|
||||||
servers = []
|
|
||||||
# plain WebSocket
|
|
||||||
websockets_logger = logging.getLogger("websockets.server")
|
|
||||||
#if _debug > 2:
|
|
||||||
# websockets_logger.setLevel(logging.DEBUG)
|
|
||||||
#else:
|
|
||||||
# websockets_logger.setLevel(logging.INFO)
|
|
||||||
# regular WebSocket
|
|
||||||
ws_server = websockets.serve(_handler, host, ws_port) # , subprotocols=["hbd"])
|
|
||||||
servers.append(ws_server)
|
|
||||||
# secure WebSocket (optional)
|
|
||||||
if wss_port and ssl_context:
|
|
||||||
wss_server = websockets.serve(
|
|
||||||
_handler, host, wss_port, ssl=ssl_context
|
|
||||||
) # , subprotocols=["hbd"])
|
|
||||||
servers.append(wss_server)
|
|
||||||
|
|
||||||
# await starting of all servers
|
|
||||||
for srv in servers:
|
|
||||||
await srv
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"WebSocket server(s) started on port %s (wss %s)", ws_port, wss_port
|
|
||||||
)
|
|
||||||
|
|
||||||
# block forever (until loop is stopped or cancelled)
|
|
||||||
await asyncio.Future()
|
|
||||||
|
|
||||||
|
|
||||||
def broadcast(typ: str, data) -> bool:
|
def _user_can_see_host(user, host_name: str) -> bool:
|
||||||
"""Thread-safe broadcast helper.
|
"""Return True if *user* may see updates for *host_name* (manager or higher)."""
|
||||||
|
from . import hbdclass, users as users_mod
|
||||||
|
if user is None or not users_mod.users_enabled():
|
||||||
|
return True
|
||||||
|
if user.admin:
|
||||||
|
return True
|
||||||
|
host = hbdclass.Host.hosts.get(host_name)
|
||||||
|
if host is None:
|
||||||
|
return False
|
||||||
|
return host.is_manager(user.username)
|
||||||
|
|
||||||
Schedules coroutine(s) on the running loop to send message to all
|
|
||||||
connected websockets. Returns False if server was not running.
|
def _get_token(request) -> str:
|
||||||
|
"""Extract session token from request (mirrors logic in http.py)."""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if auth.startswith("Bearer "):
|
||||||
|
return auth[7:].strip()
|
||||||
|
token = request.headers.get("X-Auth-Token", "")
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
return request.cookies.get("hbd_session", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
"""aiohttp WebSocket upgrade handler — register as GET /ws."""
|
||||||
|
from aiohttp import web
|
||||||
|
from . import users as users_mod
|
||||||
|
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
token = _get_token(request)
|
||||||
|
user = users_mod.get_session_user(token) if token else None
|
||||||
|
|
||||||
|
_connections[ws] = user
|
||||||
|
remote = request.remote
|
||||||
|
logger.info("WebSocket connected from %s", remote)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send current host state, filtered to hosts this user may see
|
||||||
|
if _get_hosts:
|
||||||
|
try:
|
||||||
|
for h in list(_get_hosts()):
|
||||||
|
host_name = h.get("raw_name") or h.get("name", "")
|
||||||
|
if _user_can_see_host(user, host_name):
|
||||||
|
await ws.send_str(json.dumps({"type": "host", "data": h}))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error sending initial hosts: %s", e)
|
||||||
|
|
||||||
|
# Send recent messages, filtered to hosts this user may see
|
||||||
|
if data.msgs:
|
||||||
|
try:
|
||||||
|
for m in data.msgs:
|
||||||
|
host_name = m.get("host") if isinstance(m, dict) else None
|
||||||
|
if not host_name or _user_can_see_host(user, host_name):
|
||||||
|
await ws.send_str(json.dumps({"type": "message", "data": m}))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error sending initial messages: %s", e)
|
||||||
|
|
||||||
|
# Keep connection open, ignore incoming frames
|
||||||
|
async for msg in ws:
|
||||||
|
from aiohttp import WSMsgType
|
||||||
|
if msg.type == WSMsgType.TEXT:
|
||||||
|
if _verbose:
|
||||||
|
logger.debug("ws recv from %s: %s", remote, msg.data)
|
||||||
|
elif msg.type in (WSMsgType.ERROR, WSMsgType.CLOSE):
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("WebSocket handler error from %s: %s", remote, e)
|
||||||
|
finally:
|
||||||
|
_connections.pop(ws, None)
|
||||||
|
logger.info("WebSocket disconnected from %s", remote)
|
||||||
|
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast(typ: str, payload) -> bool:
|
||||||
|
"""Thread-safe broadcast to all connected WebSocket clients.
|
||||||
|
|
||||||
|
For host and plugin updates, only sends to clients whose user has
|
||||||
|
manager-or-higher access to that host. Other message types are
|
||||||
|
broadcast to all clients.
|
||||||
|
|
||||||
|
Can be called from any thread; schedules sends on the event loop.
|
||||||
|
Returns False if the loop is not running yet.
|
||||||
"""
|
"""
|
||||||
if not _loop:
|
if not _loop:
|
||||||
return False
|
return False
|
||||||
jmsg = json.dumps({"type": typ, "data": data})
|
|
||||||
to_close = []
|
# Determine the host name for access-filtered message types
|
||||||
for ws in list(_connections):
|
host_name: Optional[str] = None
|
||||||
if ws.state != websockets.protocol.State.OPEN:
|
if typ in ("host", "plugin"):
|
||||||
to_close.append(ws)
|
host_name = payload.get("raw_name") or payload.get("host") or payload.get("name")
|
||||||
continue
|
elif typ == "message" and isinstance(payload, dict):
|
||||||
try:
|
host_name = payload.get("host")
|
||||||
asyncio.run_coroutine_threadsafe(ws.send(jmsg), _loop)
|
|
||||||
except Exception:
|
jmsg = json.dumps({"type": typ, "data": payload})
|
||||||
to_close.append(ws)
|
|
||||||
logger.debug("ws.send exception: closed")
|
async def _send_all():
|
||||||
for ws in to_close:
|
dead = set()
|
||||||
try:
|
for ws, user in list(_connections.items()):
|
||||||
asyncio.run_coroutine_threadsafe(ws.wait_closed(), _loop)
|
try:
|
||||||
except Exception:
|
if ws.closed:
|
||||||
pass
|
dead.add(ws)
|
||||||
if ws in _connections:
|
continue
|
||||||
_connections.remove(ws)
|
if host_name is not None and not _user_can_see_host(user, host_name):
|
||||||
|
continue
|
||||||
|
await ws.send_str(jmsg)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
for ws in dead:
|
||||||
|
_connections.pop(ws, None)
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(_send_all(), _loop)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.0.8"
|
version = "5.3.0"
|
||||||
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -31,8 +31,13 @@ server = [
|
|||||||
"mattermostdriver>=7.3.0",
|
"mattermostdriver>=7.3.0",
|
||||||
"aiohttp>=3.11",
|
"aiohttp>=3.11",
|
||||||
"Jinja2>=3.1.6",
|
"Jinja2>=3.1.6",
|
||||||
|
"matrix-nio>=0.24",
|
||||||
|
"ruamel.yaml>=0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Minimal client — hbc_mini only, no external dependencies
|
||||||
|
mini = []
|
||||||
|
|
||||||
# Install both client and server
|
# Install both client and server
|
||||||
all = [
|
all = [
|
||||||
"hbd[client,server]",
|
"hbd[client,server]",
|
||||||
@@ -53,6 +58,9 @@ dev = [
|
|||||||
hbd = "hbd.server.cli:main"
|
hbd = "hbd.server.cli:main"
|
||||||
hbc = "hbd.client.main:main"
|
hbc = "hbd.client.main:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
script-files = ["scripts/hb_install.sh", "scripts/hbc_mini.py"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["hbd*"]
|
include = ["hbd*"]
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ set -e
|
|||||||
uv version --bump patch
|
uv version --bump patch
|
||||||
VER=$(uv version --short)
|
VER=$(uv version --short)
|
||||||
sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py
|
sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py
|
||||||
|
sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" scripts/hbc_mini.py
|
||||||
|
|
||||||
# commit pyproject.toml
|
# commit pyproject.toml
|
||||||
git commit -m "version $VER" pyproject.toml hbd/__init__.py
|
git commit -m "version $VER" pyproject.toml hbd/__init__.py scripts/hbc_mini.py
|
||||||
git push
|
git push
|
||||||
# tag version
|
# tag version
|
||||||
git tag -a v$VER -m "Version $VER"
|
git tag -a v$VER -m "Version $VER"
|
||||||
git push --tags
|
git push --tags
|
||||||
|
|
||||||
rm hbd/__init__.py.bak
|
rm hbd/__init__.py.bak
|
||||||
|
rm scripts/hbc_mini.py.bak
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
hbc_mini
|
||||||
|
hbc_mini_dbg
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CC ?= cc
|
||||||
|
CFLAGS = -O2 -Wall -Wextra -std=c11
|
||||||
|
LDFLAGS = -lz -lpthread -lm
|
||||||
|
TARGET = hbc_mini
|
||||||
|
SRC = hbc_mini.c
|
||||||
|
|
||||||
|
# FreeBSD/NetBSD keep zlib in base; no extra flags needed.
|
||||||
|
# On some NetBSD installs pthreads may need -lpthread from pkgsrc.
|
||||||
|
|
||||||
|
.PHONY: all clean debug
|
||||||
|
|
||||||
|
all: $(TARGET)
|
||||||
|
|
||||||
|
$(TARGET): $(SRC)
|
||||||
|
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
|
||||||
|
|
||||||
|
debug: $(SRC)
|
||||||
|
$(CC) -g -fsanitize=address,undefined -o $(TARGET)_dbg $< $(LDFLAGS)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(TARGET) $(TARGET)_dbg
|
||||||
File diff suppressed because it is too large
Load Diff
Executable
+115
@@ -0,0 +1,115 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Helper script to install the heartbeat tools. By default, it will only
|
||||||
|
# install the heartbeat client, hbc. The server is installed when the arg 'server' is passed
|
||||||
|
# to the script. The script will install the heartbeat tools in a python
|
||||||
|
# virtual environment in ~/venvs/hbd. The hbd and hbc commands will be
|
||||||
|
# installed from the wheel and symlinked to ~/bin/hbd and ~/bin/hbc,
|
||||||
|
# respectively. If the virtual environment already exists, it will be
|
||||||
|
# reused. The script will also remove any existing symlinks for hbd and hbc
|
||||||
|
# in ~/bin before creating new ones.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
what=$1
|
||||||
|
on_ha=0
|
||||||
|
where=""
|
||||||
|
venv=""
|
||||||
|
[ "$2" = "HA" ] && on_ha=1
|
||||||
|
[ -z "$what" ] && what="client"
|
||||||
|
|
||||||
|
if [ -d /homeassistant ]; then # if running from HA command line
|
||||||
|
echo "HA, running \"docker exec homeassistant /config/bin/hb_install.sh $@\""
|
||||||
|
docker exec homeassistant /config/bin/hb_install.sh $@ HA
|
||||||
|
rc=$?
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "Failed to install heartbeat in HA, please check the logs for more details"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $on_ha -eq 1 ] || [ -r /.dockerenv ] && [ -d /config/bin ]; then
|
||||||
|
# Installing under docker on Home Assistant OS, using /config/bin for executables and /config/venvs for virtual environments
|
||||||
|
echo "Home Assistant OS detected, installing under docker"
|
||||||
|
where="/config/bin"
|
||||||
|
venv="/config/venvs"
|
||||||
|
else
|
||||||
|
if [ ! -d $HOME/.local/bin ] && [ ! -d $HOME/bin ]; then
|
||||||
|
echo "No suitable bin directory found in PATH, please add either $HOME/.local/bin or $HOME/bin to your PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for where in $HOME/bin $HOME/.local/bin notset ; do
|
||||||
|
if echo ":$PATH:" | grep -q ":$where:" ; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$where" = "notset" ]; then
|
||||||
|
echo "No suitable bin directory found in PATH, please add either $HOME/.local/bin or $HOME/bin to your PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$what" = "mini" ]; then
|
||||||
|
venv=""
|
||||||
|
else
|
||||||
|
venv="$HOME/venvs"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "Installing $what to $where"
|
||||||
|
if [ ! -z "$venv" ]; then
|
||||||
|
echo "Using virtual environment at $venv/hbd"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$venv" != "" ] && [ ! -d $venv/hbd ]; then
|
||||||
|
arg=""
|
||||||
|
have_pip=$(python3 -c "import pip" 2>/dev/null &> /dev/null && echo "Installed" || echo "Not Installed")
|
||||||
|
if [ "$have_pip" = "Not Installed" ]; then
|
||||||
|
# some systems do not have pip installed by default, so we need to fetch get-pip.py and install pip
|
||||||
|
echo "pip is not installed, fetching get-pip.py and installing pip"
|
||||||
|
arg="--without-pip"
|
||||||
|
fi
|
||||||
|
mkdir -p $venv
|
||||||
|
have_venv=$(python3 -c "import venv" 2>/dev/null &> /dev/null && echo "Installed" || echo "Not Installed")
|
||||||
|
if [ "$have_venv" = "Not Installed" ]; then
|
||||||
|
if [ "$have_pip" = "Not Installed" ]; then
|
||||||
|
echo "python has no venv, and no pip to install virtualenv, cannot continue"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "python venv module not found, installing virtualenv"
|
||||||
|
python3 -m pip install --user virtualenv
|
||||||
|
python3 -m virtualenv $venv/hbd --system-site-packages $arg
|
||||||
|
else
|
||||||
|
python3 -m venv $venv/hbd --system-site-packages $arg
|
||||||
|
fi
|
||||||
|
. $venv/hbd/bin/activate
|
||||||
|
if [ -n "$arg" ]; then
|
||||||
|
curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && python3 get-pip.py
|
||||||
|
fi
|
||||||
|
deactivate
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -z "$venv" ]; then
|
||||||
|
. $venv/hbd/bin/activate
|
||||||
|
fi
|
||||||
|
if [ "$what" = "mini" ]; then
|
||||||
|
curl -s -o $where/hbc_mini https://git.wrede.ca/andreas/heartbeat/raw/branch/master/scripts/hbc_mini.py
|
||||||
|
chmod +x $where/hbc_mini
|
||||||
|
else
|
||||||
|
python3 -mpip install --upgrade --index-url https://git.wrede.ca/api/packages/andreas/pypi/simple/ --extra-index-url https://pypi.org/simple hbd[$what]
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -z "$venv" ]; then
|
||||||
|
echo "linking executables to $where"
|
||||||
|
if [ "$what" = "server" ]; then
|
||||||
|
rm -f $where/hbd
|
||||||
|
ln -sf $(which hbd) $where/hbd
|
||||||
|
elif [ "$what" = "client" ]; then
|
||||||
|
rm -f $where/hbc
|
||||||
|
ln -sf $(which hbc) $where/hbc
|
||||||
|
fi
|
||||||
|
rm -f $where/hb_install.sh
|
||||||
|
ln -sf $(which hb_install.sh) $where/hb_install.sh
|
||||||
|
fi
|
||||||
|
echo "Installation complete. To upgrade, run the following:"
|
||||||
|
echo " $where/hb_install.sh $what"
|
||||||
|
echo "To install on another machine, run the following obtain the install script and run it:"
|
||||||
|
echo "from https://git.wrede.ca/andreas/heartbeat/raw/branch/master/scripts/hb_install.sh"
|
||||||
|
echo "and then run sh hb_install.sh [mini|client]"
|
||||||
Executable
+1192
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# install hbd/hbc from wheel and create symlinks for hbd and hbc in ~/bin
|
|
||||||
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/venvs/hbd ]; then
|
|
||||||
mkdir -p ~/venvs
|
|
||||||
python3 -m venv ~/venvs/hbd --system-site-packages
|
|
||||||
fi
|
|
||||||
. ~/venvs/hbd/bin/activate
|
|
||||||
pip install 'git+ssh://git@git.wrede.ca/andreas/heartbeat.git'
|
|
||||||
rm -f ~/bin/hbd
|
|
||||||
rm -f ~/bin/hbc
|
|
||||||
ln -sf $(which hbd) ~/bin/hbd
|
|
||||||
ln -sf $(which hbc) ~/bin/hbc
|
|
||||||
+1
-2
@@ -68,8 +68,7 @@ async def test_nagios_runner():
|
|||||||
print(f" ✓ Collected {len(data)} data points")
|
print(f" ✓ Collected {len(data)} data points")
|
||||||
|
|
||||||
print(f"\n4. Results:")
|
print(f"\n4. Results:")
|
||||||
print(f" Overall Status: {data.get('overall_status')} (code: {data.get('overall_status_code')})")
|
print(f" Data points collected: {len(data)}")
|
||||||
print(f" Plugins Executed: {data.get('plugin_count')}")
|
|
||||||
|
|
||||||
# Show individual plugin results
|
# Show individual plugin results
|
||||||
print(f"\n5. Individual Plugin Results:")
|
print(f"\n5. Individual Plugin Results:")
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from hbd.server import configio
|
||||||
|
|
||||||
|
SAMPLE_YAML = """\
|
||||||
|
# Server configuration
|
||||||
|
hbd_port: 50004 # HTTP API port
|
||||||
|
interval: 20
|
||||||
|
users:
|
||||||
|
alice:
|
||||||
|
full_name: Alice Smith
|
||||||
|
admin: true
|
||||||
|
notification_channels:
|
||||||
|
pushover_ops:
|
||||||
|
type: pushover
|
||||||
|
token: abc123
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_roundtrip_loads_values(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
assert data["hbd_port"] == 50004
|
||||||
|
assert data["interval"] == 20
|
||||||
|
assert data["users"]["alice"]["full_name"] == "Alice Smith"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_config_creates_backup(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
data["interval"] = 30
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
backups = configio.list_backups(str(f))
|
||||||
|
assert len(backups) == 1
|
||||||
|
assert ".bak." in backups[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_config_preserves_comments(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
data["interval"] = 30
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
content = f.read_text()
|
||||||
|
assert "# Server configuration" in content
|
||||||
|
assert "# HTTP API port" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_config_atomically_replaces_file(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
data["interval"] = 99
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
assert not (tmp_path / ".hb.yaml.tmp").exists()
|
||||||
|
data2 = configio.read_roundtrip(str(f))
|
||||||
|
assert data2["interval"] == 99
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_config_backup_rotation(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(SAMPLE_YAML)
|
||||||
|
# Pre-create 10 existing backups with old timestamps
|
||||||
|
for i in range(10):
|
||||||
|
(tmp_path / f".hb.yaml.bak.20260101-{i:06d}").write_text("old")
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
backups = configio.list_backups(str(cfg))
|
||||||
|
assert len(backups) == 10
|
||||||
|
assert not (tmp_path / ".hb.yaml.bak.20260101-000000").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_backups_newest_first(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(SAMPLE_YAML)
|
||||||
|
for i in range(3):
|
||||||
|
(tmp_path / f".hb.yaml.bak.20260101-{i:02d}0000").write_text("b")
|
||||||
|
backups = configio.list_backups(str(cfg))
|
||||||
|
assert len(backups) == 3
|
||||||
|
assert backups == sorted(backups, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_structured_section_server_updates_keys(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_structured_section(data, "server", {"interval": 60, "hbd_port": 8080})
|
||||||
|
assert data["interval"] == 60
|
||||||
|
assert data["hbd_port"] == 8080
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_structured_section_server_ignores_unknown_keys(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_structured_section(data, "server", {"interval": 60, "not_a_key": "x"})
|
||||||
|
assert "not_a_key" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_structured_section_users_replaces_dict(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
new_users = {"bob": {"full_name": "Bob Jones", "admin": False}}
|
||||||
|
configio.apply_structured_section(data, "users", new_users)
|
||||||
|
assert "alice" not in data["users"]
|
||||||
|
assert data["users"]["bob"]["full_name"] == "Bob Jones"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_yaml_section_notification_channels(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
new_yaml = "email_ops:\n type: email\n recipients: [ops@example.com]\n"
|
||||||
|
configio.apply_yaml_section(data, "notification_channels", new_yaml)
|
||||||
|
assert "email_ops" in data["notification_channels"]
|
||||||
|
assert "pushover_ops" not in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_yaml_section_thresholds_maps_to_threshold_configs(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_yaml_section(data, "thresholds", "default:\n cpu: 80\n")
|
||||||
|
assert "threshold_configs" in data
|
||||||
|
assert data["threshold_configs"]["default"]["cpu"] == 80
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_yaml_section_dns_replaces_each_key(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_yaml_section(
|
||||||
|
data, "dns",
|
||||||
|
"nsupdate_bin: /usr/bin/nsupdate\ndyndomains: [dyn.example.com]\n"
|
||||||
|
)
|
||||||
|
assert data["nsupdate_bin"] == "/usr/bin/nsupdate"
|
||||||
|
assert data["dyndomains"] == ["dyn.example.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_yaml_section_unknown_raises(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
with pytest.raises(ValueError, match="Unknown YAML section"):
|
||||||
|
configio.apply_yaml_section(data, "nope", "x: 1\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_structured_section_unknown_raises(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
with pytest.raises(ValueError, match="Unknown structured section"):
|
||||||
|
configio.apply_structured_section(data, "nope", {"x": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_roundtrip_missing_file_raises(tmp_path):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
configio.read_roundtrip(str(tmp_path / "nonexistent.yaml"))
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for the config read/write API helpers in http.py."""
|
||||||
|
import pytest
|
||||||
|
from hbd.server import http
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_config_for_api_masks_user_passwords():
|
||||||
|
config = {
|
||||||
|
"hbd_port": 50004,
|
||||||
|
"interval": 20,
|
||||||
|
"users": {
|
||||||
|
"alice": {"full_name": "Alice", "admin": True, "password": "pbkdf2:sha256:abc"},
|
||||||
|
},
|
||||||
|
"oauth": {},
|
||||||
|
}
|
||||||
|
result = http._mask_config_for_api(config)
|
||||||
|
assert result["users"]["alice"]["password"] == "•••"
|
||||||
|
assert result["users"]["alice"]["full_name"] == "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_config_for_api_masks_oauth_client_secret():
|
||||||
|
config = {
|
||||||
|
"hbd_port": 50004,
|
||||||
|
"interval": 20,
|
||||||
|
"users": {},
|
||||||
|
"oauth": {
|
||||||
|
"gitea": {"type": "gitea", "url": "https://git.example.com",
|
||||||
|
"client_id": "cid", "client_secret": "verysecret"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = http._mask_config_for_api(config)
|
||||||
|
assert result["oauth"]["gitea"]["client_secret"] == "•••"
|
||||||
|
assert result["oauth"]["gitea"]["client_id"] == "cid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_config_for_api_includes_server_keys():
|
||||||
|
config = {"hbd_port": 50004, "interval": 20, "users": {}, "oauth": {}}
|
||||||
|
result = http._mask_config_for_api(config)
|
||||||
|
assert result["server"]["hbd_port"] == 50004
|
||||||
|
assert result["server"]["interval"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_config_for_api_no_password_in_users_leaves_no_key():
|
||||||
|
config = {
|
||||||
|
"hbd_port": 50004,
|
||||||
|
"users": {"bob": {"full_name": "Bob", "admin": False}},
|
||||||
|
"oauth": {},
|
||||||
|
}
|
||||||
|
result = http._mask_config_for_api(config)
|
||||||
|
assert "password" not in result["users"]["bob"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- configio integration for write path ----
|
||||||
|
|
||||||
|
def test_write_path_applies_server_section(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text("hbd_port: 50004\ninterval: 20\nusers: {}\n")
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
configio.apply_structured_section(data, "server", {"interval": 60})
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert data2["interval"] == 60
|
||||||
|
assert data2["hbd_port"] == 50004 # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_path_applies_yaml_section(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(
|
||||||
|
"hbd_port: 50004\nnotification_channels:\n old_ch:\n type: email\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
configio.apply_yaml_section(data, "notification_channels", "new_ch:\n type: pushover\n")
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert "new_ch" in data2["notification_channels"]
|
||||||
|
assert "old_ch" not in data2["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_path_hashes_plaintext_password(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text("hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: pbkdf2:sha256:old\n")
|
||||||
|
from hbd.server import configio
|
||||||
|
from hbd.server import users as users_mod
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
# Simulate what the POST handler does: hash plaintext password
|
||||||
|
new_users = {"alice": {"full_name": "Alice", "admin": True, "password": "newplaintext"}}
|
||||||
|
for username, attrs in new_users.items():
|
||||||
|
pw = attrs.get("password", "")
|
||||||
|
if pw and not pw.startswith("pbkdf2:"):
|
||||||
|
attrs["password"] = users_mod.hash_password(pw)
|
||||||
|
configio.apply_structured_section(data, "users", new_users)
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert data2["users"]["alice"]["password"].startswith("pbkdf2:")
|
||||||
|
assert data2["users"]["alice"]["password"] != "newplaintext"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollback_restores_backup(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text("hbd_port: 50004\ninterval: 20\n")
|
||||||
|
from hbd.server import configio
|
||||||
|
# Make a change to create a backup
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
data["interval"] = 99
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
backups = configio.list_backups(str(cfg))
|
||||||
|
assert len(backups) == 1
|
||||||
|
# Read the backup and write it back (simulating rollback)
|
||||||
|
backup_data = configio.read_roundtrip(backups[0])
|
||||||
|
configio.write_config(str(cfg), backup_data)
|
||||||
|
restored = configio.read_roundtrip(str(cfg))
|
||||||
|
assert restored["interval"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_path_preserves_masked_password(tmp_path):
|
||||||
|
"""The "•••" sentinel must preserve the existing hash, not write "•••" to disk."""
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
original_hash = "pbkdf2:sha256:original_hash"
|
||||||
|
cfg.write_text(
|
||||||
|
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: {original_hash}\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
from hbd.server import users as users_mod
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
# Simulate what api_config_post does when client sends "•••" back
|
||||||
|
existing_users = data.get("users") or {}
|
||||||
|
users_payload = {"alice": {"full_name": "Alice", "admin": True, "password": "•••"}}
|
||||||
|
for username, attrs in users_payload.items():
|
||||||
|
pw = attrs.get("password", "")
|
||||||
|
if pw and pw != "•••" and not pw.startswith("pbkdf2:"):
|
||||||
|
attrs["password"] = users_mod.hash_password(pw)
|
||||||
|
elif not pw or pw == "•••":
|
||||||
|
existing_hash = (existing_users.get(username) or {}).get("password", "")
|
||||||
|
if existing_hash:
|
||||||
|
attrs["password"] = existing_hash
|
||||||
|
else:
|
||||||
|
attrs.pop("password", None)
|
||||||
|
configio.apply_structured_section(data, "users", users_payload)
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert data2["users"]["alice"]["password"] == original_hash, (
|
||||||
|
f"Expected original hash preserved, got: {data2['users']['alice']['password']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_path_preserves_oauth_client_secret(tmp_path):
|
||||||
|
"""The "•••" sentinel for oauth client_secret must preserve the existing secret."""
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
original_secret = "real_client_secret_value"
|
||||||
|
cfg.write_text(
|
||||||
|
f"hbd_port: 50004\noauth:\n gitea:\n type: gitea\n url: https://git.example.com\n"
|
||||||
|
f" client_id: cid123\n client_secret: {original_secret}\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
# Simulate what api_config_post does when client sends "•••" back for client_secret
|
||||||
|
existing_oauth = data.get("oauth") or {}
|
||||||
|
new_oauth = {"gitea": {"type": "gitea", "url": "https://git.example.com", "client_id": "cid123", "client_secret": "•••"}}
|
||||||
|
for name, attrs in new_oauth.items():
|
||||||
|
cs = attrs.get("client_secret", "")
|
||||||
|
if not cs or cs == "•••":
|
||||||
|
existing_cs = (existing_oauth.get(name) or {}).get("client_secret", "")
|
||||||
|
if existing_cs:
|
||||||
|
attrs["client_secret"] = existing_cs
|
||||||
|
else:
|
||||||
|
attrs.pop("client_secret", None)
|
||||||
|
data["oauth"] = new_oauth
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert data2["oauth"]["gitea"]["client_secret"] == original_secret, (
|
||||||
|
f"Expected original secret preserved, got: {data2['oauth']['gitea']['client_secret']!r}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for PUT /api/0/users/me logic."""
|
||||||
|
import pytest
|
||||||
|
from hbd.server import users as users_mod
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_roundtrip():
|
||||||
|
h = users_mod.hash_password("mysecret")
|
||||||
|
assert h.startswith("pbkdf2:sha256:")
|
||||||
|
assert users_mod.authenticate.__doc__ is not None # module loaded
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_change_requires_correct_current(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
initial_hash = users_mod.hash_password("oldpass")
|
||||||
|
cfg.write_text(
|
||||||
|
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: {initial_hash}\n"
|
||||||
|
)
|
||||||
|
users_mod.load_users({"users": {"alice": {"full_name": "Alice", "admin": True, "password": initial_hash}}})
|
||||||
|
|
||||||
|
# Correct current password authenticates
|
||||||
|
assert users_mod.authenticate("alice", "oldpass") is not None
|
||||||
|
# Wrong current password does not authenticate
|
||||||
|
assert users_mod.authenticate("alice", "wrongpass") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_users_me_writes_new_fields(tmp_path):
|
||||||
|
"""Simulate the write path: read config, update user, write back."""
|
||||||
|
initial_hash = users_mod.hash_password("secret")
|
||||||
|
yaml_content = (
|
||||||
|
"hbd_port: 50004\n"
|
||||||
|
f"users:\n alice:\n full_name: Old Name\n admin: true\n password: {initial_hash}\n"
|
||||||
|
)
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(yaml_content)
|
||||||
|
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
|
||||||
|
# Simulate handler updating full_name and avatar
|
||||||
|
user_entry = dict(data["users"]["alice"])
|
||||||
|
user_entry["full_name"] = "New Name"
|
||||||
|
user_entry["avatar"] = "/img/alice.png"
|
||||||
|
data["users"]["alice"] = user_entry
|
||||||
|
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
result = configio.read_roundtrip(str(cfg))
|
||||||
|
assert result["users"]["alice"]["full_name"] == "New Name"
|
||||||
|
assert result["users"]["alice"]["avatar"] == "/img/alice.png"
|
||||||
|
assert result["users"]["alice"]["password"] == initial_hash # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_users_me_changes_password(tmp_path):
|
||||||
|
initial_hash = users_mod.hash_password("oldpass")
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(
|
||||||
|
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n password: {initial_hash}\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
|
||||||
|
new_hash = users_mod.hash_password("newpass")
|
||||||
|
data["users"]["alice"]["password"] = new_hash
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
|
||||||
|
result = configio.read_roundtrip(str(cfg))
|
||||||
|
# Load users from new config and authenticate with new password
|
||||||
|
new_config = {"users": dict(result["users"])}
|
||||||
|
users_mod.load_users(new_config)
|
||||||
|
assert users_mod.authenticate("alice", "newpass") is not None
|
||||||
|
assert users_mod.authenticate("alice", "oldpass") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_users_me_notification_channels(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(
|
||||||
|
"hbd_port: 50004\n"
|
||||||
|
"notification_channels:\n pushover_ops:\n type: pushover\n"
|
||||||
|
"users:\n alice:\n full_name: Alice\n notification_channels: []\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
data["users"]["alice"]["notification_channels"] = ["pushover_ops"]
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
result = configio.read_roundtrip(str(cfg))
|
||||||
|
assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"]
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
import logging
|
||||||
|
import time as time_mod
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hbd.server import oauth
|
||||||
|
from hbd.server import users as users_mod
|
||||||
|
from hbd.server.users import User
|
||||||
|
|
||||||
|
|
||||||
|
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"}}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_oauth_states():
|
||||||
|
oauth._states.clear()
|
||||||
|
yield
|
||||||
|
oauth._states.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_users_dict():
|
||||||
|
original = dict(users_mod.users)
|
||||||
|
yield
|
||||||
|
users_mod.users = original
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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() - 1000)
|
||||||
|
assert oauth.validate_state(state) is False
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provision_oauth_user_survives_config_reload():
|
||||||
|
_reset_users()
|
||||||
|
users_mod.provision_oauth_user("oauthonly", "OAuth Only", "https://example.com/a.png")
|
||||||
|
assert "oauthonly" in users_mod.users
|
||||||
|
# Reload with empty config — OAuth user should survive
|
||||||
|
users_mod.load_users({})
|
||||||
|
assert "oauthonly" in users_mod.users
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration-style tests: callback logic chain
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_invalid_state_rejects():
|
||||||
|
"""Verify validate_state returns False for unknown state tokens."""
|
||||||
|
fake_state = "this-is-not-a-real-state"
|
||||||
|
assert oauth.validate_state(fake_state) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_oauth_flow_chain():
|
||||||
|
"""Integration-style test: state → exchange → fetch → provision chain."""
|
||||||
|
p = _gitea_provider()
|
||||||
|
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
||||||
|
|
||||||
|
state = oauth.make_state()
|
||||||
|
assert oauth.validate_state(state) is True
|
||||||
|
|
||||||
|
mock_token_response = AsyncMock()
|
||||||
|
mock_token_response.status = 200
|
||||||
|
mock_token_response.json = AsyncMock(return_value={"access_token": "flow_token"})
|
||||||
|
|
||||||
|
mock_user_response = AsyncMock()
|
||||||
|
mock_user_response.status = 200
|
||||||
|
mock_user_response.json = AsyncMock(return_value={
|
||||||
|
"login": "flowuser",
|
||||||
|
"full_name": "Flow User",
|
||||||
|
"avatar_url": "https://git.example.com/avatars/flow.png",
|
||||||
|
})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_token_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
))
|
||||||
|
mock_session.get = MagicMock(return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_user_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(p, "authcode", redirect_uri)
|
||||||
|
profile = await oauth.fetch_user(p, token)
|
||||||
|
|
||||||
|
assert token == "flow_token"
|
||||||
|
assert profile["login"] == "flowuser"
|
||||||
|
|
||||||
|
_reset_users()
|
||||||
|
user = users_mod.provision_oauth_user(
|
||||||
|
profile["login"], profile["full_name"], profile["avatar_url"]
|
||||||
|
)
|
||||||
|
assert user.username == "flowuser"
|
||||||
|
assert user.check_password("anything") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_providers()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CFG_GITHUB = {
|
||||||
|
"oauth": {
|
||||||
|
"github": {"type": "github", "client_id": "ghid", "client_secret": "ghs"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CFG_NEXTCLOUD = {
|
||||||
|
"oauth": {
|
||||||
|
"nc": {
|
||||||
|
"type": "nextcloud",
|
||||||
|
"url": "https://nc.example.com",
|
||||||
|
"client_id": "ncid",
|
||||||
|
"client_secret": "ncs",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CFG_MULTI = {
|
||||||
|
"oauth": {
|
||||||
|
"mygitea": {
|
||||||
|
"type": "gitea",
|
||||||
|
"url": "https://git.example.com",
|
||||||
|
"client_id": "cid",
|
||||||
|
"client_secret": "cs",
|
||||||
|
"label": "Work Gitea",
|
||||||
|
"logo": "https://example.com/logo.png",
|
||||||
|
},
|
||||||
|
"github": {"type": "github", "client_id": "ghid", "client_secret": "ghs"},
|
||||||
|
"nc": {
|
||||||
|
"type": "nextcloud",
|
||||||
|
"url": "https://nc.example.com",
|
||||||
|
"client_id": "ncid",
|
||||||
|
"client_secret": "ncs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_backward_compat_no_type_field():
|
||||||
|
"""Old config without 'type' defaults to gitea."""
|
||||||
|
providers = oauth.get_providers(CFG_ON)
|
||||||
|
assert len(providers) == 1
|
||||||
|
p = providers[0]
|
||||||
|
assert p.name == "gitea"
|
||||||
|
assert p.type == "gitea"
|
||||||
|
assert p.label == "Gitea"
|
||||||
|
assert p.client_id == "cid"
|
||||||
|
assert p.authorize_url == "https://git.example.com/login/oauth/authorize"
|
||||||
|
assert p.token_url == "https://git.example.com/login/oauth/access_token"
|
||||||
|
assert p.profile_url == "https://git.example.com/api/v1/user"
|
||||||
|
assert p.scope == "user:email"
|
||||||
|
assert p.profile_data_path == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_multiple():
|
||||||
|
providers = oauth.get_providers(CFG_MULTI)
|
||||||
|
assert len(providers) == 3
|
||||||
|
names = [p.name for p in providers]
|
||||||
|
assert "mygitea" in names
|
||||||
|
assert "github" in names
|
||||||
|
assert "nc" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_custom_label_and_logo():
|
||||||
|
providers = oauth.get_providers(CFG_MULTI)
|
||||||
|
gitea = next(p for p in providers if p.name == "mygitea")
|
||||||
|
assert gitea.label == "Work Gitea"
|
||||||
|
assert gitea.logo == "https://example.com/logo.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_github_default_label():
|
||||||
|
providers = oauth.get_providers(CFG_GITHUB)
|
||||||
|
assert providers[0].label == "GitHub"
|
||||||
|
assert providers[0].logo == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_github_fixed_urls():
|
||||||
|
providers = oauth.get_providers(CFG_GITHUB)
|
||||||
|
p = providers[0]
|
||||||
|
assert p.authorize_url == "https://github.com/login/oauth/authorize"
|
||||||
|
assert p.token_url == "https://github.com/login/oauth/access_token"
|
||||||
|
assert p.profile_url == "https://api.github.com/user"
|
||||||
|
assert p.scope == "read:user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_nextcloud_urls_and_path():
|
||||||
|
providers = oauth.get_providers(CFG_NEXTCLOUD)
|
||||||
|
p = providers[0]
|
||||||
|
assert p.authorize_url == "https://nc.example.com/apps/oauth2/authorize"
|
||||||
|
assert p.token_url == "https://nc.example.com/apps/oauth2/api/v1/token"
|
||||||
|
assert p.profile_url == "https://nc.example.com/ocs/v2.php/cloud/user?format=json"
|
||||||
|
assert p.profile_data_path == ["ocs", "data"]
|
||||||
|
assert p.scope == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_missing_client_id(caplog):
|
||||||
|
cfg = {"oauth": {"gitea": {"url": "https://git.example.com", "client_secret": "cs"}}}
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "missing" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_missing_client_secret(caplog):
|
||||||
|
cfg = {"oauth": {"gitea": {"url": "https://git.example.com", "client_id": "cid"}}}
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "missing" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_missing_url_for_gitea(caplog):
|
||||||
|
cfg = {"oauth": {"gitea": {"type": "gitea", "client_id": "cid", "client_secret": "cs"}}}
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "url" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_missing_url_for_nextcloud(caplog):
|
||||||
|
cfg = {"oauth": {"nc": {"type": "nextcloud", "client_id": "cid", "client_secret": "cs"}}}
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "url" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_github_no_url_required():
|
||||||
|
providers = oauth.get_providers(CFG_GITHUB)
|
||||||
|
assert len(providers) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_unknown_type(caplog):
|
||||||
|
cfg = {"oauth": {"mystery": {"type": "saml", "client_id": "cid", "client_secret": "cs"}}}
|
||||||
|
import logging
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "saml" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_empty_config():
|
||||||
|
assert oauth.get_providers({}) == []
|
||||||
|
assert oauth.get_providers(CFG_OFF) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_auth_url / exchange_code / fetch_user (generic, ResolvedProvider-based)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _gitea_provider() -> oauth.ResolvedProvider:
|
||||||
|
return oauth.get_providers(CFG_ON)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _github_provider() -> oauth.ResolvedProvider:
|
||||||
|
return oauth.get_providers(CFG_GITHUB)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _nextcloud_provider() -> oauth.ResolvedProvider:
|
||||||
|
return oauth.get_providers(CFG_NEXTCLOUD)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_auth_url_gitea():
|
||||||
|
p = _gitea_provider()
|
||||||
|
url = oauth.build_auth_url(p, "teststate", "https://hbd.example.com/login/oauth/gitea/callback")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
assert parsed.netloc == "git.example.com"
|
||||||
|
assert parsed.path == "/login/oauth/authorize"
|
||||||
|
assert qs["client_id"] == ["cid"]
|
||||||
|
assert qs["state"] == ["teststate"]
|
||||||
|
assert qs["scope"] == ["user:email"]
|
||||||
|
assert qs["response_type"] == ["code"]
|
||||||
|
assert qs["redirect_uri"] == ["https://hbd.example.com/login/oauth/gitea/callback"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_auth_url_github():
|
||||||
|
p = _github_provider()
|
||||||
|
url = oauth.build_auth_url(p, "st", "https://hbd.example.com/login/oauth/github/callback")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
assert parsed.netloc == "github.com"
|
||||||
|
assert qs["scope"] == ["read:user"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_auth_url_nextcloud_no_scope_param():
|
||||||
|
"""Nextcloud scope is empty — the 'scope' key must be absent from the URL."""
|
||||||
|
p = _nextcloud_provider()
|
||||||
|
url = oauth.build_auth_url(p, "st", "https://hbd.example.com/login/oauth/nc/callback")
|
||||||
|
qs = parse_qs(urlparse(url).query)
|
||||||
|
assert "scope" not in qs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_code_generic_returns_token():
|
||||||
|
p = _gitea_provider()
|
||||||
|
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(p, "mycode", redirect_uri)
|
||||||
|
assert token == "tok123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_code_sends_accept_json():
|
||||||
|
"""Accept: application/json must be present for all providers (required by GitHub)."""
|
||||||
|
p = _github_provider()
|
||||||
|
captured_headers = {}
|
||||||
|
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={"access_token": "ghtoken"})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
|
||||||
|
def capture_post(url, **kwargs):
|
||||||
|
captured_headers.update(kwargs.get("headers", {}))
|
||||||
|
return AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_session.post = capture_post
|
||||||
|
|
||||||
|
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)):
|
||||||
|
await oauth.exchange_code(p, "code", "https://hbd.example.com/login/oauth/github/callback")
|
||||||
|
|
||||||
|
assert captured_headers.get("Accept") == "application/json"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_code_raises_on_error_status():
|
||||||
|
p = _gitea_provider()
|
||||||
|
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(p, "badcode", "https://hbd.example.com/login/oauth/gitea/callback")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_code_raises_when_no_access_token():
|
||||||
|
p = _gitea_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={"error": "bad_request"})
|
||||||
|
|
||||||
|
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(p, "mycode", "https://hbd.example.com/login/oauth/gitea/callback")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_user_gitea_returns_profile():
|
||||||
|
p = _gitea_provider()
|
||||||
|
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(p, "tok123")
|
||||||
|
|
||||||
|
assert profile == {
|
||||||
|
"login": "alice",
|
||||||
|
"full_name": "Alice Smith",
|
||||||
|
"avatar_url": "https://git.example.com/avatars/alice.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_user_github_maps_name_field():
|
||||||
|
p = _github_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={
|
||||||
|
"login": "bobgh",
|
||||||
|
"name": "Bob GitHub",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1",
|
||||||
|
})
|
||||||
|
|
||||||
|
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(p, "ghtoken")
|
||||||
|
|
||||||
|
assert profile["login"] == "bobgh"
|
||||||
|
assert profile["full_name"] == "Bob GitHub"
|
||||||
|
assert profile["avatar_url"] == "https://avatars.githubusercontent.com/u/1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_user_nextcloud_nested_extraction():
|
||||||
|
"""Nextcloud profile is nested under ocs.data; avatar is absent."""
|
||||||
|
p = _nextcloud_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={
|
||||||
|
"ocs": {
|
||||||
|
"meta": {"status": "ok", "statuscode": 200},
|
||||||
|
"data": {
|
||||||
|
"id": "ncuser",
|
||||||
|
"display-name": "NC User",
|
||||||
|
"email": "nc@example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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(p, "nctoken")
|
||||||
|
|
||||||
|
assert profile["login"] == "ncuser"
|
||||||
|
assert profile["full_name"] == "NC User"
|
||||||
|
assert profile["avatar_url"] == "" # Nextcloud has no avatar field
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_user_raises_on_error_status():
|
||||||
|
p = _gitea_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 401
|
||||||
|
mock_response.text = AsyncMock(return_value="unauthorized")
|
||||||
|
|
||||||
|
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),
|
||||||
|
)):
|
||||||
|
with pytest.raises(oauth.OAuthError):
|
||||||
|
await oauth.fetch_user(p, "badtoken")
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_enabled_with_valid_provider():
|
||||||
|
assert oauth.is_enabled(CFG_ON) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_enabled_false_when_no_providers():
|
||||||
|
assert oauth.is_enabled(CFG_OFF) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_enabled_false_partial_config():
|
||||||
|
assert oauth.is_enabled(CFG_PARTIAL) is False
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
from hbd.client.plugin import 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)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import pytest
|
||||||
|
from hbd.server import settings as settings_mod
|
||||||
|
|
||||||
|
CFG = {
|
||||||
|
"hbd_port": 50004,
|
||||||
|
"interval": 20,
|
||||||
|
"grace": 2,
|
||||||
|
"users": {
|
||||||
|
"alice": {"full_name": "Alice Smith", "admin": True, "password": "pbkdf2:sha256:abc",
|
||||||
|
"notification_channels": ["pushover_ops"]},
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"gitea": {"type": "gitea", "url": "https://git.example.com",
|
||||||
|
"client_id": "cid", "client_secret": "csec", "label": "Sign in with Gitea"},
|
||||||
|
},
|
||||||
|
"notification_channels": {
|
||||||
|
"pushover_ops": {"type": "pushover", "token": "tok", "user": "usr"},
|
||||||
|
},
|
||||||
|
"hosts": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sections_have_section_mode():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
for s in sections:
|
||||||
|
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
|
||||||
|
assert s["section_mode"] in ("form", "yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sections_have_api_section():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
for s in sections:
|
||||||
|
assert "api_section" in s, f"Section {s['id']} missing api_section"
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_section_has_editable_fields():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
network = next(s for s in sections if s["id"] == "network")
|
||||||
|
assert network["section_mode"] == "form"
|
||||||
|
assert network["api_section"] == "server"
|
||||||
|
editable = [f for f in network["fields"] if f["editable"]]
|
||||||
|
assert len(editable) >= 2 # hbd_port, ws_port at minimum
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_sections_have_correct_mode():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
|
||||||
|
assert "channels" in yaml_sections
|
||||||
|
assert "hosts" in yaml_sections
|
||||||
|
assert "thresholds" in yaml_sections
|
||||||
|
assert "dns" in yaml_sections
|
||||||
|
assert yaml_sections["channels"]["api_section"] == "notification_channels"
|
||||||
|
assert yaml_sections["hosts"]["api_section"] == "hosts"
|
||||||
|
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
|
||||||
|
assert yaml_sections["dns"]["api_section"] == "dns"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_section_exists():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
oauth = next((s for s in sections if s["id"] == "oauth"), None)
|
||||||
|
assert oauth is not None
|
||||||
|
assert oauth["section_mode"] == "form"
|
||||||
|
assert oauth["api_section"] == "oauth"
|
||||||
|
assert len(oauth["providers"]) == 1
|
||||||
|
assert oauth["providers"][0]["name"] == "gitea"
|
||||||
|
assert oauth["providers"][0]["client_secret"] == "•••"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_channel_names_returned():
|
||||||
|
result = settings_mod.get_settings_data(CFG)
|
||||||
|
assert "all_channel_names" in result
|
||||||
|
assert "pushover_ops" in result["all_channel_names"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_users_section_has_user_list():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
users_sec = next(s for s in sections if s["id"] == "users")
|
||||||
|
assert users_sec["section_mode"] == "form"
|
||||||
|
assert users_sec["api_section"] == "users"
|
||||||
|
assert len(users_sec["users"]) == 1
|
||||||
|
assert users_sec["users"][0]["username"] == "alice"
|
||||||
|
# Password hash never exposed
|
||||||
|
assert "password" not in users_sec["users"][0]
|
||||||
Reference in New Issue
Block a user