From 714990bffb93ef71895be0cf82d1fab883b1167a Mon Sep 17 00:00:00 2001 From: mellis Date: Fri, 12 Jan 2024 12:23:21 +0100 Subject: [PATCH] MKP 2.4.0, added password store for app password, some changes for compatibility with NC 28 --- .gitignore | 2 +- README.md | 3 +- .../plugins/agent_based/nextcloud_info.py | 62 +++++++-- .../check_mk/agents/special/agent_nextcloud | 126 ++++++++++-------- local/share/check_mk/checks/agent_nextcloud | 2 +- .../web/plugins/wato/nextcloud_params.py | 31 ++--- mkp/Nextcloud-2.4.0.mkp | Bin 0 -> 10958 bytes 7 files changed, 137 insertions(+), 89 deletions(-) create mode 100644 mkp/Nextcloud-2.4.0.mkp diff --git a/.gitignore b/.gitignore index 18ff89d..2c68f37 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ __pycache__/ *.vsix # Exclude all "private" files (Ralf Mellis) -RaMe* +RM* # Exclude all "private" files (Torsten Behne) TB* diff --git a/README.md b/README.md index aaf6856..7952f4f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Nextcloud CheckMK Special Agent Monitors various aspects of Nextcloud instances like state, quota and disk usage of all users, number of apps with available updates, database php opcache hit rate and so on. Gives additional information regarding versions of Nextcloud, database, number of storages and active users etc. -Tested with Nextcloud 25/26/27. +Tested with Nextcloud 25/26/27/28. Tested only with MySQL/MariaDB as database backend. Feel free to report other working environments. @@ -9,6 +9,7 @@ Version History: -- |Date|Version|Changes| |----|-------|-------| +|2023/01/12|2.4.0|Integrated Password Store for App Password, some changes for compatibility with NC 28 added| |2023/11/26|2.3.4|Fixed agent crash if opcache_get_status is disabled by server settings| |2023/08/16|2.3.3|Fixed some misleading info strings regarding database opcache| |2023/08/12|2.3.2|MKP now compatible with CheckMK 2.2| diff --git a/local/lib/python3/cmk/base/plugins/agent_based/nextcloud_info.py b/local/lib/python3/cmk/base/plugins/agent_based/nextcloud_info.py index 4c1fd06..b7d4c2f 100644 --- a/local/lib/python3/cmk/base/plugins/agent_based/nextcloud_info.py +++ b/local/lib/python3/cmk/base/plugins/agent_based/nextcloud_info.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 from pprint import pprint +from datetime import datetime from .agent_based_api.v1 import register, render, Service, Result, State, Metric + def getStateUpper(levels, value): warn, crit = levels if value >= crit: @@ -10,6 +12,7 @@ def getStateUpper(levels, value): return State.WARN return State.OK + def getStateLower(levels, value): warn, crit = levels if value < crit: @@ -18,21 +21,32 @@ def getStateLower(levels, value): return State.WARN return State.OK + def discover_nextcloud_info(section): yield(Service()) - + + def check_nextcloud_info(params, section): for key in section: if key == "nextcloud": levels_free_space = params["levels_free_space"] levels_number_of_files = params["levels_number_of_files"] + # update infos are available only from Nextcloud version 28 onwards + try: + last_update = section[key]["last_update"] + update_available = section[key]["update_available"] + last_update_human = datetime.fromtimestamp(last_update) + except KeyError: + last_update = "Update information not available, update to at least version 28" + update_available = "False" + last_update_human = "Update information not available" status = section[key]["status"] free_space = section[key]["freespace"] version = section[key]["version"] php_version = section[key]["php_version"] webserver = section[key]["webserver"] num_files = section[key]["number_files"] - num_shares = section[key]["number_shares"] + num_shares = section[key]["number_shares"] # create graph for number of files and shares yield(Metric("nc_num_files", num_files)) @@ -40,13 +54,23 @@ def check_nextcloud_info(params, section): # create overall result summary = f"Status is {status}" - details = f"Nextcloud version: {version}\nPHP version: {php_version}\nWebserver: {webserver}\n\nNumber of files: {num_files}\nNumber of shares: {num_shares}\n\nFree space on disk: {render.bytes(free_space)}\n" + details = f"Nextcloud version: {version}\nLast update: {last_update_human}\nPHP version: {php_version}\nWebserver: {webserver}\n\nNumber of files: {num_files}\nNumber of shares: {num_shares}\n\nFree space on disk: {render.bytes(free_space)}\n" if status == "ok": state = State.OK else: state = State.CRIT yield Result(state=state, summary=summary, details=details) + # Create result for available updates + if update_available != "False": + state = State.WARN + notice = f"Update is available" + else: + state = State.OK + notice = "No update available" + if state != State.OK: + yield(Result(state=state, notice=notice)) + # Create result for free space on disk # Levels for free space are given in GBytes, we have to adjust this here warn, crit = levels_free_space @@ -90,15 +114,20 @@ def check_nextcloud_info(params, section): notice = f"Number of storages: {num_storages}\nNumber of home/local/other storages: {num_storages_home}/{num_storages_local}/{num_storages_other}" yield(Result(state=State.OK, notice=notice)) elif key == "apps": - num_apps_installed = section[key]["installed"] - num_apps_with_updates_available = section[key]["with_updates_available"] - # create graphs for number of apps - levels = params["levels_apps_with_updates_available"] - yield Metric("nc_num_apps_installed", num_apps_installed) - yield Metric("nc_apps_with_updates_available", num_apps_with_updates_available, levels=levels) - state = getStateUpper(levels, num_apps_with_updates_available) - notice = f"Number of installed apps: {num_apps_installed}\nNumber of apps with updates available: {num_apps_with_updates_available}" - yield(Result(state=state, notice=notice)) + # Workaround for Nextcloud 28, "apps" info is not always available + try: + num_apps_installed = section[key]["installed"] + num_apps_with_updates_available = section[key]["with_updates_available"] + # create graphs for number of apps + levels = params["levels_apps_with_updates_available"] + yield Metric("nc_num_apps_installed", num_apps_installed) + yield Metric("nc_apps_with_updates_available", num_apps_with_updates_available, levels=levels) + state = getStateUpper(levels, num_apps_with_updates_available) + notice = f"Number of installed apps: {num_apps_installed}\nNumber of apps with updates available: {num_apps_with_updates_available}" + yield(Result(state=state, notice=notice)) + except KeyError: + pass + def parse_nextcloud_info_section(string_table): parsed_data = { @@ -111,6 +140,8 @@ def parse_nextcloud_info_section(string_table): "NC_Version", "NC_Freespace", "NC_Status", + "NC_Last_Update", + "NC_Update_Available", "NC_Webserver", "NC_PHP_Version", "NC_Num_Users", @@ -136,6 +167,10 @@ def parse_nextcloud_info_section(string_table): parsed_data["nextcloud"]["freespace"] = float(value) elif param == "NC_Status": parsed_data["nextcloud"]["status"] = value + elif param == "NC_Last_Update": + parsed_data["nextcloud"]["last_update"] = int(value) + elif param == "NC_Update_Available": + parsed_data["nextcloud"]["update_available"] = value elif param == "NC_Webserver": parsed_data["nextcloud"]["webserver"] = value elif param == "NC_PHP_Version": @@ -164,13 +199,16 @@ def parse_nextcloud_info_section(string_table): parsed_data["users"]["active_last1hour"] = int(value) elif param == "NC_Active_Users_Last_1Day": parsed_data["users"]["active_last1day"] = int(value) + #pprint(parsed_data) return parsed_data + register.agent_section( name="nextcloud_info", parse_function=parse_nextcloud_info_section, ) + register.check_plugin( name="nextcloud_info", service_name="Nextcloud Info", diff --git a/local/share/check_mk/agents/special/agent_nextcloud b/local/share/check_mk/agents/special/agent_nextcloud index 67a907c..7a01523 100755 --- a/local/share/check_mk/agents/special/agent_nextcloud +++ b/local/share/check_mk/agents/special/agent_nextcloud @@ -9,6 +9,7 @@ import os from pprint import pprint from requests.structures import CaseInsensitiveDict from requests.auth import HTTPBasicAuth +import cmk.utils.password_store def showUsage(): sys.stderr.write("""CheckMK Nextcloud Special Agent @@ -34,7 +35,7 @@ OPTIONS: # be aware: activating this logs very sensitive information to debug files in ~/tmp # !!DO NOT FORGET to delete these files after debugging is done!! -DEBUG = False +DEBUG = True nc_api_endpoint = "ocs/v2.php/apps/serverinfo/api/v1/info?format=json" nc_api_endpoint_all_users = "ocs/v1.php/cloud/users?format=json" @@ -54,6 +55,15 @@ long_options = [ 'hostname=', 'username=', 'password=', 'port=', 'token=', 'folder=', 'no-https=', 'no-cert-check=', 'help' ] +def logDebug(line): + if DEBUG: + home_path = os.getenv("HOME") + tmp_path = f"{home_path}/tmp" + help_file = f"{tmp_path}/nextcloud_{opt_hostname}_{opt_port}_debug.txt" + with open(help_file, "a") as file: + file.write(line) + + def getOptions(): global opt_hostname global opt_username @@ -91,12 +101,7 @@ def getOptions(): elif opt in ['-h', '--help']: showUsage() sys.exit(0) - if DEBUG: - home_path = os.getenv("HOME") - tmp_path = f"{home_path}/tmp" - help_file = f"{tmp_path}/nextcloud_{opt_hostname}_debug.txt" - with open(help_file, "a") as file: - file.write(f"Number of Arguments: {len(sys.argv)}, Argument List: {str(sys.argv)}\n") + logDebug(f"getOptions - Number of Arguments: {len(sys.argv)}, Argument List: {str(sys.argv)}\n") def showOptions(): print(f"Hostname: {opt_hostname}") @@ -107,48 +112,41 @@ def showOptions(): print(f"Token: {opt_token}") print(f"No HTTPS: {opt_no_https}") print(f"No TLS Check: {opt_no_cert_check}") - home_path = os.getenv("HOME") - tmp_path = f"{home_path}/tmp" - help_file = f"{tmp_path}/nextcloud_{opt_hostname}_debug.txt" - with open(help_file, "a") as file: - file.write(f"Hostname: {opt_hostname}, Port: {opt_port}, No HTTPS: {opt_no_https}, No Cert Check: {opt_no_cert_check}\n") + logDebug(f"showOptions - Hostname: {opt_hostname}, Port: {opt_port}, No HTTPS: {opt_no_https}, No Cert Check: {opt_no_cert_check}\n") def createUrl(endpoint, hostname, protocol, port, folder): + # these parameters are needed, otherwise no information about updates regarding apps and Nextcloud itself are not reported (since version 28) + params = "skipApps=false&skipUpdate=false" if folder == "": url = f"{protocol}://{hostname}:{port}/{endpoint}" else: url = f"{protocol}://{hostname}:{port}/{folder}/{endpoint}" - if DEBUG: - home_path = os.getenv("HOME") - tmp_path = f"{home_path}/tmp" - help_file = f"{tmp_path}/nextcloud_{hostname}_debug.txt" - with open(help_file, "a") as file: - file.write(f"Data URL: {url}\n") + if endpoint == nc_api_endpoint: + url = f"{url}&{params}" + logDebug(f"createUrl - Data URL: {url}\n") return url def createUrlUser(user, endpoint, hostname, protocol, port, folder): + params = "format=json" if folder == "": - url = f"{protocol}://{hostname}:{port}/{endpoint}/{user}?format=json" + url = f"{protocol}://{hostname}:{port}/{endpoint}/{user}?{params}" else: - url = f"{protocol}://{hostname}:{port}/{folder}/{endpoint}/{user}?format=json" - if DEBUG: - home_path = os.getenv("HOME") - tmp_path = f"{home_path}/tmp" - help_file = f"{tmp_path}/nextcloud_{hostname}_debug.txt" - with open(help_file, "a") as file: - file.write(f"User URL: {url}\n") + url = f"{protocol}://{hostname}:{port}/{folder}/{endpoint}/{user}?{params}" + logDebug(f"createUrlUser - User URL: {url}\n") return url -def getData(url, verify): +def getSession(username, secret): + session = requests.session() + session.cookies.set("SameSite", "Strict") + session.auth = (username, secret) + session.headers['Accept'] = "application/json" + return session + +def getData(session, url, verify): headers = CaseInsensitiveDict() headers["Accept"] = "application/json" - if (opt_token == '0'): - # authenticate with username and password - pwd = opt_password - else: - # authenticate with token - pwd = opt_token - response = requests.get(url, auth=HTTPBasicAuth(opt_username, pwd), headers=headers, verify=verify) + cookies = {"nc_sameSiteCookiestrict": "true"} + response = session.get(url, headers=headers, cookies=cookies, verify=verify) status = response.status_code if (status == 200): jsdata = response.text @@ -162,17 +160,12 @@ def getData(url, verify): sys.stderr.write(f"Request response code is {response.status_code} with URL {url}\n") sys.exit(1) -def getDataAllUsers(url, verify): +def getDataAllUsers(session, url, verify): headers = CaseInsensitiveDict() headers["Accept"] = "application/json" headers["OCS-APIRequest"] = "true" - if (opt_token == '0'): - # authenticate with username and password - pwd = opt_password - else: - # authenticate with token - pwd = opt_token - response = requests.get(url, auth=HTTPBasicAuth(opt_username, pwd), headers=headers, verify=verify) + cookies = {"nc_sameSiteCookiestrict": "true"} + response = session.get(url, headers=headers, cookies=cookies, verify=verify) status = response.status_code if (status == 200): jsdata = response.text @@ -182,18 +175,12 @@ def getDataAllUsers(url, verify): sys.stderr.write(f"Request response code is {response.status_code} with URL {url}\n") sys.exit(1) -def getDataUser(url, verify): - #print(url) +def getDataUser(session, url, verify): headers = CaseInsensitiveDict() headers["Accept"] = "application/json" headers["OCS-APIRequest"] = "true" - if (opt_token == '0'): - # authenticate with username and password - pwd = opt_password - else: - # authenticate with token - pwd = opt_token - response = requests.get(url, auth=HTTPBasicAuth(opt_username, pwd), headers=headers, verify=verify) + cookies = {"nc_sameSiteCookiestrict": "true"} + response = session.get(url, headers=headers, cookies=cookies, verify=verify) status = response.status_code if (status == 200): jsdata = response.text @@ -208,6 +195,12 @@ def doCmkOutput(data): print(f"NC_Version;{data['ocs']['data']['nextcloud']['system']['version']}") print(f"NC_Freespace;{data['ocs']['data']['nextcloud']['system']['freespace']}") print(f"NC_Status;{data['ocs']['meta']['status']}") + # This update info is available only from version 28 onwards, so the key "update" does not exist in all versions before + try: + print(f"NC_Last_Update;{data['ocs']['data']['nextcloud']['system']['update']['lastupdatedat']}") + print(f"NC_Update_Available;{data['ocs']['data']['nextcloud']['system']['update']['available']}") + except KeyError: + pass print(f"NC_Num_Users;{data['ocs']['data']['nextcloud']['storage']['num_users']}") print(f"NC_Num_Files;{data['ocs']['data']['nextcloud']['storage']['num_files']}") print(f"NC_Num_Shares;{data['ocs']['data']['nextcloud']['shares']['num_shares']}") @@ -215,8 +208,12 @@ def doCmkOutput(data): print(f"NC_Num_Storages_Home;{data['ocs']['data']['nextcloud']['storage']['num_storages_home']}") print(f"NC_Num_Storages_Local;{data['ocs']['data']['nextcloud']['storage']['num_storages_local']}") print(f"NC_Num_Storages_Other;{data['ocs']['data']['nextcloud']['storage']['num_storages_other']}") - print(f"NC_Num_Apps_Installed;{data['ocs']['data']['nextcloud']['system']['apps']['num_installed']}") - print(f"NC_Num_Apps_Updates_Available;{data['ocs']['data']['nextcloud']['system']['apps']['num_updates_available']}") + # Workaround for Nextcloud 28.0.1 (KeyError "apps") + try: + print(f"NC_Num_Apps_Installed;{data['ocs']['data']['nextcloud']['system']['apps']['num_installed']}") + print(f"NC_Num_Apps_Updates_Available;{data['ocs']['data']['nextcloud']['system']['apps']['num_updates_available']}") + except KeyError: + pass print(f"NC_Active_Users_Last_5Min;{data['ocs']['data']['activeUsers']['last5minutes']}") print(f"NC_Active_Users_Last_1Hour;{data['ocs']['data']['activeUsers']['last1hour']}") print(f"NC_Active_Users_Last_1Day;{data['ocs']['data']['activeUsers']['last24hours']}") @@ -234,11 +231,11 @@ def doCmkOutput(data): else: print(f"NC_OPCache_Hit_Rate;0") -def doCmkOutputAllUsers(data, verify, hostname, protocol, port, folder): +def doCmkOutputAllUsers(session, data, verify, hostname, protocol, port, folder): print("<<>>") for user in data['ocs']['data']['users']: nc_url = createUrlUser(user, nc_api_endpoint_user, hostname, protocol, port, folder) - user_data = getDataUser(nc_url, verify) + user_data = getDataUser(session, nc_url, verify) userid = user_data['ocs']['data']['id'] displayname = user_data['ocs']['data']['displayname'] lastlogin = int(user_data['ocs']['data']['lastLogin']) @@ -260,6 +257,8 @@ def doCmkOutputAllUsers(data, verify, hostname, protocol, port, folder): print(f"{userid};{displayname};{lastlogin};{quota_free};{quota_quota};{quota_relative};{quota_total};{quota_used}") def main(): + # replace password from pwd store + cmk.utils.password_store.replace_passwords() getOptions() if (opt_hostname == ""): sys.stderr.write(f"No hostname given.\n") @@ -290,13 +289,22 @@ def main(): sys.exit(1) if (protocol == "https" and port == "80"): sys.stderr.write(f"Combining HTTPS with port 80 is not supported.\n") - sys.exit(1) + sys.exit(1) + if (opt_token == '0'): + # authenticate with username and password + pwd = opt_password + else: + # authenticate with token + pwd = opt_token + + # create session + session = getSession(opt_username, pwd) nc_url = createUrl(nc_api_endpoint, opt_hostname, protocol, port, opt_folder) - nc_data = getData(nc_url, verify) + nc_data = getData(session, nc_url, verify) doCmkOutput(nc_data) nc_url = createUrl(nc_api_endpoint_all_users, opt_hostname, protocol, port, opt_folder) - nc_data = getDataAllUsers(nc_url, verify) - doCmkOutputAllUsers(nc_data, verify, opt_hostname, protocol, port, opt_folder) + nc_data = getDataAllUsers(session, nc_url, verify) + doCmkOutputAllUsers(session, nc_data, verify, opt_hostname, protocol, port, opt_folder) if __name__ == "__main__": main() \ No newline at end of file diff --git a/local/share/check_mk/checks/agent_nextcloud b/local/share/check_mk/checks/agent_nextcloud index 9d864e6..9f653a1 100644 --- a/local/share/check_mk/checks/agent_nextcloud +++ b/local/share/check_mk/checks/agent_nextcloud @@ -3,7 +3,7 @@ def agent_nextcloud_arguments(params, hostname, ipaddress): "--hostname", params["hostname"], "--username", params["username"], "--password", params["password"], - "--token", params["token"], + "--token", passwordstore_get_cmdline("%s", params["token"]), "--port", params["port"], "--folder", params["folder"], "--no-https", params["no_https"], diff --git a/local/share/check_mk/web/plugins/wato/nextcloud_params.py b/local/share/check_mk/web/plugins/wato/nextcloud_params.py index 32c524d..081d0d1 100644 --- a/local/share/check_mk/web/plugins/wato/nextcloud_params.py +++ b/local/share/check_mk/web/plugins/wato/nextcloud_params.py @@ -1,10 +1,13 @@ -from cmk.gui.i18n import _ -from cmk.gui.plugins.wato import ( - CheckParameterRulespecWithItem, - rulespec_registry, - RulespecGroupCheckParametersOperatingSystem, -) +#!/usr/bin/env python3 +from cmk.gui.i18n import _ +from cmk.gui.plugins.wato.special_agents.common import RulespecGroupDatasourceProgramsApps +from cmk.gui.plugins.wato.utils import ( + HostRulespec, + Rulespec, + IndividualOrStoredPassword, + rulespec_registry, +) from cmk.gui.valuespec import ( Dictionary, ListChoice, @@ -13,11 +16,8 @@ from cmk.gui.valuespec import ( Password, ) -from cmk.gui.plugins.wato import ( - HostRulespec, -) - -from cmk.gui.plugins.wato.datasource_programs import RulespecGroupDatasourceProgramsCustom +def _factory_default_special_agent_nextcloud(): + return Rulespec.FACTORY_DEFAULT_UNUSED def _valuespec_special_agent_nextcloud(): return Dictionary( @@ -33,7 +33,7 @@ def _valuespec_special_agent_nextcloud(): ("password", Password(title=_("Password"), allow_empty=True, help=_("Specify password OR token, not both, token recommended"))), - ("token", Password(title=_("Token"), + ("token", IndividualOrStoredPassword(title=_("App Password"), allow_empty=True, help=_("Specify password OR token, not both, token recommended"))), ("port", TextAscii(title=_("Port"), @@ -52,8 +52,9 @@ def _valuespec_special_agent_nextcloud(): rulespec_registry.register( HostRulespec( - group=RulespecGroupDatasourceProgramsCustom, - name="special_agents:nextcloud", - valuespec=_valuespec_special_agent_nextcloud, + factory_default = _factory_default_special_agent_nextcloud(), + group = RulespecGroupDatasourceProgramsApps, + name = "special_agents:nextcloud", + valuespec = _valuespec_special_agent_nextcloud, ) ) \ No newline at end of file diff --git a/mkp/Nextcloud-2.4.0.mkp b/mkp/Nextcloud-2.4.0.mkp new file mode 100644 index 0000000000000000000000000000000000000000..466145fabf3023fea66fcf3f41330d9f9b3092c7 GIT binary patch literal 10958 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 n$y literal 0 HcmV?d00001