diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6bc360b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM almalinux:9-minimal as base + +USER root + +RUN microdnf install \ + --assumeyes \ + --setopt=install_weak_deps=0 \ + --setopt=tsflags=nodocs \ + python3.12 \ + && microdnf clean all + +FROM base as build + +COPY requirements.txt /requirements.txt + +RUN microdnf install \ + --assumeyes \ + --setopt=install_weak_deps=0 \ + --setopt=tsflags=nodocs \ + python3.12-pip \ +&& microdnf clean all \ +&& python3.12 -m venv /venv \ +&& /venv/bin/python -m ensurepip --upgrade \ +&& /venv/bin/pip install --upgrade setuptools \ +&& /venv/bin/pip install \ + --disable-pip-version-check \ + -r /requirements.txt \ +&& rm /venv/bin/pip* + +FROM base + +WORKDIR /opt/app + +COPY . /opt/app/ +COPY --from=build /venv /venv + +EXPOSE 80 + +ENTRYPOINT [ "/venv/bin/python", "nagstamon.py" ] + diff --git a/Nagstamon/headless/__init__.py b/Nagstamon/headless/__init__.py new file mode 100644 index 00000000..aa10ef85 --- /dev/null +++ b/Nagstamon/headless/__init__.py @@ -0,0 +1,97 @@ +import os +import secrets +import sys +import uvicorn +from typing import Annotated +from fastapi import Depends, FastAPI, APIRouter, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + + +from Nagstamon.Config import (conf) +from Nagstamon.Servers import (servers) + + +class HeadlessMode: + security = HTTPBasic() + ENVVAR_NAME_ADDRESS = 'NAGSTAMON_HEADLESS_ADDRESS' + ENVVAR_NAME_PORT = 'NAGSTAMON_HEADLESS_PORT' + ENVVAR_NAME_BASICAUTH_USER = 'NAGSTAMON_HEADLESS_BASICAUTH_USER' + ENVVAR_NAME_BASICAUTH_PASSWORD = 'NAGSTAMON_HEADLESS_BASICAUTH_PASSWORD' + + address = '0.0.0.0' + port = 80 + basicauth_user = None + basicauth_password = None + + """ + initialize headless rest api server + """ + def __init__(self, argv=None): + + if not (self.ENVVAR_NAME_BASICAUTH_USER in os.environ and self.ENVVAR_NAME_BASICAUTH_PASSWORD in os.environ): + print(f"For headless mode the following envvars must be set for basic auth: {self.ENVVAR_NAME_BASICAUTH_USER}, {self.ENVVAR_NAME_BASICAUTH_PASSWORD}") + sys.exit(1) + else: + self.basicauth_user = os.environ[self.ENVVAR_NAME_BASICAUTH_USER] + self.basicauth_password = os.environ[self.ENVVAR_NAME_BASICAUTH_PASSWORD] + + self.address = os.getenv(self.ENVVAR_NAME_ADDRESS, self.address) + self.port = int(os.getenv(self.ENVVAR_NAME_PORT, self.port)) + + self.check_servers() + + self.api = FastAPI() + + self.router = APIRouter() + self.router.add_api_route("/hosts", self.get_hosts, methods=["GET"], dependencies=[Depends(self.check_user)]) + self.api.include_router(self.router) + + uvicorn.run(self.api, host=self.address, port=self.port) + + def check_user(self, credentials: Annotated[HTTPBasicCredentials, Depends(security)]): + current_username_bytes = credentials.username.encode("utf8") + correct_username_bytes = str.encode(self.basicauth_user, "utf-8") + is_correct_username = secrets.compare_digest( + current_username_bytes, correct_username_bytes + ) + current_password_bytes = credentials.password.encode("utf8") + correct_password_bytes = str.encode(self.basicauth_password, "utf-8") + is_correct_password = secrets.compare_digest( + current_password_bytes, correct_password_bytes + ) + if not (is_correct_username and is_correct_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Basic"}, + ) + return credentials.username + + def check_servers(self): + """ + check if there are any servers configured and enabled + """ + # no server is configured + if len(servers) == 0: + print('no_server') + sys.exit(1) + # no server is enabled + elif len([x for x in conf.servers.values() if x.enabled is True]) == 0: + print('no_server_enabled') + sys.exit(1) + + # TODOs + # * add caching + # * don't authenticate on each request + def get_hosts(self): + all_hosts = [] + for server in servers.values(): + if server.enabled: + server.init_config() + status = server.GetStatus() + all_hosts.append(server.hosts) + + return all_hosts + + +APP = HeadlessMode(sys.argv) diff --git a/grafana-dashboard.json b/grafana-dashboard.json new file mode 100644 index 00000000..35342e9e --- /dev/null +++ b/grafana-dashboard.json @@ -0,0 +1,332 @@ +{ + "__inputs": [ + { + "name": "DS_YESOREYERAM-INFINITY-DATASOURCE", + "label": "yesoreyeram-infinity-datasource", + "description": "", + "type": "datasource", + "pluginId": "yesoreyeram-infinity-datasource", + "pluginName": "Infinity" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "11.2.0" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + }, + { + "type": "datasource", + "id": "yesoreyeram-infinity-datasource", + "name": "Infinity", + "version": "2.10.0" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": true, + "panels": [ + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "${DS_YESOREYERAM-INFINITY-DATASOURCE}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Host" + }, + "properties": [ + { + "id": "custom.width", + "value": 104 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Service" + }, + "properties": [ + { + "id": "custom.width", + "value": 329 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Status" + }, + "properties": [ + { + "id": "custom.width", + "value": 140 + }, + { + "id": "mappings", + "value": [ + { + "options": { + "CRITICAL": { + "color": "dark-red", + "index": 0 + }, + "HIGH": { + "color": "dark-red", + "index": 3 + }, + "UNKNOWN": { + "color": "dark-purple", + "index": 2 + }, + "WARNING": { + "color": "yellow", + "index": 1 + } + }, + "type": "value" + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "applyToRow": true, + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Duration" + }, + "properties": [ + { + "id": "custom.width", + "value": 166 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Last Check" + }, + "properties": [ + { + "id": "custom.width", + "value": 181 + } + ] + } + ] + }, + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.2.0", + "targets": [ + { + "columns": [ + { + "selector": "service", + "text": "Service", + "type": "string" + }, + { + "selector": "status", + "text": "Status", + "type": "string" + }, + { + "selector": "acknowledged", + "text": "Acknowledged", + "type": "boolean" + }, + { + "selector": "host", + "text": "Host", + "type": "string" + }, + { + "selector": "status_information", + "text": "Status Information", + "type": "string" + }, + { + "selector": "last_check", + "text": "Last Check", + "type": "string" + }, + { + "selector": "duration", + "text": "Duration", + "type": "string" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "${DS_YESOREYERAM-INFINITY-DATASOURCE}" + }, + "filterExpression": "acknowledged == false && status != \"INFORMATION\"", + "filters": [], + "format": "table", + "global_query_id": "", + "parser": "backend", + "refId": "A", + "root_selector": "$.*.services.*", + "source": "url", + "type": "json", + "url": "http://:/hosts", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "transformations": [ + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "desc": false, + "field": "Status" + } + ] + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Acknowledged": true, + "address": true, + "allow_manual_close": true + }, + "includeByName": {}, + "indexByName": { + "Acknowledged": 0, + "Duration": 5, + "Host": 1, + "Last Check": 4, + "Service": 2, + "Status": 3, + "Status Information": 6 + }, + "renameByName": {} + } + } + ], + "type": "table" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "hidden": true, + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "browser", + "title": "nagstamon", + "uid": "cdzsolnsgexa8f", + "version": 4, + "weekStart": "" +} \ No newline at end of file diff --git a/grafana-dashboard.png b/grafana-dashboard.png new file mode 100644 index 00000000..c75fa704 Binary files /dev/null and b/grafana-dashboard.png differ diff --git a/headless.md b/headless.md new file mode 100644 index 00000000..57eadb10 --- /dev/null +++ b/headless.md @@ -0,0 +1,63 @@ +## Overview +Nagstamon headless mode can be used to query all data fetched by Nagstamon from all enabled servers allowing a unified view of all alerts from all configured servers (just like Nagstamon does on desktop). +The data retrieved from the REST api can then be used for example in Grafana to visualize all open alerts. + +## Server passwords +Using Nagstamon in headless mode in containers where no keyring is available the option "Use system keyring" must be disabled for saving the server passwords to the server config files. +Make sure the `password` key is not empty in your servers config. + +### Grafana example +Using the datasource plugin [yesoreyeram-infinity-datasource](https://grafana.com/docs/plugins/yesoreyeram-infinity-datasource/latest/) you can visualize all data from the datasource. + +#### Datasource configuration +* Authentication + * Auth type: Basic Authentication + * User Name: + * Password: + * Allowed Hosts: http(s)://[:] +* URL, Headers & Params + * Base URL: http(s)://[:] +* Health check + * Enable custom health check: true + * Health check URL: http(s)://[:]/hosts + +#### Grafana Dashboard +Sample dashboard: +![Dashboard](grafana-dashboard.png "Grafana Dashboard") + +The dashboard can be imported from this json (datasource needs to be configured manually): [Dashboard json](grafana-dashboard.json) + +## Build container +If you want to build the container yourself it can be built like this: +``` +docker buildx build -t friesoft/nagstamon-headless:0.0.1 . +``` + +## Docker Standalone with same Nagstamon config directory from Windows install +``` +docker run -it -p 80:80 -v C:\Users\fries\.nagstamon:/root/.nagstamon:ro -e NAGSTAMON_HEADLESS=true -e NAGSTAMON_HEADLESS_BASICAUTH_USER=nagstamon -e NAGSTAMON_HEADLESS_BASICAUTH_PASSWORD=test friesoft/nagstamon-headless:0.0.1 +``` + +Sample call to REST API: +``` +curl http://127.0.0.1:80/hosts -u nagstamon:test +``` + +## docker compose +``` +--- +version: '3.9' +services: + nagstamon-headless: + container_name: nagstamon-headless + image: friesoft/nagstamon-headless:0.0.1 + restart: unless-stopped + volumes: + - /volume1/docker/nagstamon-headless:/root/.nagstamon:ro + environment: + - NAGSTAMON_HEADLESS=true # mandatory + - NAGSTAMON_HEADLESS_ADDRESS=0.0.0.0 # default value + - NAGSTAMON_HEADLESS_PORT=80 # default value + - NAGSTAMON_HEADLESS_BASICAUTH_USER=nagstamon # mandatory + - NAGSTAMON_HEADLESS_BASICAUTH_PASSWORD=test # mandatory +``` \ No newline at end of file diff --git a/nagstamon.py b/nagstamon.py index 4c5db0b4..3aa38c03 100755 --- a/nagstamon.py +++ b/nagstamon.py @@ -20,6 +20,7 @@ import sys import socket +import os # fix/patch for https://bugs.launchpad.net/ubuntu/+source/nagstamon/+bug/732544 socket.setdefaulttimeout(30) @@ -34,33 +35,37 @@ if OS == OS_WINDOWS: import pip_system_certs.wrapt_requests - from Nagstamon.Helpers import lock_config_folder + headless = os.getenv("NAGSTAMON_HEADLESS", 'False').lower() in ('true', '1', 't') + if headless: + from Nagstamon.headless import (APP) + else: + from Nagstamon.Helpers import lock_config_folder - # Acquire the lock - if not lock_config_folder(conf.configdir): - print('An instance is already running this config ({})'.format(conf.configdir)) - sys.exit(1) + # Acquire the lock + if not lock_config_folder(conf.configdir): + print('An instance is already running this config ({})'.format(conf.configdir)) + sys.exit(1) - # get GUI - from Nagstamon.QUI import (APP, - statuswindow, - check_version, - check_servers, - QT_FLAVOR, - QT_VERSION_STR) + # get GUI + from Nagstamon.QUI import (APP, + statuswindow, + check_version, + check_servers, + QT_FLAVOR, + QT_VERSION_STR) - # ask for help if no servers are configured - check_servers() + # ask for help if no servers are configured + check_servers() - # show and resize status window - statuswindow.show() - if not conf.fullscreen: - statuswindow.adjustSize() + # show and resize status window + statuswindow.show() + if not conf.fullscreen: + statuswindow.adjustSize() - if conf.check_for_new_version is True: - check_version.check(start_mode=True, parent=statuswindow) + if conf.check_for_new_version is True: + check_version.check(start_mode=True, parent=statuswindow) - sys.exit(APP.exec()) + sys.exit(APP.exec()) except Exception as err: import traceback diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..401dcf01 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# required by headless mode +setuptools~=75.1.0 +requests~=2.32.3 +beautifulsoup4~=4.12.3 +urllib3~=2.2.3 +python-dateutil~=2.9.0.post0 +arrow~=1.3.0 +uvicorn~=0.30.6 +fastapi~=0.115.0 +keyring~=25.4.1 +psutil~=6.0.0