#!/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.human
import lib.lftest
import lib.net
import lib.txt
import lib.url
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Monitors HAProxy performance and health via the stats endpoint. Reports
frontend and backend session usage, request rates, response times, error rates, and server
states. Alerts when session usage exceeds the configured thresholds. Proxies, frontends,
backends or individual servers can be filtered out with --ignore (e.g. an on-demand certbot
backend that is only UP during cert renewal). HTTP basic auth is supplied through the URL
itself (`https://user:linuxfabrik@host/server-status`); the credentials are stripped from the
URL before the request is sent and instead carried in an `Authorization: Basic` header so
they never reach the request line or any proxy access log. Supports extended reporting
via --lengthy."""

DEFAULT_CRIT = 95  # %
DEFAULT_INSECURE = False
DEFAULT_LENGTHY = False
DEFAULT_NO_PROXY = False
DEFAULT_TIMEOUT = 3
DEFAULT_URL = 'unix:///run/haproxy.sock'
DEFAULT_WARN = 80  # %

GOOD_STATUSES = {'OPEN', 'UP', 'no check'}


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(
        '-c',
        '--critical',
        help=lib.args.help('--critical') + ' Default: >= %(default)s',
        dest='CRIT',
        type=int,
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--ignore',
        help='Ignore proxies, frontends, backends or servers matching this '
        'Python regular expression on the combined `<proxy>/<svname>` '
        'identifier (where `svname` is `FRONTEND`, `BACKEND` or an individual '
        'server name). Case-sensitive by default; use `(?i)` for '
        'case-insensitive matching. '
        'Can be specified multiple times. '
        'Example: `--ignore="^certbot/"` to ignore everything under the certbot proxy. '
        'Example: `--ignore="(?i)^certbot/"` for a case-insensitive match. '
        'Default: %(default)s',
        dest='IGNORE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--insecure',
        help=lib.args.help('--insecure'),
        dest='INSECURE',
        action='store_true',
        default=DEFAULT_INSECURE,
    )

    parser.add_argument(
        '--lengthy',
        help=lib.args.help('--lengthy'),
        dest='LENGTHY',
        action='store_true',
        default=DEFAULT_LENGTHY,
    )

    parser.add_argument(
        '--no-proxy',
        help=lib.args.help('--no-proxy'),
        dest='NO_PROXY',
        action='store_true',
        default=DEFAULT_NO_PROXY,
    )

    # `--password` and `--username` were removed in favour of HTTP basic
    # auth embedded in the URL itself (e.g. `https://user:linuxfabrik@host/`).
    # The flags stay registered (with argparse.SUPPRESS so they do not
    # show up in --help) so we can detect legacy usage in main() and
    # exit UNKNOWN with a clear migration pointer; otherwise
    # parser.parse_known_args() would silently swallow them and the
    # plugin would run without credentials, surfacing as a 401.
    parser.add_argument(
        '-p',
        '--password',
        help=argparse.SUPPRESS,
        dest='PASSWORD',
    )

    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(
        '-u',
        '--url',
        help='HAProxy stats URI. '
        'Accepts `unix:///path/to/haproxy.sock` or an HTTP(S) URL. '
        'For HTTP basic auth, embed the credentials in the URL itself; '
        'the plugin strips them from the netloc before the request is '
        'sent and carries them in an `Authorization: Basic` header. '
        'Example: `--url https://webserver:8443/server-status`. '
        'Example: `--url https://stats:s3cret@webserver:8443/server-status`. '
        'Default: %(default)s',
        dest='URL',
        default=DEFAULT_URL,
    )

    # See the comment on --password above.
    parser.add_argument(
        '--username',
        help=argparse.SUPPRESS,
        dest='USERNAME',
    )

    parser.add_argument(
        '-w',
        '--warning',
        help=lib.args.help('--warning') + ' Default: >= %(default)s',
        dest='WARN',
        type=int,
        default=DEFAULT_WARN,
    )

    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)

    # `--username` and `--password` were removed in favour of HTTP
    # basic auth embedded in the URL itself. They are still
    # registered (with argparse.SUPPRESS) so we can hard-UNKNOWN
    # here with a clear migration pointer instead of letting
    # parse_known_args silently swallow them and surface a 401 from
    # the haproxy stats endpoint.
    if args.USERNAME is not None or args.PASSWORD is not None:
        lib.base.cu(
            '--username and --password were removed. Embed the HTTP '
            'basic auth credentials in the URL itself, e.g. '
            '`--url=https://user:linuxfabrik@host:8443/server-status`. The '
            'plugin strips them from the netloc before the request is '
            'sent and carries them in an Authorization: Basic header.'
        )

    if args.IGNORE is None:
        args.IGNORE = []

    # compile ignore patterns (case-sensitive by default, matching the
    # lib.args convention for --match / --ignore-regex; the user can
    # opt into case-insensitive matching with the inline `(?i)` flag).
    try:
        ignore_patterns = [re.compile(p) for p in args.IGNORE]
    except re.error as e:
        lib.base.cu(f'Invalid regular expression: {e}')

    # fetch data
    result = ''
    if args.TEST is None:
        url = args.URL
        if not (url.startswith('http') or url.startswith('unix://')):
            lib.base.oao(
                '`--url` parameter must start with "http://", "https://" or "unix://"',
                STATE_UNKNOWN,
            )

        if url.startswith('http'):
            # Move any `user:linuxfabrik@` userinfo from the URL into an
            # Authorization: Basic header so the credentials never reach
            # the request line or any proxy access log. Append the
            # `;csv` query that the haproxy stats endpoint expects to
            # the stripped URL afterwards so the userinfo split stays
            # confined to lib.url.split_basic_auth().
            stripped_url, headers = lib.url.split_basic_auth(url)
            stripped_url += ';csv'
            result = lib.base.coe(
                lib.url.fetch(
                    stripped_url,
                    header=headers,
                    insecure=args.INSECURE,
                    no_proxy=args.NO_PROXY,
                    timeout=args.TIMEOUT,
                )
            )
        elif url.startswith('unix://'):
            result = lib.base.coe(
                lib.net.fetch_socket(
                    url.replace('unix://', ''), lib.txt.to_bytes('show stat\n')
                )
            )
    else:
        # do not call the command, put in test data
        stdout, _, _ = lib.lftest.test(args.TEST)
        result = stdout

    result = result.strip().split('\n')

    # init some vars
    msg_parts = []
    state = STATE_OK
    perfdata_parts = []
    table_values = []

    # analyze data
    for line in result[1:]:
        values = [x.strip() for x in line.strip().split(',')]

        # ugly, but necessary - the csv is huge
        try:
            ha_pxname = values[0]
            ha_svname = values[1]
            ha_qcur = values[2]
            _ha_qmax = values[3]
            ha_scur = values[4]
            _ha_smax = values[5]
            ha_slim = values[6]
            ha_stot = values[7]
            ha_bin = values[8]
            ha_bout = values[9]
            ha_dreq = values[10]
            ha_dresp = values[11]
            ha_ereq = values[12]
            ha_econ = values[13]
            ha_eresp = values[14]
            ha_wretr = values[15]
            ha_wredis = values[16]
            ha_status = values[17]
            ha_weight = values[18]
            ha_act = values[19]
            ha_bck = values[20]
            ha_chkfail = values[21]
            ha_chkdown = values[22]
            ha_lastchg = values[23]
            ha_downtime = values[24]
            ha_qlimit = values[25]
            _ha_pid = values[26]
            _ha_iid = values[27]
            _ha_sid = values[28]
            _ha_throttle = values[29]
            ha_lbtot = values[30]
            ha_tracked = values[31]
            _ha_type = values[32]
            ha_rate = values[33]
            ha_rate_lim = values[34]
            _ha_rate_max = values[35]
            _ha_check_status = values[36]
            _ha_check_code = values[37]
            _ha_check_duration = values[38]
            ha_hrsp_1xx = values[39]
            ha_hrsp_2xx = values[40]
            ha_hrsp_3xx = values[41]
            ha_hrsp_4xx = values[42]
            ha_hrsp_5xx = values[43]
            ha_hrsp_other = values[44]
            ha_hanafail = values[45]
            ha_req_rate = values[46]
            _ha_req_rate_max = values[47]
            ha_req_tot = values[48]
            ha_cli_abrt = values[49]
            ha_srv_abrt = values[50]
            ha_comp_in = values[51]
            ha_comp_out = values[52]
            ha_comp_byp = values[53]
            ha_comp_rsp = values[54]
            ha_lastsess = values[55]
            ha_last_chk = values[56]
            _ha_last_agt = values[57]
            ha_qtime = values[58]
            ha_ctime = values[59]
            ha_rtime = values[60]
            ha_ttime = values[61]
        except (ValueError, IndexError):
            lib.base.cu('Malformed HAProxy status info.')

        # Skip rows matched by --ignore. The combined `<pxname>/<svname>`
        # identifier is unique per row and lets the user target a whole
        # proxy (`^certbot/`), every backend aggregate (`/BACKEND$`) or
        # a specific server in a specific proxy (`^web/server-02$`).
        ignore_key = f'{ha_pxname}/{ha_svname}'
        if any(pattern.search(ignore_key) for pattern in ignore_patterns):
            continue

        # Status check
        status_state = STATE_OK
        if ha_status not in GOOD_STATUSES:
            status_state = STATE_WARN
            msg_parts.append(f'{ha_pxname} {ha_svname}: {ha_status}, ')
            state = lib.base.get_worst(status_state, state)

        # Queued connections
        if ha_qcur and ha_qlimit and float(ha_qlimit) != 0:
            q_percentage = round(float(ha_qcur) / float(ha_qlimit) * 100, 1)
            q_state = lib.base.get_state(q_percentage, args.WARN, args.CRIT)
            if q_state != STATE_OK:
                msg_parts.append(
                    f'{ha_pxname} {ha_svname}: {ha_qcur} queued ({q_percentage}%)'
                    f'{lib.base.state2str(q_state, prefix=" ")}, '
                )
                state = lib.base.get_worst(q_state, state)

        # Session usage
        if ha_scur and ha_slim and float(ha_slim) != 0:
            s_percentage = round(float(ha_scur) / float(ha_slim) * 100, 1)
            s_state = lib.base.get_state(s_percentage, args.WARN, args.CRIT)
            if s_state != STATE_OK:
                msg_parts.append(
                    f'{ha_pxname} {ha_svname}: {ha_scur} sessions ({s_percentage}%)'
                    f'{lib.base.state2str(s_state, prefix=" ")}, '
                )
                state = lib.base.get_worst(s_state, state)

        # Rate check
        if ha_rate and ha_rate_lim and float(ha_rate_lim) != 0:
            r_percentage = round(float(ha_rate) / float(ha_rate_lim) * 100, 1)
            r_state = lib.base.get_state(r_percentage, args.WARN, args.CRIT)
            if r_state != STATE_OK:
                msg_parts.append(
                    f'{ha_pxname} {ha_svname}: {ha_rate} sessions/sec ({r_percentage}%)'
                    f'{lib.base.state2str(r_state, prefix=" ")}, '
                )
                state = lib.base.get_worst(r_state, state)

        # Build table entry
        table_values.append(
            {
                'pxname': ha_pxname,
                'svname': ha_svname,
                'qcur': f'{ha_qcur}/{ha_qlimit}' if ha_qlimit else ha_qcur,
                'scur': f'{ha_scur}/{ha_slim}' if ha_slim else ha_scur,
                'bin': lib.human.bytes2human(int(ha_bin)) if ha_bin else '',
                'bout': lib.human.bytes2human(int(ha_bout)) if ha_bout else '',
                'status': f'{ha_status}{lib.base.state2str(status_state, prefix=" ")}',
                'lbtot': ha_lbtot,
                'rate': f'{ha_rate}/{ha_rate_lim}' if ha_rate_lim else ha_rate,
                'hrsp_2xx': ha_hrsp_2xx,
                'hrsp_4xx': ha_hrsp_4xx,
                'hrsp_5xx': ha_hrsp_5xx,
                'req_rate': ha_req_rate,
                'lastsess': (
                    lib.human.seconds2human(int(ha_lastsess))
                    if ha_lastsess not in ('', '-1')
                    else ''
                ),
                'ttime': ha_ttime,
            }
        )

        key = f'{ha_pxname}_{ha_svname}'

        perf_items = [
            ('act', ha_act, None),
            ('bck', ha_bck, None),
            ('bin', ha_bin, 'B'),
            ('bout', ha_bout, 'B'),
            ('chkdown', ha_chkdown, 'c'),
            ('chkfail', ha_chkfail, 'c'),
            ('cli_abrt', ha_cli_abrt, 'c'),
            ('comp_byp', ha_comp_byp, 'B'),
            ('comp_in', ha_comp_in, 'B'),
            ('comp_out', ha_comp_out, 'B'),
            ('comp_rsp', ha_comp_rsp, 'c'),
            ('ctime', ha_ctime, 'ms'),
            ('downtime', ha_downtime, 's'),
            ('dreq', ha_dreq, 'c'),
            ('dresp', ha_dresp, 'c'),
            ('econ', ha_econ, 'c'),
            ('ereq', ha_ereq, 'c'),
            ('eresp', ha_eresp, 'c'),
            ('hanafail', ha_hanafail, None),
            ('hrsp_1xx', ha_hrsp_1xx, 'c'),
            ('hrsp_2xx', ha_hrsp_2xx, 'c'),
            ('hrsp_3xx', ha_hrsp_3xx, 'c'),
            ('hrsp_4xx', ha_hrsp_4xx, 'c'),
            ('hrsp_5xx', ha_hrsp_5xx, 'c'),
            ('hrsp_other', ha_hrsp_other, 'c'),
            ('last_chk', ha_last_chk, 's'),
            ('lastchg', ha_lastchg, 's'),
            ('lastsess', ha_lastsess, 's'),
            ('lbtot', ha_lbtot, 'c'),
            ('qcur', ha_qcur, None),
            ('qlimit', ha_qlimit, None),
            ('qtime', ha_qtime, 'ms'),
            ('rate', ha_rate, None),
            ('rate_lim', ha_rate_lim, None),
            ('req_rate', ha_req_rate, None),
            ('req_tot', ha_req_tot, 'c'),
            ('rtime', ha_rtime, 'ms'),
            ('scur', ha_scur, None),
            ('slim', ha_slim, None),
            ('srv_abrt', ha_srv_abrt, 'c'),
            ('stot', ha_stot, 'c'),
            ('tracked', ha_tracked, None),
            ('ttime', ha_ttime, 'ms'),
            ('weight', ha_weight, None),
            ('wredis', ha_wredis, 'c'),
            ('wretr', ha_wretr, 'c'),
        ]

        for fieldname, value, unit in perf_items:
            if value:
                perfdata_parts.append(
                    lib.base.get_perfdata(
                        f'{key}_{fieldname}',
                        value,
                        uom=unit,
                        warn=None,
                        crit=None,
                        _min=0,
                        _max=None,
                    )
                )

    # build the message
    msg = ''.join(msg_parts).rstrip(', ') if msg_parts else 'Everything is ok.'
    msg += '\n\n'

    if not args.LENGTHY:
        msg += lib.base.get_table(
            table_values,
            [
                'pxname',
                'svname',
                'scur',
                'bin',
                'bout',
                'hrsp_5xx',
                'req_rate',
                'status',
            ],
            header=[
                'Proxy name',
                'Server name',
                'Sessions',
                'RqBytes',
                'RspBytes',
                'Rsp5xx',
                'Rq/s',
                'Status',
            ],
        )
    else:
        msg += lib.base.get_table(
            table_values,
            [
                'pxname',
                'svname',
                'qcur',
                'scur',
                'bin',
                'bout',
                'lbtot',
                'rate',
                'hrsp_2xx',
                'hrsp_4xx',
                'hrsp_5xx',
                'req_rate',
                'lastsess',
                'ttime',
                'status',
            ],
            header=[
                'Proxy name',
                'Server name',
                'Queued',
                'Sessions',
                'RqBytes',
                'RspBytes',
                'RqLB',
                'Rate',
                'Rsp2xx',
                'Rsp4xx',
                'Rsp5xx',
                'Rq/s',
                'LastReq',
                'RqRspTime',
                'Status',
            ],
        )

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


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