#!/usr/lib64/linuxfabrik-monitoring-plugins/venv/bin/python
# -*- 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 shlex
import sys

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

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

DESCRIPTION = """Executes a PowerShell command or script on a remote Windows host via WinRM,
JEA endpoints included, 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_SEVERITY_CONNTIMEOUT = 'unknown'
DEFAULT_SEVERITY_RETC = 'warn'
DEFAULT_SEVERITY_STDERR = 'warn'
DEFAULT_SEVERITY_STDOUT = 'ok'
DEFAULT_TIMEOUT = 8
DEFAULT_VERBOSE = False
DEFAULT_WINRM_DOMAIN = None
DEFAULT_WINRM_TRANSPORT = 'ntlm'


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        epilog=lib.args.epilog(__file__),
        formatter_class=lib.args.HelpFormatter,
    )

    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='PowerShell command or script to execute on the remote host. '
        'Supports pipelines and complex expressions. '
        'With `--winrm-configuration-name` (JEA), a single cmdlet with its '
        'parameters, all by name or all by position. '
        'Example: `--command="Get-Service -Name W32Time"`.',
        dest='COMMAND',
        required=True,
    )

    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(
        '--no-perfdata',
        help=lib.args.help('--no-perfdata'),
        dest='NO_PERFDATA',
        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',
        # `None` tells "not specified" apart from an explicit value: only an explicit
        # value applies to a command that runs into `--timeout`, which is WARN otherwise
        help='Severity on connection problems, and on a command that does not finish '
        'within `--timeout` (WARN if this parameter is not specified). '
        f'Default: {DEFAULT_SEVERITY_CONNTIMEOUT}',
        dest='SEVERITY_CONNTIMEOUT',
        default=None,
        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(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '--verbose',
        help=lib.args.help('--verbose'),
        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,
    )

    parser.add_argument(
        '--winrm-configuration-name',
        help='PowerShell session configuration name (JEA endpoint). '
        'Only supported with pypsrp.',
        dest='WINRM_CONFIGURATION_NAME',
    )

    parser.add_argument(
        '--winrm-domain',
        help=lib.args.help('--winrm-domain'),
        dest='WINRM_DOMAIN',
        default=DEFAULT_WINRM_DOMAIN,
    )

    parser.add_argument(
        '--winrm-hostname',
        help='Target Windows computer on which the command will be executed.',
        dest='WINRM_HOSTNAME',
        required=True,
    )

    parser.add_argument(
        '--winrm-password',
        help=lib.args.help('--winrm-password'),
        dest='WINRM_PASSWORD',
    )

    parser.add_argument(
        '--winrm-transport',
        help=lib.args.help('--winrm-transport') + ' Default: %(default)s',
        dest='WINRM_TRANSPORT',
        choices=['basic', 'ntlm', 'kerberos', 'credssp', 'plaintext'],
        default=DEFAULT_WINRM_TRANSPORT,
    )

    parser.add_argument(
        '--winrm-username',
        help=lib.args.help('--winrm-username'),
        dest='WINRM_USERNAME',
    )

    args, _ = parser.parse_known_args()
    return args


def split_jea_command(command):
    """Split `--command` into a cmdlet and its parameters for a JEA endpoint.

    A JEA endpoint runs in NoLanguage mode and refuses any script text, a single
    command included, with a ParseException; it only accepts a cmdlet with its
    parameters. `Get-Service -Name W32Time` becomes `('Get-Service', {'Name':
    'W32Time'})`, `Get-Service W32Time` becomes `('Get-Service', ['W32Time'])`, a
    parameter without a value is a switch. Quotes group a value as in a shell.

    Returns `(True, (cmdlet, params))`, or `(False, message)` for anything a JEA
    endpoint cannot run.
    """
    # a backslash is part of a Windows path, not an escape character
    lexer = shlex.shlex(command, posix=True)
    lexer.whitespace_split = True
    lexer.escape = ''
    try:
        tokens = list(lexer)
    except ValueError as e:
        return False, f'Unable to split --command into a cmdlet and parameters: {e}'
    if not tokens:
        return False, '--command is empty.'
    cmdlet, rest = tokens[0], tokens[1:]
    # a value reaches the cmdlet as a literal and is never run, so only the command
    # itself has to be a plain (optionally module-qualified) cmdlet name
    if not re.fullmatch(r'(?:[A-Za-z][\w.]*\\)?[A-Za-z][\w-]*', cmdlet) or any(
        token in ('|', ';', '&&', '||') for token in rest
    ):
        return (
            False,
            'A JEA endpoint runs a single cmdlet with its parameters, no pipes,'
            ' scripts or expressions. Put what goes beyond one cmdlet into a function'
            ' of the role capability, and call that.',
        )
    named = {}
    positional = []
    i = 0
    while i < len(rest):
        token = rest[i]
        if re.fullmatch(r'-[A-Za-z]\w*', token):
            if i + 1 < len(rest) and not re.fullmatch(r'-[A-Za-z]\w*', rest[i + 1]):
                named[token[1:]] = rest[i + 1]
                i += 2
            else:
                named[token[1:]] = True
                i += 1
        else:
            positional.append(token)
            i += 1
    if named and positional:
        return (
            False,
            'Behind a JEA endpoint, give the parameters of --command either all by'
            ' name (`-Name W32Time`) or all by position (`W32Time`), not mixed.',
        )
    return True, (cmdlet, named or positional)


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)
    if args.SEVERITY_CONNTIMEOUT is None:
        timeout_state = STATE_WARN
        args.SEVERITY_CONNTIMEOUT = DEFAULT_SEVERITY_CONNTIMEOUT
    else:
        timeout_state = lib.base.str2state(args.SEVERITY_CONNTIMEOUT)

    # 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 WinRM command
    if args.TEST is None:
        timer_start = lib.time.now('float')
        if args.WINRM_CONFIGURATION_NAME:
            cmdlet, params = lib.base.coe(split_jea_command(args.COMMAND))
            result = lib.winrm.run_ps(args, cmdlet, params, timeout=args.TIMEOUT)
        else:
            result = lib.winrm.run_ps(args, args.COMMAND, timeout=args.TIMEOUT)
        if result['retc'] and result['stderr'].startswith('Timeout after'):
            lib.base.oao(
                f'Timeout after {args.TIMEOUT}s while running the command on'
                f' {args.WINRM_HOSTNAME}.',
                timeout_state,
                always_ok=args.ALWAYS_OK,
            )
        stdout, stderr, retc = result['stdout'], result['stderr'], result['retc']
    else:
        # do not call the command, put in test data
        timer_start = lib.time.now('float')
        stdout, stderr, retc = lib.lftest.test(args.TEST)

    perfdata = lib.base.get_perfdata(
        'remote_runtime',
        round(lib.time.now('float') - timer_start, 3),
        uom='s',
        warn=None,
        crit=None,
        _min=None,
        _max=None,
    )

    # 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
    state = stdout_state = stderr_state = retc_state = STATE_OK
    reasons = []  # what raised the stdout state, for the first line

    # 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 stdout_state != STATE_OK:
            reasons.append('The command returned output')

    # check stdout - alert with given severity on a single numeric value
    is_number = False
    try:
        float(stdout)
        is_number = True
        stdout_state = lib.base.get_state(
            stdout, args.WARN, args.CRIT, _operator='range'
        )
    except ValueError:
        pass

    # check stdout - alert with given severity using pattern matching
    # the help promises case-insensitive matching per line, for the regexes as
    # for the patterns, and an invalid regex ends in a clear UNKNOWN instead of a
    # traceback
    compiled_warn_regex = [
        lib.base.coe(item)
        for item in lib.txt.compile_regex(
            args.WARN_REGEX, '--warning-regex', flags=re.IGNORECASE | re.MULTILINE
        )
    ]
    compiled_crit_regex = [
        lib.base.coe(item)
        for item in lib.txt.compile_regex(
            args.CRIT_REGEX, '--critical-regex', flags=re.IGNORECASE | re.MULTILINE
        )
    ]
    haystack = stdout.lower()
    if any(
        warn_pattern.lower() in haystack for warn_pattern in args.WARN_PATTERN
    ) or any(item.search(stdout) for item in compiled_warn_regex):
        stdout_state = STATE_WARN
        reasons = ['The output matches a warning pattern']
    if any(
        crit_pattern.lower() in haystack for crit_pattern in args.CRIT_PATTERN
    ) or any(item.search(stdout) for item in compiled_crit_regex):
        stdout_state = STATE_CRIT
        reasons = ['The output matches a critical pattern']
    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
    connection_failed = bool(retc and not stdout and stderr)
    if retc:
        if connection_failed:
            # no command output but error present - likely a connection
            # error (timeout, authentication failure, etc.)
            retc_state = lib.base.str2state(args.SEVERITY_CONNTIMEOUT)
        else:
            retc_state = lib.base.str2state(args.SEVERITY_RETC)
        state = lib.base.get_worst(state, retc_state)
    if connection_failed:
        # stderr is the connection error itself, so --severity-timeout decides
        # alone, UNKNOWN by default: nothing ran, nothing is known about the host
        state = retc_state

    # build the message: a first line that says what happened, then the output
    stderr_head = stderr.splitlines()[0] if stderr else ''
    if connection_failed:
        header = (
            f'Unable to run the command on {args.WINRM_HOSTNAME}: {stderr_head}'
            f'{lib.base.state2str(retc_state, prefix=" ")}'
        )
    else:
        parts = []
        if is_number:
            parts.append(f'{stdout}{lib.base.state2str(stdout_state, prefix=" ")}')
        elif reasons:
            parts.append(f'{reasons[0]}{lib.base.state2str(stdout_state, prefix=" ")}')
        if retc:
            parts.append(
                f'Exit code {retc}{lib.base.state2str(retc_state, prefix=" ")}'
            )
        if stderr:
            parts.append(
                f'STDERR: {stderr_head}{lib.base.state2str(stderr_state, prefix=" ")}'
            )
        if parts:
            header = ', '.join(parts)
        elif not stdout:
            header = 'Everything is ok. The command returned no output.'
        elif '\n' not in stdout:
            header = stdout
        else:
            header = 'Everything is ok.'
    body = []
    if stdout and not is_number and header != stdout:
        body.append(stdout)
    if '\n' in stderr:
        body.append(f'STDERR:\n{stderr}')
    if args.VERBOSE:
        body.append(
            'Connected via pypsrp, which supports JEA.'
            if lib.winrm.HAVE_JEA
            else 'Connected via pywinrm, without JEA.'
        )
    msg = header + ''.join(f'\n\n{part}' for part in body)

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


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