#!/usr/bin/env bash # # Author: Linuxfabrik GmbH, Zurich, Switzerland # Contact: info (at) linuxfabrik (dot) ch # https://www.linuxfabrik.ch/ # License: The Unlicense, see LICENSE file. # # One-liner installer for the Linuxfabrik Monitoring Plugins. # # Three install paths plus an uninstaller: # # --package (default) Detect the OS family from /etc/os-release, register the signed # Linuxfabrik repository and install the package with the system # package manager. Recommended, upgradeable. # --source Install the latest source straight from GitHub (no git client, no # package manager) into a self-contained venv. Because the # monitoring-plugins repo pulls in the shared `lib` from a SEPARATE # repo via a symlink, this fetches TWO tarballs (monitoring-plugins + # lib) and assembles them. # --zip Install the signed source zip from download.linuxfabrik.ch # (sha256 + GPG verified) into a venv. Defaults to the 'latest' alias # on the download server; pin an exact release with --version. For # air-gapped or version-pinned production hosts that cannot or do not # want to reach the repository. # --uninstall Reverse a previous install (package, source or zip). # # Usage (recommended path): # curl -fsSL https://repo.linuxfabrik.ch/install-monitoring-plugins | sudo bash # # Set DRY_RUN=1 to print every privileged action without executing it. set -eu -o pipefail # Everything we create has to stay readable and traversable for the monitoring user, which is # never the user running this script. Hardened hosts carry a restrictive umask (027 or 077), # and sudo hands the more restrictive of the caller's and the sudoers umask to us, so relying # on the inherited value leaves an unreadable venv and library behind. umask 022 # --- constants --------------------------------------------------------------------------- REPO_BASE_URL='https://repo.linuxfabrik.ch' DOWNLOAD_BASE_URL='https://download.linuxfabrik.ch/monitoring-plugins' KEY_URL="${REPO_BASE_URL}/linuxfabrik.key" PKG_NAME='linuxfabrik-monitoring-plugins' PKG_NAME_SELINUX='linuxfabrik-monitoring-plugins-selinux' # GitHub source for the --source path. Both repositories are public. GH_BASE='https://github.com/Linuxfabrik' GH_MP_REPO='monitoring-plugins' GH_LIB_REPO='lib' # We always install to lib64, even where the distro's own Nagios package uses lib. This # keeps sudoers rules and Icinga Director command definitions portable across distros. DEFAULT_PLUGIN_DIR='/usr/lib64/nagios/plugins' # Source/zip installs keep their state here, mirroring the RPM/DEB layout: a self-contained # dependency venv plus a manifest of every path placed, so --uninstall can reverse cleanly # even though the plugin directory is shared with the distro's own plugins. STATE_DIR='/usr/lib64/linuxfabrik-monitoring-plugins' VENV_DIR="${STATE_DIR}/venv" MANIFEST="${STATE_DIR}/install-manifest.txt" SUDOERS_DEST='/etc/sudoers.d/linuxfabrik-monitoring-plugins' # The bash completion goes to the legacy directory on purpose: bash-completion sources that one # at shell startup, while its own completions directory is loaded lazily by command name, which # would need one file per plugin and would collide with the files bash-completion ships for the # plugins named after a real command (ping, uptime, ...). BASH_COMPLETION_DEST='/etc/bash_completion.d/linuxfabrik-monitoring-plugins' # sudoers drop-ins this project placed under earlier names: 'monitoring-plugins' (the Ansible # source install) and 'icinga2-plugins' (the manual scp deployment documented in the old README). # They are removed on install and on uninstall so a host never carries the LF_NAGIOS Cmnd_Alias # twice, which makes sudo warn about a duplicate Cmnd_Alias on every invocation. LEGACY_SUDOERS_DESTS='/etc/sudoers.d/monitoring-plugins /etc/sudoers.d/icinga2-plugins' # --- defaults (overridable via flags / env) ---------------------------------------------- MODE='package' # 'package', 'source' or 'zip' ACTION='install' # 'install' or 'uninstall' SOURCE_REF='main' # branch or tag for the --source path ZIP_VERSION="${LFMP_VERSION:-}" # - for the --zip path PLUGIN_DIR="${DEFAULT_PLUGIN_DIR}" PYBIN='python3' # interpreter for the source/zip venv; override with --python PYBIN_EXPLICIT=0 # set to 1 once the operator pins one via --python DRY_RUN="${DRY_RUN:-0}" # --- helpers ----------------------------------------------------------------------------- # All diagnostics go to stderr so that functions returning a value via stdout (e.g. # fetch_and_extract) stay uncontaminated, in dry runs as well as real runs. log() { printf '\033[1;34m==>\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } # Run a command, honouring DRY_RUN. Privileged side effects must go through here so a # dry run stays read-only. run() { if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] %s\n' "$*" >&2 return 0 fi "$@" } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" } # apt-get, driven so that it never waits for an answer. This installer is meant to be piped into # `sudo bash`, so stdin is the pipe rather than a terminal: on an upgrade that touches a conffile # the admin has edited, dpkg asks what to do, reads EOF and aborts the transaction, leaving the # package unpacked but unconfigured. --force-confold keeps the local file, which is what the # interactive prompt defaults to anyway, and dpkg parks the maintainer's version next to it as # .dpkg-dist. DEBIAN_FRONTEND=noninteractive does NOT cover this on its own, the prompt # comes from dpkg and not from debconf (verified against apt 3.0.3 / dpkg 1.22.21 on Debian 13). apt_get() { run env DEBIAN_FRONTEND=noninteractive apt-get \ --option Dpkg::Options::=--force-confdef \ --option Dpkg::Options::=--force-confold \ "$@" } # Report conffiles dpkg could not replace because they differ from the shipped version. The # sudoers drop-in is the one that matters: it whitelists the plugins for sudo, so a stale local # copy leaves plugins added since that edit unable to run, and misses tightened rules. warn_conffile_leftovers() { [ "${DRY_RUN}" = '1' ] && return 0 local leftover for leftover in "${SUDOERS_DEST}.dpkg-dist" "${SUDOERS_DEST}.dpkg-new"; do if [ -e "${leftover}" ]; then warn "${SUDOERS_DEST} differs from the version this release ships;" warn "the shipped one is at ${leftover}. Compare them and merge what you need." fi done return 0 } require_root() { [ "${DRY_RUN}" = '1' ] && return 0 [ "$(id -u)" -eq 0 ] || die 'this installer must run as root (pipe to "sudo bash")' } # Record an absolute path we created, so --uninstall can reverse it. No-op in a dry run. record() { [ "${DRY_RUN}" = '1' ] && return 0 mkdir -p "${STATE_DIR}" printf '%s\n' "$1" >> "${MANIFEST}" } usage() { cat <<'EOF' Install the Linuxfabrik Monitoring Plugins. Usage: install-monitoring-plugins [OPTIONS] Modes (mutually exclusive; --package is the default): --package Register the signed package repository and install the package with the system package manager. Recommended, upgradeable. --source Install the latest source from GitHub into a venv (no git client, no package manager), instead of the package repository. (--git is accepted as an alias.) --zip Install the signed source zip from download.linuxfabrik.ch (sha256 + GPG verified) into a venv. Defaults to the latest release; pin with --version. For air-gapped or pinned hosts. --uninstall Reverse a previous package, source or zip install. Options: --ref=REF Branch or tag to install with --source (default: main). --version=VER Release for --zip (or set LFMP_VERSION): 'latest' (default) for the newest release, or a pinned -, e.g. 2.2.1-1. --plugin-dir=DIR Target plugin directory (default: /usr/lib64/nagios/plugins). --python=BIN Interpreter for the source/zip dependency venv (default: python3). Use a newer Python where the system python3 is too old, e.g. --python=python3.12 on RHEL 8. --help Show this help and exit. Environment: DRY_RUN=1 Print every privileged action without executing it. LFMP_VERSION=VER Same as --version for --zip. Examples: install-monitoring-plugins install-monitoring-plugins --source install-monitoring-plugins --zip install-monitoring-plugins --zip --version=2.2.1-1 install-monitoring-plugins --source --python=python3.12 install-monitoring-plugins --uninstall EOF } # --- OS detection ------------------------------------------------------------------------ # Sets OS_FAMILY (rhel|sle|debian|ubuntu), OS_ID and VERSION_CODENAME from os-release. detect_os() { [ -r /etc/os-release ] || die '/etc/os-release not found; unsupported system' # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-}" VERSION_CODENAME="${VERSION_CODENAME:-}" local like="${ID_LIKE:-}" case " ${OS_ID} ${like} " in *' rhel '*|*' fedora '*|*' centos '*) OS_FAMILY='rhel' ;; *' sles '*|*' suse '*|*' opensuse '*) OS_FAMILY='sle' ;; *' ubuntu '*) OS_FAMILY='ubuntu' ;; *' debian '*) OS_FAMILY='debian' ;; *) case "${OS_ID}" in rhel|rocky|almalinux|centos|ol|fedora) OS_FAMILY='rhel' ;; sles|sle*|opensuse*) OS_FAMILY='sle' ;; ubuntu) OS_FAMILY='ubuntu' ;; debian|raspbian) OS_FAMILY='debian' ;; *) die "unsupported distribution: ID=${OS_ID:-unknown}" ;; esac ;; esac log "detected ${OS_ID:-unknown} (family: ${OS_FAMILY})" } # --- downloader -------------------------------------------------------------------------- # Pick curl or wget once, expose a single download() interface. pick_downloader() { if command -v curl >/dev/null 2>&1; then DL='curl' elif command -v wget >/dev/null 2>&1; then DL='wget' else die 'neither curl nor wget is available' fi } # download URL DEST download() { local url="$1" dest="$2" case "${DL}" in curl) run curl -fsSL --proto '=https' --tlsv1.2 -o "${dest}" "${url}" ;; wget) run wget --quiet --https-only --output-document="${dest}" "${url}" ;; esac } # --- path: package repository ------------------------------------------------------------ setup_repo_apt() { # $1 = repo path segment: 'debian' or 'ubuntu' local variant="$1" [ -n "${VERSION_CODENAME}" ] || die 'VERSION_CODENAME missing in /etc/os-release' run mkdir -p /etc/apt/keyrings download "${KEY_URL}" /etc/apt/keyrings/linuxfabrik.asc local list='/etc/apt/sources.list.d/linuxfabrik-monitoring-plugins.list' local line="deb [signed-by=/etc/apt/keyrings/linuxfabrik.asc] \ ${REPO_BASE_URL}/monitoring-plugins/${variant}/ ${VERSION_CODENAME}-release main" if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] write %s:\n %s\n' "${list}" "${line}" >&2 else printf '%s\n' "${line}" > "${list}" fi apt_get update apt_get install --yes "${PKG_NAME}" warn_conffile_leftovers } setup_repo_rhel() { run rpm --import "${KEY_URL}" download \ "${REPO_BASE_URL}/monitoring-plugins/rhel/${PKG_NAME}-release.repo" \ "/etc/yum.repos.d/${PKG_NAME}-release.repo" # The -selinux sub-package pulls in the base package via Recommends. run dnf install --assumeyes "${PKG_NAME_SELINUX}" } setup_repo_sle() { # Import the signing key into the rpm keyring first, so zypper can verify the repo metadata. # Without it the implicit refresh on addrepo fails with "Signature verification failed". run rpm --import "${KEY_URL}" run zypper --non-interactive addrepo \ "${REPO_BASE_URL}/monitoring-plugins/sle/${PKG_NAME}-release.repo" run zypper --non-interactive --gpg-auto-import-keys refresh run zypper --non-interactive install "${PKG_NAME}" } install_via_package() { require_root detect_os # The package repository serves enterprise releases only (RHEL 8/9/10, SLE, Debian, # Ubuntu). Fedora is detected as the rhel family for the source path, but has no repo # build, so reject it early instead of failing on a 404 deep inside dnf. if [ "${OS_ID}" = 'fedora' ]; then die 'Fedora has no package repository build (supported: RHEL 8/9/10, SLE, Debian, Ubuntu). Install from source instead with --source.' fi case "${OS_FAMILY}" in debian) setup_repo_apt debian ;; ubuntu) setup_repo_apt ubuntu ;; rhel) setup_repo_rhel ;; sle) setup_repo_sle ;; *) die "no repository path for family: ${OS_FAMILY}" ;; esac log "installed ${PKG_NAME} from ${REPO_BASE_URL}" log 'keep current with your usual package manager (dnf/zypper/apt upgrade).' } # --- shared source/zip assembly ---------------------------------------------------------- # Flatten // executables into the plugin directory and record each, so the # uninstaller can reverse them out of the shared directory. is a check-plugins or # notification-plugins tree. flatten_plugins() { local src="$1" dir name count=0 if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] install %s// -> %s/\n' "${src}" "${PLUGIN_DIR}" >&2 return 0 fi [ -d "${src}" ] || die "no ${src##*/} directory in the downloaded source" for dir in "${src}"/*/; do name="$(basename "${dir}")" [ -f "${dir}${name}" ] || continue install -m 0755 "${dir}${name}" "${PLUGIN_DIR}/${name}" record "${PLUGIN_DIR}/${name}" count=$((count + 1)) done # A source tree always carries plugins. Zero of them means the download or the extraction # produced something unusable, and going on would leave a host that reports a successful # install and has no plugins at all. [ "${count}" -gt 0 ] || die "found no plugins in ${src##*/}" log "flattened ${count} plugins from ${src##*/}" } # Copy the shared lib package next to the flattened plugins, dropping development cruft. install_lib() { local lib_src="$1" if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] copy %s/. -> %s/lib/\n' "${lib_src}" "${PLUGIN_DIR}" >&2 return 0 fi [ -d "${lib_src}" ] || die "no library source at ${lib_src}" cp -a "${lib_src}/." "${PLUGIN_DIR}/lib/" rm -rf "${PLUGIN_DIR}/lib/tests" "${PLUGIN_DIR}/lib/.github" \ "${PLUGIN_DIR}/lib/lockfiles" "${PLUGIN_DIR}/lib/.git" record "${PLUGIN_DIR}/lib" } # Install the family-specific sudoers drop-in shipped in the source tree, mirroring what the # RPM/DEB package does. The file is validated with visudo before activation so a broken # drop-in can never lock sudo out. Only Debian and RedHat drop-ins are shipped. install_sudoers() { local mp_dir="$1" family_file='' case "${OS_FAMILY}" in rhel) family_file='RedHat.sudoers' ;; debian|ubuntu) family_file='Debian.sudoers' ;; *) warn "no sudoers drop-in shipped for family ${OS_FAMILY}; configure sudo manually" return 0 ;; esac if ! command -v visudo >/dev/null 2>&1; then warn 'visudo not found (sudo not installed); skipping sudoers drop-in' return 0 fi local src="${mp_dir}/assets/sudoers/${family_file}" if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] visudo -cf %s && install -m 0440 %s %s\n' \ "${src}" "${src}" "${SUDOERS_DEST}" >&2 return 0 fi [ -f "${src}" ] || { warn "sudoers source ${src} not found; skipping"; return 0; } log "installing sudoers drop-in ${family_file}" visudo -cf "${src}" >/dev/null || die "sudoers file ${src} failed validation" install -m 0440 "${src}" "${SUDOERS_DEST}" record "${SUDOERS_DEST}" # Drop sudoers files this project shipped under earlier names, so the LF_NAGIOS Cmnd_Alias is # not defined twice (which makes sudo warn about a duplicate Cmnd_Alias on every call). local legacy for legacy in ${LEGACY_SUDOERS_DESTS}; do if [ -e "${legacy}" ]; then log "removing superseded sudoers drop-in ${legacy}" run rm -f "${legacy}" fi done } # Install the bash completion shipped in the source tree, mirroring what the RPM/DEB package # does. It completes the plugins' command line options and is inert on a host without the # bash-completion package, so it is installed unconditionally. install_bash_completion() { local mp_dir="$1" local src="${mp_dir}/assets/bash-completion/linuxfabrik-monitoring-plugins.bash" if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] install -m 0644 %s %s\n' "${src}" "${BASH_COMPLETION_DEST}" >&2 return 0 fi [ -f "${src}" ] || { warn "bash completion source ${src} not found; skipping"; return 0; } log 'installing bash completion' install -d -m 0755 "$(dirname "${BASH_COMPLETION_DEST}")" install -m 0644 "${src}" "${BASH_COMPLETION_DEST}" # The completion registers whatever it finds in the plugin directory, so a non-default # --plugin-dir has to reach the installed copy. if [ "${PLUGIN_DIR}" != "${DEFAULT_PLUGIN_DIR}" ]; then sed -i "s|LFMP_PLUGIN_DIR:-${DEFAULT_PLUGIN_DIR}|LFMP_PLUGIN_DIR:-${PLUGIN_DIR}|" \ "${BASH_COMPLETION_DEST}" fi record "${BASH_COMPLETION_DEST}" } # Final ownership, mode and SELinux fix-ups, mirroring the package: root owns the plugins, the # monitoring user may read and execute them, the tree is relabelled, and plugins may call sudo. # Each step is applied only when its tool is present, so the install still succeeds on a host # without a monitoring agent. # # The monitoring user must be able to traverse, read and execute the plugins, the bundled lib # and the dependency venv, but must NOT own or be able to write them: the whitelisted plugins # run as root via sudo, so a writable plugin, lib module or venv interpreter would be arbitrary # root code execution. # # Ownership and modes are set explicitly rather than left to the install-time defaults, so that # re-running the installer repairs a host that an earlier version, a restrictive umask or a # manual chown left behind in a broken state. finalize_permissions() { if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] chown root:root and chmod every recorded path plus %s\n' \ "${STATE_DIR}" >&2 return 0 fi command -v chown >/dev/null 2>&1 || return 0 log 'setting ownership and modes of the installed files' # Only ever touch what this installer recorded. The plugin directory is shared with the # distro's own Nagios plugins, some of which are setuid root (check_icmp), so a recursive # chmod across it would silently disarm them. chmod a+rx "${PLUGIN_DIR}" || warn "could not make ${PLUGIN_DIR} traversable" local p if [ -f "${MANIFEST}" ]; then while IFS= read -r p; do [ -n "${p}" ] || continue # The sudoers drop-in was installed and validated with its own mandatory 0440, and # the bash completion is sourced rather than executed. Both were placed by root # with the mode they need, so they are left exactly as they are. case "${p}" in "${SUDOERS_DEST}" | "${BASH_COMPLETION_DEST}") continue ;; esac [ -e "${p}" ] || continue chown --recursive root:root "${p}" || warn "chown of ${p} to root failed" if [ -d "${p}" ]; then # 'X' keeps the executable bit on directories and on files that already carry # one (the venv interpreters), without granting it to plain library modules. chmod --recursive u=rwX,go=rX "${p}" || warn "chmod of ${p} failed" else chmod 0755 "${p}" || warn "chmod of ${p} failed" fi done < "${MANIFEST}" fi # The venv is recorded only once the dependencies were installed, so fix it up separately. if [ -d "${STATE_DIR}" ]; then chown --recursive root:root "${STATE_DIR}" || warn 'chown of the venv to root failed' chmod --recursive u=rwX,go=rX "${STATE_DIR}" || warn 'chmod of the venv failed' fi if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then if command -v restorecon >/dev/null 2>&1; then log "restoring SELinux labels on ${PLUGIN_DIR} and ${STATE_DIR}" run restorecon -Fr "${PLUGIN_DIR}" || warn 'restorecon failed' [ -d "${STATE_DIR}" ] \ && { run restorecon -Fr "${STATE_DIR}" || warn 'restorecon of the venv failed'; } fi command -v setsebool >/dev/null 2>&1 \ && { run setsebool -P nagios_run_sudo on || warn 'setsebool nagios_run_sudo failed'; } fi # Every step above is best-effort and warns on its own. Return success explicitly so that a # skipped optional step does not abort the install through `set -e`. return 0 } # Shell-quote the arguments into a single command line, for the `su -c` fallback. quote_args() { local arg out='' for arg in "$@"; do out="${out}'${arg//\'/\'\\\'\'}' " done printf '%s' "${out% }" } # Run CMD as the monitoring user, leaving its combined output in AS_USER_OUTPUT and its exit # code in AS_USER_RC. Call it directly, never through a command substitution: that would run it # in a subshell and throw both results away. A monitoring account usually has nologin as its # shell, so `su` needs one named explicitly; `runuser` bypasses the shell and is preferred. AS_USER_RC=0 AS_USER_OUTPUT='' as_user() { local user="$1" shift AS_USER_RC=0 if command -v runuser >/dev/null 2>&1; then AS_USER_OUTPUT="$(runuser -u "${user}" -- "$@" 2>&1)" || AS_USER_RC=$? else AS_USER_OUTPUT="$(su -s /bin/sh -c "$(quote_args "$@")" "${user}" 2>&1)" || AS_USER_RC=$? fi } # Verify that the monitoring user can actually use what was just installed. Everything here is # installed by root but always executed by the monitoring user, so an install can report success # and still be unusable: an unreadable venv interpreter, a library directory the user cannot # traverse, or a library dependency no lockfile covers. Say so here instead of leaving it to the # first check that runs. This is a report, not a gate: a host without a monitoring agent installs # the plugins perfectly legitimately, so the exit code stays 0 either way. smoke_test() { [ "${DRY_RUN}" = '1' ] && return 0 command -v runuser >/dev/null 2>&1 || command -v su >/dev/null 2>&1 || { warn 'neither runuser nor su is available, skipping the smoke test' return 0 } local user='' candidate for candidate in icinga nagios; do if id "${candidate}" >/dev/null 2>&1; then user="${candidate}" break fi done if [ -z "${user}" ]; then log 'no icinga or nagios user on this host, skipping the smoke test' return 0 fi [ -x "${VENV_DIR}/bin/python3" ] || return 0 local failed=0 # Import every bundled library module the way a plugin does. This is the authoritative # check: it exercises the venv interpreter, the traversal into the library and the # dependencies, and its exit code is unambiguous. Plugins deliberately turn --help into # UNKNOWN and report a crash the same way, so their exit code alone proves nothing. # # The module list is collected here, as root, and handed over as arguments. Letting the # unprivileged side discover the modules itself would make an unreadable library look like # an empty one, and the check would pass on exactly the host it is meant to catch. local -a modules=() local module name for module in "${PLUGIN_DIR}"/lib/*.py; do [ -f "${module}" ] || continue name="${module##*/}" name="${name%.py}" [ "${name}" = '__init__' ] && continue modules+=("${name}") done if [ "${#modules[@]}" -eq 0 ]; then warn "found no library modules in ${PLUGIN_DIR}/lib, skipping the import check" else log "checking that user ${user} can import the library" as_user "${user}" "${VENV_DIR}/bin/python3" -c " import importlib, sys sys.path.insert(0, '${PLUGIN_DIR}') for module in sys.argv[1:]: importlib.import_module('lib.' + module) " "${modules[@]}" if [ "${AS_USER_RC}" -ne 0 ]; then failed=1 warn "user ${user} cannot import the library:" printf '%s\n' "${AS_USER_OUTPUT}" >&2 fi fi # Second, that a plugin file itself is reachable and executable for that user. Ask `test` # rather than running the plugin: plugins report --help as UNKNOWN and a crash the same # way, so their exit code cannot tell the two apart. Take the first plugin this run # recorded, so the check never lands on a distro plugin. local plugin='' p if [ -f "${MANIFEST}" ]; then while IFS= read -r p; do case "${p}" in "${PLUGIN_DIR}"/*) ;; *) continue ;; esac if [ -f "${p}" ] && [ -x "${p}" ]; then plugin="${p}" break fi done < "${MANIFEST}" fi if [ -n "${plugin}" ]; then log "checking that user ${user} can execute ${plugin##*/}" as_user "${user}" test -x "${plugin}" if [ "${AS_USER_RC}" -ne 0 ]; then failed=1 warn "user ${user} cannot execute ${plugin}" fi # And that the shebang rewrite landed, so the plugin uses the venv rather than a # system Python that has none of the dependencies. if ! head -n 1 "${plugin}" | grep -qxF "#!${VENV_DIR}/bin/python3"; then failed=1 warn "${plugin} does not point at ${VENV_DIR}/bin/python3" fi fi if [ "${failed}" -ne 0 ]; then warn 'the files are installed, but your monitoring agent will not be able to run them.' fi return 0 } # `python -m venv` needs ensurepip to bootstrap pip. Debian/Ubuntu ship ensurepip in a separate # python3-venv package, so importing venv succeeds but creating one fails. Make sure ensurepip is # really present, installing the package on the spot where apt is available. ensure_venv_support() { "${PYBIN}" -c 'import ensurepip' >/dev/null 2>&1 && return 0 if command -v apt-get >/dev/null 2>&1; then log 'installing python3-venv (needed to build the dependency venv)' apt_get install --yes python3-venv [ "${DRY_RUN}" = '1' ] && return 0 "${PYBIN}" -c 'import ensurepip' >/dev/null 2>&1 && return 0 fi die 'python ensurepip/venv is missing; install the python3-venv package and re-run' } # Install the Python dependencies into a self-contained venv and point every flattened plugin # at that interpreter. This keeps the dependencies independent of any user account (no ~/.local, # no sudo -u) and isolated from the system Python (no PEP 668 externally-managed conflict), # matching the venv the RPM/DEB packages ship. # # holds the monitoring-plugins lockfiles/pyXX/ tree. optionally holds # the same tree from the shared lib, and is applied on top: the monitoring-plugins lockfile # resolves the dependencies of the RELEASED linuxfabrik-lib, while source and zip installs ship # a lib that can be ahead of that release and may need a package no release declares yet. install_dependencies() { local reqs_dir="$1" lib_reqs_dir="${2:-}" pyver py_tag lockfile lib_lockfile pyver="$("${PYBIN}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" py_tag="py${pyver//./}" lockfile="${reqs_dir}/lockfiles/${py_tag}/requirements.txt" lib_lockfile='' [ -n "${lib_reqs_dir}" ] && lib_lockfile="${lib_reqs_dir}/lockfiles/${py_tag}/requirements.txt" if [ ! -f "${lockfile}" ] && [ "${DRY_RUN}" != '1' ]; then warn "no lockfile for ${py_tag}; skipping dependencies, plugins may fail to import" return 0 fi ensure_venv_support # --clear wipes any pre-existing venv first. Without it, `python -m venv` over an existing venv # keeps the OLD interpreter (e.g. a 3.9 venv from an earlier install stays 3.9 even when invoked # with python3.11), so the venv would mismatch the lockfile chosen from PYBIN and pip would # reject the pinned dependencies as requiring a newer Python. log "creating dependency venv at ${VENV_DIR} (${py_tag})" run "${PYBIN}" -m venv --clear "${VENV_DIR}" record "${STATE_DIR}" run "${VENV_DIR}/bin/python3" -m pip install --quiet --upgrade pip run "${VENV_DIR}/bin/python3" -m pip install --quiet \ --requirement "${lockfile}" --require-hashes # The two lockfiles overlap and pin a handful of shared packages to different versions, so # they have to go into separate pip calls: one call with both would abort with # ResolutionImpossible. Applying the lib lockfile second lets its pins win, which is what # the bundled lib was tested against. Both are hash-pinned, so nothing unverified enters. if [ -n "${lib_lockfile}" ] && [ -f "${lib_lockfile}" ]; then log 'installing the dependencies of the bundled library' run "${VENV_DIR}/bin/python3" -m pip install --quiet \ --requirement "${lib_lockfile}" --require-hashes elif [ -n "${lib_lockfile}" ] && [ "${DRY_RUN}" != '1' ]; then warn "the bundled library ships no lockfile for ${py_tag}; relying on the plugin lockfile" fi # Source and zip installs place the library next to the plugins, where it takes precedence # over anything in the venv. Drop the released copy the lockfile pulled in, so that exactly # one library is importable: with both present, a library the caller cannot read falls back # to the released one silently and the plugins fail far from the cause, with a missing # attribute or module rather than a permission error. log 'removing the released library from the venv in favour of the bundled one' run "${VENV_DIR}/bin/python3" -m pip uninstall --quiet --yes linuxfabrik-lib \ || warn 'could not remove the released library from the venv' # Point the plugins at the venv interpreter so they pick up the dependencies no matter who # runs them. Only the files this run recorded are rewritten: the plugin directory is shared # with the distro's own Nagios plugins, and `sed -i` replaces a file rather than editing it # in place, which would drop the setuid bit some of them carry (check_icmp). log 'pointing plugins at the venv interpreter' if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] rewrite the shebang of every installed plugin -> #!%s/bin/python3\n' \ "${VENV_DIR}" >&2 return 0 fi [ -f "${MANIFEST}" ] || return 0 local f while IFS= read -r f; do case "${f}" in "${PLUGIN_DIR}"/*) ;; *) continue ;; esac [ -f "${f}" ] || continue sed -i "1s|^#!.*python3.*|#!${VENV_DIR}/bin/python3|" "${f}" done < "${MANIFEST}" } # True if BIN is a Python 3.9+ interpreter (the oldest version we ship a dependency lockfile for). python_ok() { local bin="$1" pyminor command -v "${bin}" >/dev/null 2>&1 || return 1 pyminor="$("${bin}" -c 'import sys; print(sys.version_info[1] if sys.version_info[0] == 3 else 0)' 2>/dev/null || echo 0)" [ "${pyminor}" -ge 9 ] } # When the operator did not pin an interpreter and the default python3 is too old, pick the newest # pythonX.Y already on PATH that we ship a lockfile for. RHEL 8 hosts often carry an ancient system # python3 (3.6) next to a usable python3.11/3.12, so this saves the operator from hunting it down and # re-running with --python. Candidates are the versions present under lockfiles/, newest first. autoselect_python() { [ "${PYBIN_EXPLICIT}" = '1' ] && return 0 python_ok "${PYBIN}" && return 0 local cand for cand in python3.14 python3.13 python3.12 python3.11 python3.10 python3.9; do if python_ok "${cand}"; then PYBIN="${cand}" log "system python3 is too old; using ${cand} found on PATH" return 0 fi done } # Bail early on a Python older than 3.9 (no matching dependency lockfile) with an actionable # hint, instead of leaving a half-working install behind. require_python39() { autoselect_python require_cmd "${PYBIN}" python_ok "${PYBIN}" && return 0 warn "${PYBIN} is $(${PYBIN} --version 2>&1); the plugins need Python 3.9 or newer." warn 'install a newer Python and re-run pointing at it, for example on RHEL 8:' warn ' dnf install -y python3.12' warn " curl -fsSL ${REPO_BASE_URL}/install-monitoring-plugins | sudo bash -s -- --source --python=python3.12" die 'aborting: the system Python is too old' } # --- path: from source (GitHub tarball, no git client) ----------------------------------- # GitHub serves any branch/tag as a tarball at /archive/.tar.gz and extracts to # -/. We download monitoring-plugins AND lib, because lib lives in a separate # repository and is only referenced via a (dangling-in-tarball) symlink. # # The extracted directory is handed back in FETCHED_DIR rather than echoed, because a caller # writing `dir="$(fetch_and_extract ...)"` would lose every failure in here: in an assignment # from a command substitution, bash runs the function to its end despite `set -e` and reports # the exit status of its LAST command. A failed download would then be followed by a tarfile # traceback, an empty plugin directory and a cheerful success message (verified with bash 5.3). FETCHED_DIR='' fetch_and_extract() { # fetch_and_extract REPO REF WORKDIR -> sets FETCHED_DIR local repo="$1" ref="$2" workdir="$3" local tarball="${workdir}/${repo}.tar.gz" if ! download "${GH_BASE}/${repo}/archive/${ref}.tar.gz" "${tarball}"; then warn "could not download ${repo}@${ref} from GitHub." warn 'GitHub answers frequent downloads with HTTP 429 (rate limiting); in that case' warn 'wait a few minutes, or install from the package repository (the default mode).' warn "Otherwise check that the ref exists: ${GH_BASE}/${repo}/tree/${ref}" die 'aborting: could not fetch the source' fi # Extract with python (tarfile + built-in zlib) instead of `tar -xz`, so the source path # needs no external tar/gzip (minimal hosts, e.g. openSUSE, often ship neither). # Request the "data" extraction filter where available (CVE-2007-4559 mitigation, for # example backported into the RHEL 9 python3.9): same safe extraction, but without the # RuntimeWarning that an unfiltered extractall() triggers there. Pythons without the # filter machinery reject the keyword, so fall back to a plain extractall(). run "${PYBIN}" -c ' import sys, tarfile kwargs = {"filter": "data"} if hasattr(tarfile, "data_filter") else {} tarfile.open(sys.argv[1]).extractall(sys.argv[2], **kwargs) ' "${tarball}" "${workdir}" || die "could not extract ${tarball}" # ref may contain slashes (e.g. release branches); GitHub flattens them with '-'. FETCHED_DIR="${workdir}/${repo}-${ref//\//-}" [ "${DRY_RUN}" = '1' ] && return 0 [ -d "${FETCHED_DIR}" ] || die "the ${repo} tarball did not contain ${FETCHED_DIR##*/}" return 0 } install_from_source() { require_root detect_os require_python39 local workdir workdir="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf '${workdir}'" EXIT log "downloading ${GH_MP_REPO}@${SOURCE_REF} and ${GH_LIB_REPO}@${SOURCE_REF} from GitHub" local mp_dir lib_dir fetch_and_extract "${GH_MP_REPO}" "${SOURCE_REF}" "${workdir}" mp_dir="${FETCHED_DIR}" fetch_and_extract "${GH_LIB_REPO}" "${SOURCE_REF}" "${workdir}" lib_dir="${FETCHED_DIR}" log "installing plugins into ${PLUGIN_DIR}" run mkdir -p "${PLUGIN_DIR}/lib" flatten_plugins "${mp_dir}/check-plugins" flatten_plugins "${mp_dir}/notification-plugins" install_lib "${lib_dir}" # Pass the extracted lib tree, not the installed copy: install_lib() strips the lockfiles # while copying, because they are of no use to a plugin at runtime. install_dependencies "${mp_dir}" "${lib_dir}" install_sudoers "${mp_dir}" install_bash_completion "${mp_dir}" finalize_permissions smoke_test log "installed ${GH_MP_REPO}@${SOURCE_REF} into ${PLUGIN_DIR}" } # --- path: from the signed source zip ---------------------------------------------------- # Verify a downloaded file against its detached sha256 and GPG signature, both fetched from # the same location. Aborts on any mismatch so a tampered or truncated zip is never installed. verify_download() { local file="$1" url="$2" workdir="$3" require_cmd sha256sum require_cmd gpg # Name a missing checksum or signature for what it is. Without this, the bare transfer # error from curl or wget is all the operator sees, and it reads like a network problem. if ! download "${url}.sha256" "${file}.sha256"; then warn "no checksum published next to ${url##*/}" die 'refusing to install unverified code' fi if ! download "${url}.asc" "${file}.asc"; then warn "no GPG signature published next to ${url##*/}" warn 'install from the package repository (the default mode) or with --source instead' die 'refusing to install unverified code' fi if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] sha256sum -c %s.sha256 && gpg --verify %s.asc %s\n' \ "${file}" "${file}" "${file}" >&2 return 0 fi log 'verifying sha256 checksum' ( cd "${workdir}" && sha256sum -c "$(basename "${file}").sha256" >/dev/null ) \ || die 'sha256 checksum verification failed' log 'verifying GPG signature' local gnupghome gnupghome="$(mktemp -d)" download "${KEY_URL}" "${workdir}/linuxfabrik.key" GNUPGHOME="${gnupghome}" gpg --quiet --import "${workdir}/linuxfabrik.key" 2>/dev/null \ || die 'could not import the Linuxfabrik signing key' GNUPGHOME="${gnupghome}" gpg --quiet --verify "${file}.asc" "${file}" 2>/dev/null \ || { rm -rf "${gnupghome}"; die 'GPG signature verification failed'; } rm -rf "${gnupghome}" } install_from_zip() { require_root detect_os require_python39 # Default to the 'latest' alias on the download server when no release is pinned, so # `--zip` works without looking up a version. Pin --version=- for a # reproducible rollout. if [ -z "${ZIP_VERSION}" ]; then ZIP_VERSION='latest' log "no --version given, using the latest release" fi local workdir workdir="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf '${workdir}'" EXIT local zip_name="lfmp-${ZIP_VERSION}.source.noarch.zip" local zip_url="${DOWNLOAD_BASE_URL}/${zip_name}" local zip="${workdir}/${zip_name}" log "downloading ${zip_name} from ${DOWNLOAD_BASE_URL}" download "${zip_url}" "${zip}" verify_download "${zip}" "${zip_url}" "${workdir}" # The source zip is pre-assembled (plugins flat, plus lib/, lockfiles/ and assets/). Extract # it to a staging dir with python's zipfile (no external unzip), then copy each top-level # entry into the plugin directory, recording it for a clean uninstall. local staging="${workdir}/staging" log "extracting and installing into ${PLUGIN_DIR}" if [ "${DRY_RUN}" = '1' ]; then printf ' [dry-run] unzip %s and copy plugins + lib/lockfiles/assets -> %s\n' \ "${zip_name}" "${PLUGIN_DIR}" >&2 else run mkdir -p "${staging}" "${PLUGIN_DIR}" "${PYBIN}" -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' \ "${zip}" "${staging}" local entry base for entry in "${staging}"/*; do base="$(basename "${entry}")" if [ -f "${entry}" ]; then install -m 0755 "${entry}" "${PLUGIN_DIR}/${base}" record "${PLUGIN_DIR}/${base}" else cp -a "${entry}" "${PLUGIN_DIR}/${base}" record "${PLUGIN_DIR}/${base}" fi done fi install_dependencies "${PLUGIN_DIR}" "${PLUGIN_DIR}/lib" install_sudoers "${PLUGIN_DIR}" install_bash_completion "${PLUGIN_DIR}" finalize_permissions smoke_test log "installed ${zip_name} into ${PLUGIN_DIR}" } # --- uninstall --------------------------------------------------------------------------- uninstall() { require_root detect_os local did_something=0 # Package install: let the package manager reverse it, then drop the repo registration. if command -v rpm >/dev/null 2>&1 && rpm -q "${PKG_NAME}" >/dev/null 2>&1; then did_something=1 if command -v dnf >/dev/null 2>&1; then run dnf remove --assumeyes "${PKG_NAME}" "${PKG_NAME_SELINUX}" run rm -f "/etc/yum.repos.d/${PKG_NAME}-release.repo" elif command -v zypper >/dev/null 2>&1; then run zypper --non-interactive remove "${PKG_NAME}" run zypper --non-interactive removerepo "${PKG_NAME}-release" || true fi fi if command -v dpkg >/dev/null 2>&1 && dpkg -s "${PKG_NAME}" >/dev/null 2>&1; then did_something=1 apt_get remove --yes "${PKG_NAME}" run rm -f "/etc/apt/sources.list.d/${PKG_NAME}.list" \ /etc/apt/keyrings/linuxfabrik.asc fi # Source/zip install: remove exactly what the manifest recorded, plus the venv state dir. if [ -f "${MANIFEST}" ]; then did_something=1 log "removing source/zip install listed in ${MANIFEST}" local p while IFS= read -r p; do [ -n "${p}" ] && run rm -rf "${p}" done < "${MANIFEST}" run rm -rf "${STATE_DIR}" elif [ -d "${STATE_DIR}" ]; then warn "no manifest at ${MANIFEST}; removing ${STATE_DIR} but leaving plugin files in ${PLUGIN_DIR}" run rm -rf "${STATE_DIR}" did_something=1 fi run rm -f "${SUDOERS_DEST}" "${BASH_COMPLETION_DEST}" # also drop any sudoers drop-ins shipped under earlier names for p in ${LEGACY_SUDOERS_DESTS}; do run rm -f "${p}" done if [ "${did_something}" -eq 1 ]; then log 'uninstall complete.' else warn 'nothing to uninstall (no package, manifest or state found).' fi } # --- argument parsing -------------------------------------------------------------------- parse_args() { while [ "$#" -gt 0 ]; do case "$1" in --package) MODE='package' ;; --source|--git) MODE='source' ;; --zip) MODE='zip' ;; --uninstall) ACTION='uninstall' ;; --ref=*) SOURCE_REF="${1#*=}" ;; --version=*) ZIP_VERSION="${1#*=}" ;; --plugin-dir=*) PLUGIN_DIR="${1#*=}" ;; --python=*) PYBIN="${1#*=}"; PYBIN_EXPLICIT=1 ;; --help|-h) usage; exit 0 ;; *) die "unknown option: $1 (try --help)" ;; esac shift done } # --- main -------------------------------------------------------------------------------- main() { parse_args "$@" pick_downloader [ "${DRY_RUN}" = '1' ] && log 'DRY_RUN=1: no changes will be made.' if [ "${ACTION}" = 'uninstall' ]; then uninstall return 0 fi case "${MODE}" in package) install_via_package ;; source) install_from_source ;; zip) install_from_zip ;; esac } main "$@"