#!/usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import re
import sys

import lib.args
import lib.base
import lib.lftest
import lib.shell
import lib.time
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026040801'

DESCRIPTION = """Executes a command on a remote host via SSH and evaluates the result. Returns
STDOUT and, in case of failure, STDERR and the command's exit code. Supports pattern matching
on STDOUT to detect specific conditions, with configurable alert severities per match. Can also
alert on single numeric return values against warning and critical thresholds."""

DEFAULT_PORT = 22
DEFAULT_SEVERITY_CONNTIMEOUT = 'unknown'
DEFAULT_SEVERITY_RETC = 'warn'
DEFAULT_SEVERITY_STDERR = 'warn'
DEFAULT_SEVERITY_STDOUT = 'ok'
DEFAULT_USERNAME = 'root'
DEFAULT_VERBOSE = False


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(description=DESCRIPTION)

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--command',
        help='SSH: Command that will be executed on the remote host.',
        dest='COMMAND',
        required=True,
    )

    parser.add_argument(
        '--configfile',
        help='SSH: Alternative per-user configuration file. If a configuration '
        'file is given on the command line, the system-wide '
        'configuration file (`/etc/ssh/ssh_config`) will be ignored. The '
        'default for the per-user configuration file is `~/.ssh/config`. If '
        'set to `none`, no configuration files will be read.',
        dest='CONFIGFILE',
    )

    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for single numeric return values. '
        'Supports Nagios ranges. '
        'Example: `@10:20` alerts if STDOUT is in range 10..20.',
        dest='CRIT',
    )

    parser.add_argument(
        '--critical-pattern',
        help='Any line matching this pattern (case-insensitive) will count as a critical. '
        'Can be specified multiple times.',
        dest='CRIT_PATTERN',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--critical-regex',
        help='Any line matching this Python regex (case-insensitive) will count as a critical. '
        'Can be specified multiple times.',
        dest='CRIT_REGEX',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--disable-pseudo-terminal',
        help='SSH: Disable pseudo-terminal allocation.',
        dest='DISABLE_PSEUDO_TERMINAL',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '-H',
        '--hostname',
        help='SSH: Hostname.',
        dest='HOSTNAME',
        required=True,
    )

    parser.add_argument(
        '--identity',
        help='SSH: File from which the identity (private key) for public '
        'key authentication is read. You can also specify a public key '
        'file to use the corresponding private key that is loaded in '
        'ssh-agent(1) when the private key file is not present locally. '
        'The default is `~/.ssh/id_dsa`, `~/.ssh/id_ecdsa`, '
        '`~/.ssh/id_ecdsa_sk`, `~/.ssh/id_ed25519`, `~/.ssh/id_ed25519_sk` and '
        '`~/.ssh/id_rsa`. Identity files may also be specified on a per-'
        'host basis in the configuration file. It is possible to have '
        'multiple --identity options (and multiple identities specified in configuration '
        'files). If no certificates have been explicitly specified by the '
        'CertificateFile directive, ssh will also try to load '
        'certificate information from the filename obtained by appending '
        '`-cert.pub` to identity filenames.',
        dest='IDENTITY',
        action='append',
    )

    parser.add_argument(
        '--ipv4',
        help='SSH: Forces ssh to use IPv4 addresses only.',
        dest='IPV4',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--ipv6',
        help='SSH: Forces ssh to use IPv6 addresses only.',
        dest='IPV6',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '-p',
        '--password',
        help='SSH: Password authentication. NOT RECOMMENDED. Requires `sshpass`. '
        'If you need to use password-based SSH login, run this plugin only on trusted hosts. '
        '`ps` will expose the SSH password.',
        dest='PASSWORD',
        default=None,
    )

    parser.add_argument(
        '--port',
        help='SSH: Port to connect to on the remote host. This can be specified on '
        'a per-host basis in the configuration file. Default: %(default)s',
        dest='PORT',
        default=DEFAULT_PORT,
    )

    parser.add_argument(
        '--quiet',
        help='SSH: Quiet mode. Causes most warning and diagnostic messages to be '
        'suppressed.',
        dest='QUIET',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--severity-retc',
        help='Severity for alerting if there is a return code != 0. Default: %(default)s',
        dest='SEVERITY_RETC',
        default=DEFAULT_SEVERITY_RETC,
        choices=['ok', 'warn', 'crit', 'unknown'],
    )

    parser.add_argument(
        '--severity-stderr',
        help='Severity for alerting if there is an output on STDERR. Default: %(default)s',
        dest='SEVERITY_STDERR',
        default=DEFAULT_SEVERITY_STDERR,
        choices=['ok', 'warn', 'crit', 'unknown'],
    )

    parser.add_argument(
        '--severity-stdout',
        help='Severity for alerting if there is an output on STDOUT. Default: %(default)s',
        dest='SEVERITY_STDOUT',
        default=DEFAULT_SEVERITY_STDOUT,
        choices=['ok', 'warn', 'crit', 'unknown'],
    )

    parser.add_argument(
        '--severity-timeout',
        help='Severity on connection problems. Default: %(default)s',
        dest='SEVERITY_CONNTIMEOUT',
        default=DEFAULT_SEVERITY_CONNTIMEOUT,
        choices=['ok', 'warn', 'crit', 'unknown'],
    )

    parser.add_argument(
        '--skip-stderr',
        help='Ignore all (0) or first n lines on STDERR. Default: %(default)s (no ignore).',
        dest='SKIP_STDERR',
        type=int,
        default=-1,
    )

    parser.add_argument(
        '--skip-stdout',
        help='Ignore all (0) or first n lines on STDOUT. Default: %(default)s (no ignore).',
        dest='SKIP_STDOUT',
        type=int,
        default=-1,
    )

    parser.add_argument(
        '--ssh-option',
        help='SSH: Can be used to give options in the format used in the configuration file. '
        'This is useful for specifying options for which there '
        'is no separate command-line flag. For full details of the options, '
        'and their possible values, see ssh_config(5). Can be specified multiple times.',
        dest='SSH_OPTION',
        action='append',
    )

    parser.add_argument(
        '--shell',
        help='Enable shell expansion for environment variables and file globs. '
        'Can be a security hazard. '
        'Without this option, only simple commands without globs or pipes are supported.',
        dest='SHELL',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    parser.add_argument(
        '-u',
        '--username',
        help='SSH: Username. Default: %(default)s',
        dest='USERNAME',
        default=DEFAULT_USERNAME,
    )

    parser.add_argument(
        '--verbose',
        help=lib.args.help('--verbose') + ' Default: %(default)s',
        dest='VERBOSE',
        action='store_true',
        default=DEFAULT_VERBOSE,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for single numeric return values. '
        'Supports Nagios ranges. '
        'Example: `@10:20` alerts if STDOUT is in range 10..20.',
        dest='WARN',
    )

    parser.add_argument(
        '--warning-pattern',
        help='Any line matching this pattern (case-insensitive) will count as a warning. '
        'Can be specified multiple times.',
        dest='WARN_PATTERN',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--warning-regex',
        help='Any line matching this Python regex (case-insensitive) will count as a warning. '
        'Can be specified multiple times.',
        dest='WARN_REGEX',
        action='append',
        default=None,
    )

    args, _ = parser.parse_known_args()
    return args


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    # set default values for append parameters that were not specified
    if args.CRIT_PATTERN is None:
        args.CRIT_PATTERN = []
    if args.CRIT_REGEX is None:
        args.CRIT_REGEX = []
    if args.WARN_PATTERN is None:
        args.WARN_PATTERN = []
    if args.WARN_REGEX is None:
        args.WARN_REGEX = []

    # fetch data - run ssh
    cmd = (
        f'ssh'
        f' {"-4" if args.IPV4 else ""}'
        f' {"-6" if args.IPV6 else ""}'
        f""" {f"-F '{args.CONFIGFILE}'" if args.CONFIGFILE else ''}"""
        f""" {"-i '" + "' -i '".join(args.IDENTITY) + "'" if args.IDENTITY else ''}"""
        f""" {"-o '" + "' -o '".join(args.SSH_OPTION) + "'" if args.SSH_OPTION else ''}"""
        f' {f"-p {args.PORT}" if args.PORT else ""}'
        f' {"-q" if args.QUIET else ""}'
        f' {"-T" if args.DISABLE_PSEUDO_TERMINAL else ""}'
        f" '{args.USERNAME}'@'{args.HOSTNAME}'"
        f" '{args.COMMAND}'"
    )
    cmd = ' '.join(cmd.split())  # strip all whitespaces
    if args.PASSWORD:
        # add sshpass in front of it
        cmd = f'sshpass -p {args.PASSWORD} {cmd}'
    timer_start = lib.time.now('float')
    # shell=True is opt-in via --shell for the admin; the plugin's purpose is
    # to execute admin-provided remote commands over SSH
    stdout, stderr, retc = lib.base.coe(lib.shell.shell_exec(cmd, shell=args.SHELL))  # nosec B604
    if args.PASSWORD and not stderr and retc in range(1, 8):
        # we got a specific sshpass return code (this also means stderr = ""), and not just
        # something like stderr = "Permission denied" with retc 127 or 255
        stderr = f'sshpass: {lib.shell.RETC_SSHPASS[retc]}'

    perfdata = lib.base.get_perfdata(
        'remote_runtime',
        lib.time.now('float') - timer_start,
        uom='s',
    )

    # strip output
    if args.SKIP_STDOUT == 0:
        # ignore all lines on STDOUT
        stdout = ''
    if args.SKIP_STDOUT > 0:
        stdout = '\n'.join(stdout.splitlines()[args.SKIP_STDOUT :])
    if args.SKIP_STDERR == 0:
        # ignore all lines on STDERR
        stderr = ''
    if args.SKIP_STDERR > 0:
        stderr = '\n'.join(stderr.splitlines()[args.SKIP_STDERR :])
    stdout = stdout.strip()
    stderr = stderr.strip()

    # init some vars
    msg = ''
    state = stdout_state = stderr_state = retc_state = STATE_OK

    # analyze data

    # check stdout - alert with given severity if there is any output at all
    if stdout:
        stdout_state = lib.base.str2state(
            args.SEVERITY_STDOUT
        )  # if SEVERITY_STDOUT != 'ok'
        state = lib.base.get_worst(state, stdout_state)

    # check stdout - alert with given severity on a single numeric value
    try:
        float(stdout)
        stdout_state = lib.base.get_state(
            stdout, args.WARN, args.CRIT, _operator='range'
        )
        state = lib.base.get_worst(state, stdout_state)
    except Exception:
        if args.VERBOSE:
            msg += 'STDOUT is not a "float". '

    # check stdout - alert with given severity using pattern matching
    compiled_warn_regex = [re.compile(item) for item in args.WARN_REGEX]
    compiled_crit_regex = [re.compile(item) for item in args.CRIT_REGEX]
    haystack = stdout.lower()
    if any(
        warn_pattern.lower() in haystack for warn_pattern in args.WARN_PATTERN
    ) or any(item.search(haystack) for item in compiled_warn_regex):
        stdout_state = STATE_WARN
    if any(
        crit_pattern.lower() in haystack for crit_pattern in args.CRIT_PATTERN
    ) or any(item.search(haystack) for item in compiled_crit_regex):
        stdout_state = STATE_CRIT
    state = lib.base.get_worst(state, stdout_state)

    # stderr overwrites state from stdout
    if stderr:
        stderr_state = lib.base.str2state(args.SEVERITY_STDERR)
        state = lib.base.get_worst(state, stderr_state)

    # retc overwrites state from stderr
    if retc:
        if retc == 255 and not stdout and not stderr:
            stderr = f'ssh: Could not connect. The full command used was: `{cmd}`'
            state = lib.base.str2state(args.SEVERITY_CONNTIMEOUT)
        else:
            retc_state = lib.base.str2state(args.SEVERITY_RETC)
            state = lib.base.get_worst(state, retc_state)

    # build the message
    if retc:
        msg += f'retc: {retc}{lib.base.state2str(retc_state, prefix=" ")}; '
    if stderr:
        msg += f'stderr: {stderr}{lib.base.state2str(stderr_state, prefix=" ")}; '
    msg += f'{stdout if stdout else "stdout: None"}{lib.base.state2str(stdout_state, prefix=" ")}'

    # over and out
    lib.base.oao(msg, state, perfdata, always_ok=args.ALWAYS_OK)


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
