diff --git a/agent-local/proxmox b/agent-local/proxmox deleted file mode 100755 index 75dc8809e..000000000 --- a/agent-local/proxmox +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/perl -w - -# Copyright (C) 2015 Mark Schouten -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; version 2 dated June, -# 1991. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# See http://www.gnu.org/licenses/gpl.txt for the full license - -use strict; -use PVE::APIClient::LWP; -use PVE::AccessControl; -use PVE::INotify; -use Data::Dumper; - -my $hostname = PVE::INotify::read_file("hostname"); - -my $ticket = PVE::AccessControl::assemble_ticket('root@pam'); -my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token('root@pam'); - -my $conn = PVE::APIClient::LWP->new( - ticket => $ticket, - csrftoken => $csrftoken, -); - -my $clustername; - -foreach my $child (@{$conn->get("/api2/json/cluster/status")}) { - if ($child->{'type'} eq "cluster") { - $clustername = $child->{'name'}; - } -} - -if (!defined($clustername)) { - $clustername = $hostname; -} - -print "<<>>\n"; -print "$clustername\n"; - -foreach my $vm (@{$conn->get("/api2/json/nodes/$hostname/netstat")}) { - my $vmid = $vm->{'vmid'}; - my $vmname; - - # Try QEMU (VM) - eval { - my $config = $conn->get("/api2/json/nodes/$hostname/qemu/$vmid/config"); - die if defined($config->{'template'}) && $config->{'template'} == 1; - $vmname = $config->{'name'}; - }; - - # Try LXC - if (!defined $vmname) { - eval { - my $config = $conn->get("/api2/json/nodes/$hostname/lxc/$vmid/config"); - die if defined($config->{'template'}) && $config->{'template'} == 1; - $vmname = $config->{'hostname'}; - }; - } - - # Default setting - $vmname //= "VMID-$vmid"; - - print "$vmid/$vm->{'dev'}/$vm->{'in'}/$vm->{'out'}/$vmname\n"; -} diff --git a/snmp/proxmox/README.md b/snmp/proxmox/README.md new file mode 100644 index 000000000..030b956a4 --- /dev/null +++ b/snmp/proxmox/README.md @@ -0,0 +1,25 @@ +# Proxmox VE Agent + +## Quick Install + +```bash +curl -s https://raw.githubusercontent.com/librenms/librenms-agent/master/snmp/proxmox/install.sh | bash +``` + +For cron-based refresh instead of systemd: + +```bash +curl -s https://raw.githubusercontent.com/librenms/librenms-agent/master/snmp/proxmox/install.sh | bash -s -- --cron +``` + +## Manual Install + +Clone the repository and run locally: + +```bash +git clone https://github.com/librenms/librenms-agent.git +cd librenms-agent/snmp/proxmox +sudo ./install.sh +``` + +See [LibreNMS Proxmox documentation](https://docs.librenms.org/Extensions/Applications/Proxmox/) for full installation and usage instructions. diff --git a/snmp/proxmox/install.sh b/snmp/proxmox/install.sh new file mode 100644 index 000000000..798c64b8d --- /dev/null +++ b/snmp/proxmox/install.sh @@ -0,0 +1,325 @@ +#!/usr/bin/env bash +#--------------------------------------------------------------------------------------------------------------- +# +# Script name : install.sh +# Description : Install script for LibreNMS SNMP extension "proxmox" +# Repository : +# Version : 2.0.0 +# Author : LibreNMS Team +# License : MIT +# +# Usage: +# Local: ./install.sh +# Remote: curl -s https://raw.githubusercontent.com/librenms/librenms-agent/master/snmp/proxmox/install.sh | bash +#--------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +EXT_NAME="proxmox" +EXT_BIN="/usr/local/lib/snmpd/${EXT_NAME}" +EXT_CONF_DIR="/etc/snmp/extension" +EXT_CONF="${EXT_CONF_DIR}/${EXT_NAME}.yaml" +EXT_CACHE_DIR="/run/snmp/extension" +EXT_CACHE="${EXT_CACHE_DIR}/${EXT_NAME}.json" +SNMP_SNIPPET="/etc/snmp/snmpd.conf.d/librnms.conf" +SYSTEMD_UNIT_DIR="/etc/systemd/system" +SYSTEMD_UNIT_TIMER="${SYSTEMD_UNIT_DIR}/librenms-snmp-extension@.timer" +SYSTEMD_UNIT_SERVICE="${SYSTEMD_UNIT_DIR}/librenms-snmp-extension@.service" +GITHUB_USER=${GITHUB_USER:-librenms} +GITHUB_BRANCH=${GITHUB_BRANCH:-master} +GITHUB_BASE="https://raw.githubusercontent.com/${GITHUB_USER}/librenms-agent/${GITHUB_BRANCH}" + +SNMPD_MAIN_CONF="/etc/snmp/snmpd.conf" +SNMPD_INCLUDE_DIR_LINE="includeDir /etc/snmp/snmpd.conf.d" + +REFRESH_METHOD="" +VERBOSE_LOG=${VERBOSE_LOG:-0} + +_date() { + date +%Y-%m-%d_%H:%M:%S +} + +log_verbose() { + [[ "${VERBOSE_LOG}" -eq 1 ]] || return 0 + echo -e "\033[94m VERBOSE: $*\033[0m" +} + +log_info() { + echo "$( _date ): INFO: $*" +} + +log_notice() { + echo -e "\033[92m$( _date ): NOTICE: $* \033[0m" +} + +log_warn() { + echo -e "\033[93m$( _date ): WARN: $* \033[0m" +} + +log_error() { + echo -e "\033[91m$( _date ): ERROR: $* \033[0m" >&2 +} + +run_cmd() { + local -a cmd=("$@") + log_verbose "Running command: ${cmd[*]}" + "${cmd[@]}" +} + +error() { + log_error "$*" + exit 1 +} + +usage() { + echo "Usage: $0 [--cron|--systemd]" + echo " --cron Force cron-based cache refresh" + echo " --systemd Force systemd-based cache refresh (default)" + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --cron) + REFRESH_METHOD="cron" + shift + ;; + --systemd) + REFRESH_METHOD="systemd" + shift + ;; + -h|--help) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac +done + +if [[ $EUID -ne 0 ]]; then + log_error "This script must be run with sudo or as root" + exit 1 +fi + +log_notice "Installing ${EXT_NAME} SNMP extension." + +ask_yes_no() { + if [[ "${AUTO_YES:-}" == "1" ]]; then + return 0 + fi + local prompt="$1" + local answer + + while true; do + read -rp "$prompt [y/n]: " answer < /dev/tty + case "${answer,,}" in + y|yes) return 0 ;; + n|no) return 1 ;; + *) echo "Please answer y or n." ;; + esac + done +} + +is_installed() { + command -v "$1" >/dev/null 2>&1 +} + +is_systemd_available() { + if command -v systemctl >/dev/null 2>&1 && \ + systemctl is-system-running >/dev/null 2>&1; then + return 0 + fi + return 1 +} + +detect_refresh_method() { + if [[ -n "${REFRESH_METHOD}" ]]; then + return + fi + if is_systemd_available; then + REFRESH_METHOD="systemd" + log_notice "Detected systemd, using timer-based cache refresh." + else + REFRESH_METHOD="cron" + log_notice "No systemd detected, using cron-based cache refresh." + fi +} + +detect_snmp_user() { + SNMP_USER=$(ps aux | grep -E '[s]nmpd' | awk '{print $1}' | grep -v root | head -1) + if [[ -z "${SNMP_USER}" ]]; then + SNMP_USER="snmp" + log_warn "Could not detect snmpd user, defaulting to '${SNMP_USER}'" + else + log_info "Detected snmpd user: ${SNMP_USER}" + fi +} + +install_deps() { + if ! is_installed curl || ! is_installed snmpd; then + if ask_yes_no "Install dependencies?"; then + run_cmd apt update + run_cmd apt install -y curl snmpd ca-certificates + else + log_warn "Skipping dependencies." + fi + fi +} + +is_remote_install() { + [[ ! -f "./${EXT_NAME}" && ! -d "../common" ]] +} + +download_file() { + local url="$1" + local dest="$2" + log_info "Downloading ${dest}..." + curl -fsSL "${url}" -o "${dest}" || error "Failed to download ${url}" +} + +install_agent() { + if is_remote_install; then + download_file "${GITHUB_BASE}/snmp/proxmox/${EXT_NAME}" "${EXT_BIN}" + else + install -v -m 0755 "./${EXT_NAME}" "${EXT_BIN}" + fi +} + +if [[ -f "${SNMPD_MAIN_CONF}" ]]; then + if ! grep -Fqs "${SNMPD_INCLUDE_DIR_LINE}" "${SNMPD_MAIN_CONF}"; then + log_warn "Missing '${SNMPD_INCLUDE_DIR_LINE}' in ${SNMPD_MAIN_CONF}." + log_warn "Without it, snmpd may not load ${SNMP_SNIPPET}." + + if ask_yes_no "Append includeDir to ${SNMPD_MAIN_CONF}?"; then + install -d -m 0755 /etc/snmp/snmpd.conf.d + cp -a "${SNMPD_MAIN_CONF}" "${SNMPD_MAIN_CONF}.bak.$(date +%Y%m%d%H%M%S)" + printf '\n%s\n' "${SNMPD_INCLUDE_DIR_LINE}" >> "${SNMPD_MAIN_CONF}" + log_notice "Appended '${SNMPD_INCLUDE_DIR_LINE}' to ${SNMPD_MAIN_CONF}." + log_notice "Restart snmpd to apply changes." + else + log_warn "Skipping includeDir update." + fi + fi +else + log_warn "${SNMPD_MAIN_CONF} not found; cannot verify includeDir configuration." +fi + +install -v -d -m 0755 /usr/local/lib/snmpd +install -v -d -m 0755 "${EXT_CONF_DIR}" +install -v -d -m 0755 "${EXT_CACHE_DIR}" +install -v -d -m 0755 /etc/snmp/snmpd.conf.d + +log_info "Installing ${EXT_NAME} agent..." +install_agent + +SUDOERS_FILE="/etc/sudoers.d/librenms-proxmox" +if [ ! -f "${SUDOERS_FILE}" ]; then + detect_snmp_user + cat >"${SUDOERS_FILE}" <<'EOF' +# Cmnd alias for Proxmox VE read-only API access +Cmnd_Alias C_PROXMOX = \ + /usr/bin/pvesh get version, \ + /usr/bin/pvesh get /nodes/*/lxc, \ + /usr/bin/pvesh get /nodes/*/qemu, \ + /usr/bin/pvesh get /nodes/*/status, \ + /usr/bin/pvesh get /nodes/*/storage, \ + /usr/bin/pvesh get /nodes/*/netstat, \ + /usr/bin/pvesh get /nodes/*/ceph/status, \ + /usr/bin/pvesh get /nodes/*/subscription, \ + /usr/bin/pvesh get /nodes/*/replication, \ + /usr/bin/pvesh get /cluster/resources, \ + /usr/bin/pvesh get /cluster/status, \ + /usr/bin/pvesh get /cluster/ceph/status, \ + /usr/bin/pvesh get /cluster/ha/status, \ + /usr/bin/pvesh get /cluster/ha/resources, \ + /usr/bin/pvesh get /cluster/ha/groups, \ + /usr/bin/pvesh get /cluster/ha/rules, \ + /usr/bin/pvesh get /cluster/options, \ + /usr/bin/pvesh get /cluster/config/nodes, \ + /usr/bin/pvesh get /cluster/replication, \ + /usr/bin/pvesh get /pools + +%SNMP_USER% ALL=NOPASSWD: C_PROXMOX +EOF + sed -i "s/%SNMP_USER%/${SNMP_USER}/g" "${SUDOERS_FILE}" + chmod 0440 "${SUDOERS_FILE}" + log_notice "Created sudoers file for ${SNMP_USER} to run pvesh without password." +fi + +if [ ! -f "${EXT_CONF}" ]; then + log_info "Installing default configuration..." + cat >"${EXT_CONF}" <<'EOF' +pvesh_path: pvesh +EOF +fi + +EXTEND_LINE="extend ${EXT_NAME} /bin/cat ${EXT_CACHE}" +if [ ! -f "${SNMP_SNIPPET}" ] || ! grep -Fqs "${EXTEND_LINE}" "${SNMP_SNIPPET}"; then + printf '%s\n' "${EXTEND_LINE}" >>"${SNMP_SNIPPET}" + log_notice "Added extend line to ${SNMP_SNIPPET}." +fi + +detect_refresh_method +detect_snmp_user + +install_cron() { + log_info "Installing cron job..." + CRON_FILE="/etc/cron.d/librenms-snmp-extension-${EXT_NAME}" + cat >"${CRON_FILE}" <"${OVERRIDE_FILE}" < 0 + else 0 + ) + self.info( + f"Compressed output: {output_path} ({compressed_size} bytes, {ratio:.1f}% reduction)" + ) + + pretty_path = output_path.replace(".json", "_pretty.json") + with open(pretty_path, "w") as f: + json.dump(output_data, f, indent=2, sort_keys=True) + else: + with open(output_path, "w") as f: + json.dump(output_data, f, indent=2) + self.info(f"Output: {output_path}") + + pretty_path = output_path.replace(".json", "_pretty.json") + with open(pretty_path, "w") as f: + json.dump(output_data, f, indent=2, sort_keys=True) + except OSError as e: + self.error(f"Failed to write output: {e}") + output_data["error"] = 1 + output_data["errorString"] = f"Failed to write output: {e}" + + total_duration = time.time() - total_start + total_errors = sum(stats["errors"] for stats in self.endpoint_stats.values()) + + all_exit_codes = self.collect_exit_codes(output_data) + non_zero_codes = sorted(set(c for c in all_exit_codes if c != 0)) + if non_zero_codes: + self.info(f"Non-zero exit codes: {non_zero_codes}") + + self.info( + f"Finished in {self.duration_str(total_duration)}, {total_errors} errors" + ) + + return output_data["error"] == 0 and len(non_zero_codes) == 0 + + def load_config(self, config_path): + config = {"pvesh_path": "pvesh", "compress": True} + + if not os.path.exists(config_path): + self.log(f"Config file not found: {config_path} (using defaults)") + return config + + try: + with open(config_path, "r") as f: + user_config = yaml.safe_load(f) + + if user_config and isinstance(user_config, dict): + if "pvesh_path" in user_config: + config["pvesh_path"] = user_config["pvesh_path"] + + if "compress" in user_config: + config["compress"] = bool(user_config["compress"]) + self.compress = config["compress"] + + self.log(f"Loaded config from: {config_path}") + + except (yaml.YAMLError, OSError): + self.log(f"Config file error: {config_path}, using defaults") + return config + + return config + + +def error_handler(error_name, err): + output_data = { + "version": 2, + "error": 1, + "errorString": f"{error_name}: {err}", + "data": {}, + } + print(json.dumps(output_data)) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description="Proxmox VE agent for LibreNMS") + parser.add_argument( + "-c", + "--config", + default=DEFAULT_CONFIG, + help=f"Path to configuration file (default: {DEFAULT_CONFIG})", + ) + parser.add_argument( + "-o", + "--output", + default=DEFAULT_OUTPUT, + help=f"Path to output cache file (default: {DEFAULT_OUTPUT})", + ) + parser.add_argument( + "-n", "--node", help="Override node name (default: auto-detect)" + ) + parser.add_argument( + "-f", + "--force-rediscover", + action="store_true", + help="Force re-discovery of available endpoints", + ) + parser.add_argument( + "-z", + "--compress", + action="store_true", + help="Enable gzip+base64 compression (default)", + ) + parser.add_argument( + "--no-compress", + action="store_true", + help="Disable compression", + ) + parser.add_argument( + "--privacy", action="store_true", help="Redact sensitive information in output" + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose output" + ) + + args = parser.parse_args() + + try: + agent = Agent(verbose=args.verbose, privacy=args.privacy) + if args.no_compress: + agent.compress = False + elif args.compress: + agent.compress = True + + success = agent.run( + config_path=args.config, + output_path=args.output, + override_nodename=args.node, + force_rediscover=args.force_rediscover, + ) + + sys.exit(0 if success else 1) + except (KeyboardInterrupt, EOFError): + print("\nInterrupted, exiting.", flush=True) + sys.exit(130) + + +if __name__ == "__main__": + main() diff --git a/snmp/smart-v1 b/snmp/smart-v1 index 6dd6aa5f2..0b6f1c9db 100755 --- a/snmp/smart-v1 +++ b/snmp/smart-v1 @@ -72,6 +72,8 @@ Switches: -u Update -p Pretty print the JSON. -Z GZip+Base64 compress the results. +-v Verbose output (print status info to STDERR) +-V Print version and exit -g Guess at the config and print it to STDOUT -C Enable manual checking for guess and cciss. @@ -105,6 +107,7 @@ use JSON qw( decode_json ); use MIME::Base64 qw( encode_base64 ); use IO::Compress::Gzip qw(gzip); use Scalar::Util qw( looks_like_number ); +use List::Util qw( min ); my $cache = '/var/cache/smart'; my $smartctl = '/usr/bin/env smartctl'; @@ -114,11 +117,13 @@ my $useSN = 1; $Getopt::Std::STANDARD_HELP_VERSION = 1; sub main::VERSION_MESSAGE { - print "SMART SNMP extend 0.3.2\n"; + print "SMART SNMP extend 0.3.4\n"; } sub main::HELP_MESSAGE { &VERSION_MESSAGE; + print "\n" . "-v Verbose output\n"; + print "-V Print version and exit\n"; print "\n" . "-u Update '" . $cache . "'\n" . '-g Guess at the config and print it to STDOUT -c The config file to use. -p Pretty print the JSON. @@ -148,13 +153,17 @@ Scan Modes: #gets the options my %opts = (); -getopts( 'ugc:pZhvCSGt:U', \%opts ); +my $verbose = 0; +getopts( 'ugc:pZhvVCSGt:U', \%opts ); if ( $opts{h} ) { &HELP_MESSAGE; exit; } if ( $opts{v} ) { + $verbose = 1; +} +if ( $opts{V} ) { &VERSION_MESSAGE; exit; } @@ -578,6 +587,10 @@ my $to_return = { 'error' => 0, 'errorString' => '', }; + +if ($verbose) { + print "Processing " . scalar(@disks) . " disks (useSN=$useSN)\n"; +} foreach my $line (@disks) { my $disk; my $name; @@ -591,7 +604,28 @@ foreach my $line (@disks) { $disk = '/dev/' . $disk; } + if ($verbose) { + print "Processing disk: $name -> $disk\n"; + } + + if ($verbose) { + print " Command: $smartctl --json -a $disk\n"; + } + my $output = `$smartctl --json -a $disk`; + my $exit_code = $? >> 8; + + if ($verbose && $exit_code != 0) { + print " WARNING: smartctl exit code: $exit_code\n"; + my @output_lines = split(/\n/, $output); + if (@output_lines > 0) { + print " First few lines of output:\n"; + for my $i (0..min($#output_lines, 5)) { + print " $output_lines[$i]\n"; + } + } + } + my %IDs = ( '5' => undef, '10' => undef, @@ -776,6 +810,9 @@ foreach my $line (@disks) { } ## end if ( defined( $a_output->{'ata_smart_attributes'...})) #get the selftest logs + if ($verbose) { + print " Command: $smartctl -l selftest $disk\n"; + } $output = `$smartctl -l selftest $disk`; my @outputA = split( /\n/, $output ); my @completed = grep( /Completed/, @outputA ); @@ -1029,15 +1066,40 @@ foreach my $line (@disks) { # only bother to save this if useSN is not being used if ( !$useSN ) { + if ($verbose) { + print "Adding disk (useSN disabled): $disk_id\n"; + } $to_return->{data}{disks}{$disk_id} = \%IDs; - } elsif ( $IDs{exit} == 0 && defined($disk_id) ) { + } elsif ( $IDs{'json_err'} == 0 && defined($disk_id) && defined( $IDs{'serial'} ) ) { + if ($verbose) { + my $exit_info = ''; + if ( $IDs{exit} != 0 ) { + $exit_info = " (smartctl exit: " . ($IDs{exit} >> 8) . ")"; + } + print "Adding disk: $disk_id (serial: $IDs{serial}$exit_info)\n"; + } $to_return->{data}{disks}{$disk_id} = \%IDs; + } else { + if ($verbose) { + my $reason = ''; + if ( !defined($disk_id) ) { + $reason = 'undefined disk_id'; + } elsif ( !defined( $IDs{'serial'} ) ) { + $reason = 'no serial number'; + } elsif ( $IDs{'json_err'} != 0 ) { + $reason = "JSON parse error"; + } + print "Skipping disk: $name (reason: $reason)\n"; + } } # smartctl will in some cases exit zero when it can't pull data for cciss # so if we get a zero exit, but no serial then it means something errored # and the device is likely dead if ( $IDs{exit} == 0 && !defined( $IDs{serial} ) ) { + if ($verbose) { + print "Marking disk as unhealthy (no serial): $name\n"; + } $to_return->{data}{unhealthy}++; } } ## end foreach my $line (@disks) @@ -1055,6 +1117,13 @@ $compressed =~ s/\n//g; $compressed = $compressed . "\n"; if ( !$noWrite ) { + if ($verbose) { + my @disk_keys = keys %{ $to_return->{data}{disks} }; + print "Writing " . scalar(@disk_keys) . " disks to cache\n"; + print "Exit non-zero: $to_return->{data}{exit_nonzero}\n"; + print "Dev errors: $to_return->{data}{dev_error}\n"; + print "Unhealthy: $to_return->{data}{unhealthy}\n"; + } open( my $writefh, ">", $cache ) or die "Can't open '" . $cache . "'"; print $writefh $toReturn; close($writefh);