From adad7192ed3c4d41aa86a3b5794c4f75349ab1d6 Mon Sep 17 00:00:00 2001 From: Jan Kantert Date: Tue, 25 Jul 2023 23:46:09 +0200 Subject: [PATCH 1/8] small cleanup of IcingaDBWebNotifications --- Nagstamon/Servers/IcingaDBWebNotifications.py | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/Nagstamon/Servers/IcingaDBWebNotifications.py b/Nagstamon/Servers/IcingaDBWebNotifications.py index 3d1c356df..278109af2 100644 --- a/Nagstamon/Servers/IcingaDBWebNotifications.py +++ b/Nagstamon/Servers/IcingaDBWebNotifications.py @@ -17,41 +17,16 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA -# Initial implementation by Marcus Mönnig -# -# This Server class connects against IcingaWeb2. The monitor URL in the setup should be -# something like http://icinga2/icingaweb2 -# -# Status/TODOs: -# -# * The IcingaWeb2 API is not implemented yet, so currently this implementation uses -# two HTTP requests per action. The first fetches the HTML, then the form data is extracted and -# then a second HTTP POST request is made which actually executed the action. -# Once IcingaWeb2 has an API, it's probably the better choice. - - -from Nagstamon.Servers.Generic import GenericServer -import urllib.parse +# Same as IcingaDBWeb but uses the notification endpoint to allow fine granular recipient management for alerts import sys import json import datetime -import socket from bs4 import BeautifulSoup from Nagstamon.Objects import (GenericHost, GenericService, Result) -from Nagstamon.Config import (conf, - AppInfo) -from Nagstamon.Helpers import webbrowser_open -from Nagstamon.Servers.IcingaDBWeb import IcingaDBWebServer - - -def strfdelta(tdelta, fmt): - d = {'days': tdelta.days} - d['hours'], rem = divmod(tdelta.seconds, 3600) - d['minutes'], d['seconds'] = divmod(rem, 60) - return fmt.format(**d) +from Nagstamon.Servers.IcingaDBWeb import IcingaDBWebServer, strfdelta class IcingaDBWebNotificationsServer(IcingaDBWebServer): @@ -243,4 +218,4 @@ def _update_new_host_content(self) -> Result: self.new_hosts[host_name].services[service_name].duration = 'n/a' # return success - return Result() \ No newline at end of file + return Result() From dbbc81f468a6a3b1962bc194bfac036e1eb0c594 Mon Sep 17 00:00:00 2001 From: Jan Kantert Date: Tue, 25 Jul 2023 23:48:14 +0200 Subject: [PATCH 2/8] add oauth2 application web flow to nagstamon --- Nagstamon/Helpers.py | 6 ++--- Nagstamon/QUI/__init__.py | 27 ++++++++++++++++++---- Nagstamon/Servers/Generic.py | 12 +++++++++- Nagstamon/resources/qui/settings_server.ui | 7 ++++++ build/requirements/linux.txt | 1 + build/requirements/macos.txt | 1 + build/requirements/windows.txt | 1 + 7 files changed, 47 insertions(+), 8 deletions(-) diff --git a/Nagstamon/Helpers.py b/Nagstamon/Helpers.py index 408ce4a9b..bdb429871 100644 --- a/Nagstamon/Helpers.py +++ b/Nagstamon/Helpers.py @@ -429,15 +429,15 @@ def compare_status_information(item): return(item.lower()) -def webbrowser_open(url): +def webbrowser_open(url, new=0): """ decide if default or custom browser is used for various tasks used by almost all """ if conf.use_default_browser: - webbrowser.open(url) + webbrowser.open(url, new=new) else: - webbrowser.get('{0} %s &'.format(conf.custom_browser)).open(url) + webbrowser.get('{0} %s &'.format(conf.custom_browser)).open(url, new=new) def get_distro(): diff --git a/Nagstamon/QUI/__init__.py b/Nagstamon/QUI/__init__.py index 66611ed1a..d430a2fe1 100644 --- a/Nagstamon/QUI/__init__.py +++ b/Nagstamon/QUI/__init__.py @@ -5815,6 +5815,11 @@ def __init__(self, dialog): self.window.label_idp_ecp_endpoint, self.window.input_lineedit_idp_ecp_endpoint] + self.AUTHENTICATION_OAUTH2_WEBFLOW_WIDGETS = [ + self.window.label_openid_configuration_endpoint, + self.window.input_lineedit_idp_ecp_endpoint + ] + # fill default order fields combobox with monitor server types self.window.input_combobox_type.addItems(sorted(SERVER_TYPES.keys(), key=str.lower)) # default to Nagios as it is the mostly used monitor server @@ -5828,14 +5833,14 @@ def __init__(self, dialog): self.window.button_choose_custom_cert_ca_file.clicked.connect(self.choose_custom_cert_ca_file) # fill authentication combobox - self.window.input_combobox_authentication.addItems(['Basic', 'Digest', 'Kerberos', 'Bearer']) + self.window.input_combobox_authentication.addItems(['Basic', 'Digest', 'Kerberos', 'Bearer', 'OAuth2 Web Flow']) if ECP_AVAILABLE is True: self.window.input_combobox_authentication.addItems(['ECP']) # detect change of server type which leads to certain options shown or hidden self.window.input_combobox_type.activated.connect(self.toggle_type) - # when authentication is changed to Kerberos then disable username/password as the are now useless + # when authentication is changed to Kerberos then disable username/password as they are now useless self.window.input_combobox_authentication.activated.connect(self.toggle_authentication) # reset Checkmk views @@ -5874,15 +5879,29 @@ def toggle_authentication(self): for widget in self.AUTHENTICATION_ECP_WIDGETS: widget.hide() + if self.window.input_combobox_authentication.currentText() == 'OAuth2 Web Flow': + for widget in self.AUTHENTICATION_OAUTH2_WEBFLOW_WIDGETS: + widget.show() + else: + for widget in self.AUTHENTICATION_OAUTH2_WEBFLOW_WIDGETS: + widget.hide() + # change credential input for bearer auth if self.window.input_combobox_authentication.currentText() == 'Bearer': for widget in self.AUTHENTICATION_BEARER_WIDGETS: widget.hide() - self.window.label_password.setText('Token') else: for widget in self.AUTHENTICATION_BEARER_WIDGETS: widget.show() - self.window.label_password.setText('Password') + + if self.window.input_combobox_authentication.currentText() == 'Bearer': + self.window.label_password.setText('Token:') + elif self.window.input_combobox_authentication.currentText() == 'OAuth2 Web Flow': + self.window.label_username.setText('Client ID:') + self.window.label_password.setText('Client Secret:') + else: + self.window.label_username.setText('Username:') + self.window.label_password.setText('Password:') # after hiding authentication widgets dialog might shrink self.window.adjustSize() diff --git a/Nagstamon/Servers/Generic.py b/Nagstamon/Servers/Generic.py index 0aca42590..74e1b074f 100644 --- a/Nagstamon/Servers/Generic.py +++ b/Nagstamon/Servers/Generic.py @@ -32,6 +32,8 @@ from bs4 import BeautifulSoup import requests +from Nagstamon.Servers.Oauth2WebFlow import get_oauth2_session + # check ECP authentication support availability try: from requests_ecp import HTTPECPAuth @@ -309,7 +311,6 @@ def create_session(self): different between various server types """ session = requests.Session() - session.headers['User-Agent'] = self.USER_AGENT # support for different authentication types if self.authentication == 'basic': @@ -323,6 +324,14 @@ def create_session(self): session.auth = HTTPSKerberos() elif self.authentication == 'bearer': session.auth = BearerAuth(self.password) + elif self.authentication == 'oauth2 web flow': + session = get_oauth2_session( + client_id=self.username, + client_secret=self.password, + openid_config_url=self.idp_ecp_endpoint, + port=9000) + + session.headers['User-Agent'] = self.USER_AGENT # default to check TLS validity if self.ignore_cert: @@ -1535,6 +1544,7 @@ def FetchURL(self, url, giveback='obj', cgi_data=None, no_auth=False, multipart= del temporary_session except Exception: + self.reset_HTTP() # reset session to refresh auth and cookies if conf.debug_mode: self.Error(sys.exc_info()) result, error = self.Error(sys.exc_info()) diff --git a/Nagstamon/resources/qui/settings_server.ui b/Nagstamon/resources/qui/settings_server.ui index 170f57e2c..b361b91c1 100644 --- a/Nagstamon/resources/qui/settings_server.ui +++ b/Nagstamon/resources/qui/settings_server.ui @@ -402,6 +402,13 @@ + + + + Open ID Configuration URL + + + diff --git a/build/requirements/linux.txt b/build/requirements/linux.txt index e5b95c101..794824395 100644 --- a/build/requirements/linux.txt +++ b/build/requirements/linux.txt @@ -12,3 +12,4 @@ requests requests-kerberos requests-ecp setuptools +requests_oauthlib \ No newline at end of file diff --git a/build/requirements/macos.txt b/build/requirements/macos.txt index cda41e2fc..4b761838c 100644 --- a/build/requirements/macos.txt +++ b/build/requirements/macos.txt @@ -15,3 +15,4 @@ requests-ecp # gssapi instead kerberos requests-gssapi setuptools +requests_oauthlib \ No newline at end of file diff --git a/build/requirements/windows.txt b/build/requirements/windows.txt index d73e7b084..75684cdb1 100644 --- a/build/requirements/windows.txt +++ b/build/requirements/windows.txt @@ -16,3 +16,4 @@ requests-ecp requests-kerberos setuptools wheel +requests_oauthlib \ No newline at end of file From 299c5ea8e2b8fa3476b5b0c1dade9306e1c88f00 Mon Sep 17 00:00:00 2001 From: Jan Kantert Date: Wed, 26 Jul 2023 10:56:55 +0200 Subject: [PATCH 3/8] do not ignore nagstamon directory in git --- .gitignore | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index f1f64bf6f..6b2002304 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,7 @@ __pycache__/ *.swp *.zip *~ -build/bdist* -dist/ -nagstamon -nagstamon.conf/ -nagstamon.egg-info/ \ No newline at end of file +/build/bdist* +/dist/ +/nagstamon.conf/ +/nagstamon.egg-info/ From f4c9635ac7726eea7aed7928fc677e57ff02edf1 Mon Sep 17 00:00:00 2001 From: Jan Kantert Date: Wed, 26 Jul 2023 10:57:30 +0200 Subject: [PATCH 4/8] ignore certs --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6b2002304..705298a0d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ __pycache__/ /dist/ /nagstamon.conf/ /nagstamon.egg-info/ +/certifi/ From 5da3d1f0d2a4689d9dfe5ab1a94f55c245b6d606 Mon Sep 17 00:00:00 2001 From: Jan Kantert Date: Wed, 26 Jul 2023 10:57:50 +0200 Subject: [PATCH 5/8] add oauth2 web flow file --- Nagstamon/Servers/Oauth2WebFlow.py | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 Nagstamon/Servers/Oauth2WebFlow.py diff --git a/Nagstamon/Servers/Oauth2WebFlow.py b/Nagstamon/Servers/Oauth2WebFlow.py new file mode 100644 index 000000000..d5a215c90 --- /dev/null +++ b/Nagstamon/Servers/Oauth2WebFlow.py @@ -0,0 +1,87 @@ +"""Oauth2 Authorization Code Grant/Web Application Flow/Standard Flow with local webserver.""" +import requests +import http.server +import socketserver +from urllib.parse import urlparse, parse_qs +from requests_oauthlib import OAuth2Session +import logging + +from Nagstamon.Helpers import webbrowser_open + +log = logging.getLogger(__name__) + + +class LocalOauthServer(http.server.BaseHTTPRequestHandler): + + """Local Oauth server to receive authorization code.""" + + def __init__(self, request, client_address, server): + super().__init__(request, client_address, server) + if not hasattr(server, "code"): + server.code = None + + def do_GET(self): + url_parse = urlparse(self.path) + params = parse_qs(url_parse.query) + if "code" in params: + self.server.code = params["code"][0] + print(params["code"][0]) + self.protocol_version = 'HTTP/1.1' + self.send_response(200, 'OK') + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(bytes( + "Nagstamon Oauth Login" + "" + "You have logged in Nagstamon. You can close this window.", + 'UTF-8')) + + +class OauthTcpServer(socketserver.TCPServer): + + def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True): + self.allow_reuse_address = True # we will restart this server frequently + self.code = None # used to store Oauth code from handler + super().__init__(server_address, RequestHandlerClass, bind_and_activate) + + +def get_oauth2_session(client_id, client_secret, openid_config_url, port: int, timeout: int = 180) -> OAuth2Session: + """Login via Oauth2 and return an authenticated session.""" + redirect_uri = "http://localhost:{}/".format(port) + + openid_configuration = requests.get(openid_config_url).json() + oauth = OAuth2Session(client_id=client_id, + redirect_uri=redirect_uri, + auto_refresh_kwargs={ + "client_id": client_id, + "client_secret": client_secret + }, + auto_refresh_url=openid_configuration["token_endpoint"], + token_updater=lambda x: None + ) + authorization_url, state = oauth.authorization_url(openid_configuration["authorization_endpoint"]) + + server = OauthTcpServer(("localhost", port), LocalOauthServer) + server.timeout = 10 + max_tries = 10 + tries = max_tries + + log.debug("Starting local webserver and opening OAuth provider in browser") + with server as httpd: + while server.code is None and tries > 0: + webbrowser_open(authorization_url, new=2) + httpd.handle_request() + tries -= 1 + + if server.code is None: + log.warning("Login timed out (%ss) after %s tries.", timeout, max_tries) + raise TimeoutError("Login timed out (%ss).") + else: + log.debug("Received auth code.") + + oauth.fetch_token(token_url=openid_configuration["token_endpoint"], + code=server.code, + client_secret=client_secret) + log.debug("Fetched token. Authenticated successfully.") + + return oauth From ab554b84c089d5385ea5ea1502cd0f7e192b3cef Mon Sep 17 00:00:00 2001 From: Jan Kantert Date: Wed, 26 Jul 2023 11:21:56 +0200 Subject: [PATCH 6/8] add header to make it easier at the server to switch between OIDC and OAuth2 --- Nagstamon/Servers/Generic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Nagstamon/Servers/Generic.py b/Nagstamon/Servers/Generic.py index 74e1b074f..c1b001f55 100644 --- a/Nagstamon/Servers/Generic.py +++ b/Nagstamon/Servers/Generic.py @@ -330,6 +330,7 @@ def create_session(self): client_secret=self.password, openid_config_url=self.idp_ecp_endpoint, port=9000) + session.headers['X-OAuth2'] = "1" session.headers['User-Agent'] = self.USER_AGENT From c933924e7d353513844899b87995f96768f8604e Mon Sep 17 00:00:00 2001 From: Jan Kantert Date: Mon, 7 Aug 2023 12:00:58 +0200 Subject: [PATCH 7/8] do not crash when session creation fails for some reason --- Nagstamon/Servers/Generic.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Nagstamon/Servers/Generic.py b/Nagstamon/Servers/Generic.py index c1b001f55..b338127db 100644 --- a/Nagstamon/Servers/Generic.py +++ b/Nagstamon/Servers/Generic.py @@ -301,7 +301,13 @@ def init_HTTP(self): self.session = None return False elif self.session is None: - self.session = self.create_session() + try: + self.session = self.create_session() + except Exception as e: + print("Error while creating session: {}".format(e)) + self.refresh_authentication = True + self.session = None + return False return True def create_session(self): From 375d902c31ec6d64ea4e3ac718e712ae2302783a Mon Sep 17 00:00:00 2001 From: Jan Kantert Date: Fri, 11 Aug 2023 16:42:47 +0200 Subject: [PATCH 8/8] small output improvement --- Nagstamon/Servers/Generic.py | 1 - Nagstamon/Servers/IcingaDBWebNotifications.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Nagstamon/Servers/Generic.py b/Nagstamon/Servers/Generic.py index b338127db..e4441895e 100644 --- a/Nagstamon/Servers/Generic.py +++ b/Nagstamon/Servers/Generic.py @@ -305,7 +305,6 @@ def init_HTTP(self): self.session = self.create_session() except Exception as e: print("Error while creating session: {}".format(e)) - self.refresh_authentication = True self.session = None return False return True diff --git a/Nagstamon/Servers/IcingaDBWebNotifications.py b/Nagstamon/Servers/IcingaDBWebNotifications.py index 278109af2..86659da30 100644 --- a/Nagstamon/Servers/IcingaDBWebNotifications.py +++ b/Nagstamon/Servers/IcingaDBWebNotifications.py @@ -112,7 +112,7 @@ def _update_new_host_content(self) -> Result: self.new_hosts[host_name].status = self.STATES_MAPPING['hosts'][status_numeric] self.new_hosts[host_name].last_check = datetime.datetime.fromtimestamp(int(float(notification['host']['state']['last_update']))) self.new_hosts[host_name].attempt = "{}/{}".format(notification['host']['state']['check_attempt'],notification['host']['max_check_attempts']) - self.new_hosts[host_name].status_information = BeautifulSoup(notification['host']['state']['output'].replace('\n', ' ').strip(), 'html.parser').text + self.new_hosts[host_name].status_information = BeautifulSoup(notification['text'].replace('\n', ' ').strip(), 'html.parser').text self.new_hosts[host_name].passiveonly = not int(notification['host'].get('active_checks_enabled') or '0') self.new_hosts[host_name].notifications_disabled = not int(notification['host'].get('notifications_enabled') or '0') self.new_hosts[host_name].flapping = bool(int(notification['host']['state']['is_flapping'] or 0)) @@ -184,7 +184,7 @@ def _update_new_host_content(self) -> Result: self.new_hosts[host_name].services[service_name].status = self.STATES_MAPPING['services'][status_numeric] self.new_hosts[host_name].services[service_name].last_check = datetime.datetime.fromtimestamp(int(float(notification['service']['state']['last_update']))) - self.new_hosts[host_name].services[service_name].status_information = BeautifulSoup(notification['service']['state']['output'].replace('\n', ' ').strip(), 'html.parser').text + self.new_hosts[host_name].services[service_name].status_information = BeautifulSoup(notification['text'].replace('\n', ' ').strip(), 'html.parser').text self.new_hosts[host_name].services[service_name].passiveonly = not int(notification['service'].get('active_checks_enabled') or '0') self.new_hosts[host_name].services[service_name].notifications_disabled = not int(notification['service'].get('notifications_enabled') or '0') self.new_hosts[host_name].services[service_name].flapping = bool(int(notification['service']['state']['is_flapping'] or 0))