Commit d9efd3954e7ea9c23f851971e75b930c91dd9da8
1 parent
ed63fa91
added first implementation of check_otp supporting HOTP and TOTP checks against PrivacyIDEA/LinOTP
Showing
2 changed files
with
666 additions
and
11 deletions
README.md
1 | nagios-plugins | 1 | nagios-plugins |
2 | ============== | 2 | ============== |
3 | -This repository contains my collection of modified and custom written check | ||
4 | -plugins and scripts for [Nagios](http://www.nagios.org). | 3 | +This repository contains my small collection of modified and custom written |
4 | +nagios check plugins and scripts for [Nagios](http://www.nagios.org). | ||
5 | 5 | ||
6 | Most of these are very custom solutions or modified versions of standard plugins | 6 | Most of these are very custom solutions or modified versions of standard plugins |
7 | so distributing them through [NagiosExchange](https://exchange.nagios.org/) is | 7 | so distributing them through [NagiosExchange](https://exchange.nagios.org/) is |
@@ -12,12 +12,23 @@ encounter any issues or require changes. | @@ -12,12 +12,23 @@ encounter any issues or require changes. | ||
12 | The latest versions, documentation and bugtracker available on my | 12 | The latest versions, documentation and bugtracker available on my |
13 | [GitLab instance](https://gitlab.lindenaar.net/scripts/privacyidea-checkotp) | 13 | [GitLab instance](https://gitlab.lindenaar.net/scripts/privacyidea-checkotp) |
14 | 14 | ||
15 | -Copyright (c) 2015 Frederik Lindenaar. free for distribution under the GNU | ||
16 | -License, see [below](#license) | 15 | +Copyright (c) 2015 - 2016 Frederik Lindenaar. free for distribution under |
16 | +the GNU General Public License, see [below](#license) | ||
17 | 17 | ||
18 | +contents | ||
19 | +======== | ||
20 | +This repository contains the following scripts: | ||
21 | + * [check_memory](#check_memory) | ||
22 | + patched version of nagios-plugins check_memory script for Linux procps v3.3+ | ||
23 | + * [check_multiple_host_addresses](#host_addresses) | ||
24 | + monitor multi-home and dual-stack (i.e. ipv4 and ipv6) servers. | ||
25 | + * [check_otp](#check_otp) | ||
26 | + plugin to monitor PrivacyIDEA (and LinOTP) OTP validation | ||
27 | + * [nagiosstatus](#nagiosstatus) | ||
28 | + CGI-BIN script to report the status of nagios (to monitor nagios itself) | ||
18 | 29 | ||
19 | -plugins/check_memory | ||
20 | --------------------- | 30 | +<a name=check_memory>plugins/check_memory</a> |
31 | +--------------------------------------------- | ||
21 | Nagios check script to monitor the memory on Linux systems. Due to changes in | 32 | Nagios check script to monitor the memory on Linux systems. Due to changes in |
22 | the output of procps v3.3 (the changelog refers to it as modernizing it), it's | 33 | the output of procps v3.3 (the changelog refers to it as modernizing it), it's |
23 | output changed and breaks the the check_memory script as shipped with many linux | 34 | output changed and breaks the the check_memory script as shipped with many linux |
@@ -26,8 +37,8 @@ is indifferent of which version of procps (to date) is used. No other changes | @@ -26,8 +37,8 @@ is indifferent of which version of procps (to date) is used. No other changes | ||
26 | were made to the script. | 37 | were made to the script. |
27 | 38 | ||
28 | 39 | ||
29 | -plugins/plugins/check_multiple_host_addresses | ||
30 | ---------------------------------------------- | 40 | +<a name=host_addresses>plugins/check_multiple_host_addresses</a> |
41 | +---------------------------------------------------------------- | ||
31 | This script is a first attempt to monitor multi-home and dual-stack (i.e. ipv4 | 42 | This script is a first attempt to monitor multi-home and dual-stack (i.e. ipv4 |
32 | and ipv6) servers. In my setup a server should only considered availble if it is | 43 | and ipv6) servers. In my setup a server should only considered availble if it is |
33 | available on all of its primary addresses (i.e. both ipv4 and ipv6). It uses the | 44 | available on all of its primary addresses (i.e. both ipv4 and ipv6). It uses the |
@@ -38,6 +49,7 @@ this solution as well. | @@ -38,6 +49,7 @@ this solution as well. | ||
38 | 49 | ||
39 | Installation is straightforward, after installing the script on your server, add | 50 | Installation is straightforward, after installing the script on your server, add |
40 | the following to your `commands.cmd` configuration file to make it available: | 51 | the following to your `commands.cmd` configuration file to make it available: |
52 | + | ||
41 | ~~~ | 53 | ~~~ |
42 | # 'check-host-alive' command definition for multi-homed/dual-stack servers | 54 | # 'check-host-alive' command definition for multi-homed/dual-stack servers |
43 | define command{ | 55 | define command{ |
@@ -45,8 +57,10 @@ the following to your `commands.cmd` configuration file to make it available: | @@ -45,8 +57,10 @@ the following to your `commands.cmd` configuration file to make it available: | ||
45 | command_line [install_path]/plugins/check_multiplehost_addresses '$HOSTADDRESS$' '$_HOSTADDRESS6$' | 57 | command_line [install_path]/plugins/check_multiplehost_addresses '$HOSTADDRESS$' '$_HOSTADDRESS6$' |
46 | } | 58 | } |
47 | ~~~ | 59 | ~~~ |
60 | + | ||
48 | The example above assumes that the IPv6 address of the host is provided as part | 61 | The example above assumes that the IPv6 address of the host is provided as part |
49 | of the host configuration, i.e.: | 62 | of the host configuration, i.e.: |
63 | + | ||
50 | ~~~ | 64 | ~~~ |
51 | define host { | 65 | define host { |
52 | ... | 66 | ... |
@@ -55,13 +69,172 @@ of the host configuration, i.e.: | @@ -55,13 +69,172 @@ of the host configuration, i.e.: | ||
55 | ... | 69 | ... |
56 | } | 70 | } |
57 | ~~~ | 71 | ~~~ |
58 | -To use the script either add ` check_command check-addresses-alive` | 72 | + |
73 | +To use the script either add `check_command check-addresses-alive` | ||
59 | to the specific hosts that should use the check or to the generic host used as | 74 | to the specific hosts that should use the check or to the generic host used as |
60 | template. | 75 | template. |
61 | 76 | ||
62 | 77 | ||
63 | -cgi-bin/nagiosstatus.sh | ||
64 | ------------------------ | 78 | +<a name=check_otp>plugins/check_otp</a> |
79 | +--------------------------------------- | ||
80 | +Plugin (check) to monitor OTP validation, currently implemented for PrivacyIDEA | ||
81 | +(and LinOTP). The check can validate a provided password/secret or calculate an | ||
82 | +HOTP or TOTP value and use that to validate (with or without a password). Other | ||
83 | +methods and interfaces can be plugged in easily (please raise a request or | ||
84 | +provide a patch). | ||
85 | + | ||
86 | +Please run `check_otp -h` for an actual overview of the available options. The | ||
87 | +script currently supports 3 modes of operation: | ||
88 | + | ||
89 | + * password - simply authenticate with the provided secret (no calculations) | ||
90 | + * totp - calculate the TOTP code using a key and current time | ||
91 | + * hotp - calculate the HOTP code using a key and a count (automatically | ||
92 | + increments the count in case a count file is used) | ||
93 | + | ||
94 | +Generic parameters (connection parameters, critical/warning thresholds, etc.) | ||
95 | +should be provided before the mode of operation is specified, mode-specific | ||
96 | +parameters should follow the mode selected. Keys, passwords and HOTP counts can | ||
97 | +be read from a file as well. Checks can be performed based on token | ||
98 | +serial or a login and a password (only mandatory for password authentication). | ||
99 | + | ||
100 | +HOTP/TOTP modes require a Base16/32/64 encoded key provided on the command-line | ||
101 | +or in a file. The generated HOTP/TOTP value is appended to the password/secret | ||
102 | +(if provided), the order can be changed with the `-m` command line parameter. | ||
103 | + | ||
104 | +Installation for is straightforward, after installing the script on the server | ||
105 | +add the following to your Nagios `commands.cmd` configuration file: | ||
106 | + | ||
107 | +~~~ | ||
108 | +# 'check_totp_serial' command definition to test TOTP based on token serial (no password) | ||
109 | +# parameters: token serial (ARG1), key (ARG2), additional parameters in ARG3 | ||
110 | +define command { | ||
111 | + command_name check_totp_serial | ||
112 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token totp -s $ARG1$ -k $ARG2$ $ARG3$ | ||
113 | +} | ||
114 | + | ||
115 | +# 'check_totp_serial' command definition to test TOTP based on token serial and password | ||
116 | +# parameters: token serial (ARG1), key (ARG2), password (ARG3), additional parameters in ARG4 | ||
117 | +define command { | ||
118 | + command_name check_totp_serial_pwd | ||
119 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token totp -s $ARG1$ -k $ARG2$ -p $ARG3$ $ARG4$ | ||
120 | +} | ||
121 | + | ||
122 | +# 'check_totp_login' command definition to test TOTP based on login and password | ||
123 | +# parameters: login (ARG1), key (ARG2), password (ARG3), additional parameters in ARG4 | ||
124 | +define command { | ||
125 | + command_name check_totp_login | ||
126 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token totp -l $ARG1$ -k $ARG2$ -p $ARG3$ $ARG4$ | ||
127 | +} | ||
128 | + | ||
129 | +# 'check_totp_serial_dir' command definition to test TOTP based on token serial | ||
130 | +# parameters: directory (ARG1), token serial (ARG2) additional parameters in ARG3 | ||
131 | +define command { | ||
132 | + command_name check_totp_serial_dir | ||
133 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token totp -s $ARG2$ -K $ARG1$/$ARG2$.key $ARG3$ | ||
134 | +} | ||
135 | + | ||
136 | +# 'check_totp_serial_dir_pwd' command definition to test TOTP based on token serial and password | ||
137 | +# parameters: directory (ARG1), token serial (ARG2), additional parameters in ARG3 | ||
138 | +define command { | ||
139 | + command_name check_totp_serial_dir_pwd | ||
140 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token totp -s $ARG2$ -K $ARG1$/$ARG2$.key -P $ARG1$/$ARG2$.pwd $ARG3$ | ||
141 | +} | ||
142 | + | ||
143 | +# 'check_totp_login_dir' command definition to test TOTP based on login | ||
144 | +# parameters: directory (ARG1), login (ARG2), additional parameters in ARG3 | ||
145 | +define command { | ||
146 | + command_name check_totp_login_dir | ||
147 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token totp -l $ARG2$ -K $ARG1$/$ARG2$.key $ARG3$ | ||
148 | +} | ||
149 | + | ||
150 | +# 'check_totp_login_dir_pwd' command definition to test TOTP based on login and password | ||
151 | +# parameters: directory (ARG1), login (ARG2) additional parameters in ARG3 | ||
152 | +define command { | ||
153 | + command_name check_totp_login_dir_pwd | ||
154 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token totp -l $ARG2$ -K $ARG1$/$ARG2$.key -P $ARG1$/$ARG2$.pwd $ARG3$ | ||
155 | +} | ||
156 | + | ||
157 | +# 'check_hotp_serial_dir' command definition to test HOTP based on token serial | ||
158 | +# parameters: directory (ARG1), token serial (ARG2), additional parameters in ARG3 | ||
159 | +define command { | ||
160 | + command_name check_hotp_serial_dir | ||
161 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token hotp -s $ARG2$ -K $ARG1$/$ARG2$.key -C $ARG1$/$ARG2$.count $ARG3$ | ||
162 | +} | ||
163 | + | ||
164 | +# 'check_hotp_serial_dir_pwd' command definition to test HOTP based on token serial and password | ||
165 | +# parameters: directory (ARG1), token serial (ARG2), additional parameters in ARG3 | ||
166 | +define command { | ||
167 | + command_name check_hotp_serial_dir_pwd | ||
168 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token hotp -s $ARG2$ -K $ARG1$/$ARG2$.key -C $ARG1$/$ARG2$.count -P $ARG1$/$ARG2$.pwd $ARG3$ | ||
169 | +} | ||
170 | + | ||
171 | +# 'check_hotp_login_dir' command definition to test HOTP based on login | ||
172 | +# parameters: directory (ARG1), login (ARG2), additional parameters in ARG3 | ||
173 | +define command { | ||
174 | + command_name check_hotp_login_dir | ||
175 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token hotp -l $ARG2$ -K $ARG1$/$ARG2$.key -C $ARG1$/$ARG2$.count $ARG3$ | ||
176 | +} | ||
177 | + | ||
178 | +# 'check_hotp_login_dir_pwd' command definition to test HOTP based on login and password | ||
179 | +# parameters: directory (ARG1), login (ARG2), additional parameters in ARG3 | ||
180 | +define command { | ||
181 | + command_name check_hotp_login_dir_pwd | ||
182 | + command_line [install_path]/plugins/check_otp -H $HOSTNAME$ -w 3 -c 8 -P /token hotp -l $ARG2$ -K $ARG1$/$ARG2$.key -C $ARG1$/$ARG2$.count -P $ARG1$/$ARG2$.pwd $ARG3$ | ||
183 | +} | ||
184 | + | ||
185 | +~~~ | ||
186 | + | ||
187 | +Please check / adjust the following: | ||
188 | + | ||
189 | + * replace `[install_path]/plugins` with the location of the script | ||
190 | + * assumption is that the `$HOSTNAME$` can be used for an SSL connection (and | ||
191 | + that the certificate is valid for this host, use the -u parameter and an | ||
192 | + URL if this is not the case) | ||
193 | + * path on the server is assumed to be /token (API endpoints will be added) | ||
194 | + * check the thresholds for Warning (3s) and Critical (8s), adjust if needed | ||
195 | + | ||
196 | +The `dir` and `dir_pwd` commands allow to store all sensitive data for tokens in | ||
197 | +a folder and hence only require a folder name and token serial or login. This | ||
198 | +expects the folder specified to contain the following files: | ||
199 | + | ||
200 | + * [serial/login].key - HOTP/TOTP key in Base16/32/64 format on first line | ||
201 | + * [serial/login].pwd - password (only first line is used) | ||
202 | + * [serial/login].count - numeric HOTP count on first line, autoincremented | ||
203 | + | ||
204 | +Please note that required files must exist or the check will fail with an error. | ||
205 | + | ||
206 | +To use the it define a service check like below: | ||
207 | + | ||
208 | +~~~ | ||
209 | +# check that TOTP authentication is working for token serial and provided key | ||
210 | +define service { | ||
211 | + host hostname.mydomain.tld | ||
212 | + service_description Check TOTP Authentication | ||
213 | + check_command check_totp_serial!TOTP0001234X!82f37371367b7e8aafb320b2d9b2721f66bbf161 | ||
214 | + use generic-service | ||
215 | +} | ||
216 | + | ||
217 | + | ||
218 | +# check that TOTP authentication is working for token serial and info from folder | ||
219 | +define service { | ||
220 | + host hostname.mydomain.tld | ||
221 | + service_description Check TOTP Authentication | ||
222 | + check_command check_totp_serial_dir!/etc/nagios3/tokeninfo!TOTP0001234X | ||
223 | + use generic-service | ||
224 | +} | ||
225 | + | ||
226 | +# check that HOTP authentication is working for token serial and info from folder | ||
227 | +define service { | ||
228 | + host hostname.mydomain.tld | ||
229 | + service_description Check TOTP Authentication | ||
230 | + check_command check_hotp_serial_dir!/etc/nagios3/tokeninfo!HOTP0004321Y | ||
231 | + use generic-service | ||
232 | +} | ||
233 | +~~~ | ||
234 | + | ||
235 | + | ||
236 | +<a name=nagiosstatus>cgi-bin/nagiosstatus.sh</a> | ||
237 | +------------------------------------------------ | ||
65 | Very simplistic CGI-BIN script that checkes whether nagios is still running and | 238 | Very simplistic CGI-BIN script that checkes whether nagios is still running and |
66 | still updating its status. It wil always return an HTTP Status 200 (OK) and a | 239 | still updating its status. It wil always return an HTTP Status 200 (OK) and a |
67 | simple text page with one of the following texts: | 240 | simple text page with one of the following texts: |
plugins/check_otp
0 โ 100755
1 | +#! /usr/bin/env python | ||
2 | +# | ||
3 | +# check_otp - Nagios check plugin for LinOTP/PrivacyIDEA OTP validation | ||
4 | +# | ||
5 | +# Version 1.0, latest version, documentation and bugtracker available at: | ||
6 | +# https://gitlab.lindenaar.net/scripts/nagios-plugins | ||
7 | +# | ||
8 | +# Copyright (c) 2016 Frederik Lindenaar | ||
9 | +# | ||
10 | +# This script is free software: you can redistribute and/or modify it under the | ||
11 | +# terms of version 3 of the GNU General Public License as published by the Free | ||
12 | +# Software Foundation, or (at your option) any later version of the license. | ||
13 | +# | ||
14 | +# This script is distributed in the hope that it will be useful but WITHOUT ANY | ||
15 | +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR | ||
16 | +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
17 | +# | ||
18 | +# You should have received a copy of the GNU General Public License along with | ||
19 | +# this program. If not, visit <http://www.gnu.org/licenses/> to download it. | ||
20 | + | ||
21 | +import sys, os, logging, socket, hmac, json | ||
22 | +from time import time | ||
23 | +from struct import pack | ||
24 | +from hashlib import sha1 | ||
25 | +from getpass import getpass | ||
26 | +from urllib import urlencode | ||
27 | +from urllib2 import Request, HTTPError, URLError, urlopen | ||
28 | +from base64 import b16decode, b32decode, standard_b64decode | ||
29 | +from argparse import ArgumentParser as StandardArgumentParser, FileType, \ | ||
30 | + _StoreAction as StoreAction, _StoreConstAction as StoreConstAction | ||
31 | + | ||
32 | +# Constants (no need to change but allows for easy customization) | ||
33 | +VERSION="1.0" | ||
34 | +PROG_NAME=os.path.splitext(os.path.basename(__file__))[0] | ||
35 | +PROG_VERSION=PROG_NAME + ' ' + VERSION | ||
36 | +URL_API_SUFFIX='/validate/check' | ||
37 | +ENV_VAR_USER='USER_NAME' | ||
38 | +ENV_VAR_PWD='USER_PASSWORD' | ||
39 | +ENV_VAR_SERIAL='TOKEN_SERIAL' | ||
40 | +ENV_VAR_KEY='TOKEN_KEY' | ||
41 | +ENV_VAR_NAME='CHECK_NAME' | ||
42 | +OTP_DIGITS=6 | ||
43 | +OTP_TOTP_WINDOW=30 | ||
44 | +OTP_DIGEST_ALG=sha1 | ||
45 | +LOG_FORMAT='%(levelname)s - %(message)s' | ||
46 | +LOG_FORMAT_FILE='%(asctime)s - ' + LOG_FORMAT | ||
47 | +LOGGING_NONE=logging.CRITICAL + 10 | ||
48 | +NAGIOS_OK = ( 'OK', 0) | ||
49 | +NAGIOS_WARN = ( 'WARNING', 1) | ||
50 | +NAGIOS_CRITICAL = ( 'CRITICAL', 2 ) | ||
51 | +NAGIOS_UNKNOWN = ( 'UNKNOWN', 3 ) | ||
52 | + | ||
53 | +# Setup logging | ||
54 | +logging.basicConfig(format=LOG_FORMAT) | ||
55 | +logging.addLevelName(LOGGING_NONE, 'NONE') | ||
56 | +logger = logging.getLogger(PROG_NAME) | ||
57 | +logger.setLevel(logging.CRITICAL) | ||
58 | + | ||
59 | +###################[ minimalistic HOTP/TOTP implementation ]################### | ||
60 | +# based on Yoav Aner's code: http://blog.gingerlime.com/2010/once-upon-a-time # | ||
61 | +# latest version: https://github.com/gingerlime/hotpie/blob/master/hotpie.py # | ||
62 | + | ||
63 | +def HOTP(K, C, digits=OTP_DIGITS, digest=OTP_DIGEST_ALG): | ||
64 | + """Calculate the HOTP value for Key K and count C, returns OTP value""" | ||
65 | + digest = hmac.new(key=K, msg=pack(b"!Q", C), digestmod=digest).hexdigest() | ||
66 | + offset = int(digest[-1], 16) | ||
67 | + return str(int(digest[(offset<<1):((offset<<1)+8)],16)&0x7fffffff)[-digits:] | ||
68 | + | ||
69 | +def TOTP(K, C=None, d=OTP_DIGITS, win=OTP_TOTP_WINDOW, dg=OTP_DIGEST_ALG): | ||
70 | + """Calculate the TOTP value for Key K (using HOTP), returns OTP value""" | ||
71 | + return HOTP(K, int((time() if C is None else C) / win), digits=d, digest=dg) | ||
72 | + | ||
73 | +############################################################################### | ||
74 | + | ||
75 | +def Password(K, C): | ||
76 | + """Dummy routine to represent Password authentication (without OTP)""" | ||
77 | + return None | ||
78 | + | ||
79 | +###################[ patch to force urllib on ipv4 / ipv6 ]################### | ||
80 | +# Support functions to allow forcing urllib2.urlopen() IPv4 or IPv6 connections | ||
81 | +# based on http://stackoverflow.com/questions/2014534/force-python-mechanize-urllib2-to-only-use-a-requests/6319043#6319043 | ||
82 | +# the trick is to wrap the original socket.getaddrinfo and enforce the family | ||
83 | +socket.origGetAddrInfo = socket.getaddrinfo | ||
84 | +socket.getAddrInfoFamily = 0 | ||
85 | +def getAddrInfoWrapper(host, port, family=0, socktype=0, proto=0, flags=0): | ||
86 | + """wrapper for socket.getaddrinfo() to connect only to a specific family""" | ||
87 | + if family == 0: | ||
88 | + family = socket.getAddrInfoFamily | ||
89 | + result=socket.origGetAddrInfo(host, port, family, socktype, proto, flags) | ||
90 | + logger.debug('connecting over IPv4 to %s:%d' if family==socket.AF_INET else | ||
91 | + 'connecting over IPv6 to %s:%d' if family==socket.AF_INET6 else | ||
92 | + 'connecting to %s:%d' ,result[0][4][0],result[0][4][1]) | ||
93 | + return result | ||
94 | +socket.getaddrinfo = getAddrInfoWrapper | ||
95 | +############################################################################### | ||
96 | + | ||
97 | +################[ wrapper to stop ArgumentParser from exiting ]################ | ||
98 | +# based on http://stackoverflow.com/questions/14728376/i-want-python-argparse-to-throw-an-exception-rather-than-usage/14728477#14728477 | ||
99 | +# the only way to do this is overriding the error method and throw and Exception | ||
100 | +class ArgumentParserError(Exception): pass | ||
101 | + | ||
102 | +class ArgumentParser(StandardArgumentParser): | ||
103 | + """ArgumentParser not exiting with non-Nagios format message upon errors""" | ||
104 | + def error(self, message): | ||
105 | + raise ArgumentParserError(message) | ||
106 | + | ||
107 | +##################[ Action to immediately set the log level ]################## | ||
108 | +class SetSocketAddrFamily(StoreConstAction): | ||
109 | + """ArgumentParser action to set socket.getAddrInfo() Addr Family to const""" | ||
110 | + def __call__(self, parser, namespace, values, option_string=None): | ||
111 | + socket.getAddrInfoFamily = self.const | ||
112 | + | ||
113 | +##################[ Action to immediately set the log level ]################## | ||
114 | +class SetLogLevel(StoreConstAction): | ||
115 | + """ArgumentParser action to set log level to provided const value""" | ||
116 | + def __call__(self, parser, namespace, values, option_string=None): | ||
117 | + logging.getLogger(PROG_NAME).setLevel(self.const) | ||
118 | + | ||
119 | +####################[ Action to immediately log to a file ]#################### | ||
120 | +class SetLogFile(StoreAction): | ||
121 | + """ArgumentParser action to log to file (sets up FileHandler accordingly)""" | ||
122 | + def __call__(self, parser, namespace, values, option_string=None): | ||
123 | + super(SetLogFile, self).__call__(parser,namespace,values,option_string) | ||
124 | + formatter = logging.Formatter(LOG_FORMAT_FILE) | ||
125 | + handler = logging.FileHandler(values) | ||
126 | + handler.setFormatter(formatter) | ||
127 | + logger = logging.getLogger(PROG_NAME) | ||
128 | + logger.propagate = False | ||
129 | + logger.addHandler(handler) | ||
130 | + | ||
131 | +####################[ Action to load contents from a file ]#################### | ||
132 | +class LoadFromFile(StoreAction): | ||
133 | + """ArgumentParser action to load file contents into another variable """ | ||
134 | + def __init__(self,option_strings,dest,nargs=None,const=None,default=None, | ||
135 | + type=None,choices=None,required=False,help=None,metavar=None): | ||
136 | + if not isinstance(type, FileLoadType): | ||
137 | + raise ArgumentParserError('LoadFromFile action option %s requires ' | ||
138 | + 'type FileLoadType (got %s)' % (option_strings, type)) | ||
139 | + super(LoadFromFile, self).__init__(option_strings,dest,nargs,const, | ||
140 | + default,type,choices,required,help,metavar) | ||
141 | + | ||
142 | + def __call__(self, parser, namespace, values, option_string=None): | ||
143 | + super(LoadFromFile,self).__call__(parser,namespace,values,option_string) | ||
144 | + logger.info('reading %s from %s', self.type.name, values.name) | ||
145 | + try: | ||
146 | + content = values.readline().strip() | ||
147 | + if self.type.close: | ||
148 | + values.close() | ||
149 | + setattr(namespace, self.type.name, self.type.type(content) \ | ||
150 | + if not isempty(content) else self.type.type()) | ||
151 | + except (IOError, ValueError) as e: | ||
152 | + raise ArgumentParserError('cannot read %s from %s: %s' % | ||
153 | + (self.type.name, values.name, e)) | ||
154 | + | ||
155 | +####################[ Enhanced FileType for LoadFromFile ]#################### | ||
156 | +class FileLoadType(FileType): | ||
157 | + """ArgumentParser FileType extension storing data needed by LoadFromFile""" | ||
158 | + def __init__(self, name, valuetype=str, mode='r', close=None, bufsize=-1): | ||
159 | + self.name = name | ||
160 | + self.type = valuetype | ||
161 | + self.close = mode == 'r' if close is None else close | ||
162 | + super(FileLoadType, self).__init__(mode, bufsize) | ||
163 | + | ||
164 | +##############[ Action to prompt for password if value is empty ]############## | ||
165 | +class PasswordPrompt(StoreAction): | ||
166 | + """ArgumentParser action to prompt for password when empty (and store it)""" | ||
167 | + def __call__(self, parser, namespace, values, option_string=None): | ||
168 | + pwd = getpass('Please enter password: ') if values is None else values | ||
169 | + super(PasswordPrompt, self).__call__(parser,namespace,pwd,option_string) | ||
170 | + | ||
171 | +############################################################################### | ||
172 | + | ||
173 | + | ||
174 | +def isempty(string): | ||
175 | + """Checks whether string 'str' provided is unset or empty""" | ||
176 | + return string is None or len(string) == 0 | ||
177 | + | ||
178 | + | ||
179 | +def envvar(name, default=None): | ||
180 | + """Returns value of environment var 'name', or value of default otherwise""" | ||
181 | + return os.environ.get(name, default) | ||
182 | + | ||
183 | + | ||
184 | +def base16_32_64(string=None): | ||
185 | + """convert encoded string to binary, supports base16, base32 and base64 | ||
186 | + | ||
187 | + Args: | ||
188 | + string (str) : base 16/32/64 encoded string | ||
189 | + | ||
190 | + Returns: | ||
191 | + binary version of string, provided it is base 16/32/64 encoded | ||
192 | + """ | ||
193 | + if not isempty(string): | ||
194 | + try: | ||
195 | + encoding, value = ('base16', b16decode(string, True)) | ||
196 | + except TypeError: | ||
197 | + try: | ||
198 | + encoding, value = ('base32', b32decode(string, False)) | ||
199 | + except TypeError: | ||
200 | + encoding, value = ('base64', standard_b64decode(string)) | ||
201 | + logger.debug("converted %s encoded key '%s' to %d bytes of binary data", | ||
202 | + encoding, string, len(value)) | ||
203 | + return value | ||
204 | + | ||
205 | + | ||
206 | +def parse_args(): | ||
207 | + """Parse command line and get parameters from environment, if present""" | ||
208 | + | ||
209 | + # Setup argument parser, the workhorse gluing it all together | ||
210 | + parser = ArgumentParser( | ||
211 | + description='Nagios check for OTP validation against LinOTP/PrivacyIDEA' | ||
212 | + ) | ||
213 | + parser.add_argument('-V', '--version',action="version",version=PROG_VERSION) | ||
214 | + | ||
215 | + pgroup = parser.add_mutually_exclusive_group(required=True) | ||
216 | + pgroup.add_argument('-u', '--url', | ||
217 | + help='URL to check OTP authentication against') | ||
218 | + pgroup.add_argument('-H', '--host', | ||
219 | + help='hostname to test against (to construct URL)') | ||
220 | + | ||
221 | + parser.add_argument('-p', '--port', type=int, | ||
222 | + help='port number to connect to (only used with -H)') | ||
223 | + parser.add_argument('-P', '--path', | ||
224 | + help='URL path to be used (only used with -H)') | ||
225 | + parser.add_argument('-S', '--no-ssl', action='store_true', | ||
226 | + help='connect WITHOUT SSL (only used with -H)') | ||
227 | + | ||
228 | + parser.add_argument('-n', '--name', default=envvar(ENV_VAR_NAME, PROG_NAME), | ||
229 | + help="name in authentication request for logging " | ||
230 | + "(defaults to '%s')" % PROG_NAME) | ||
231 | + | ||
232 | + parser.add_argument('-w', '--warn', type=float, | ||
233 | + help='Response time for warning status (seconds)') | ||
234 | + parser.add_argument('-c','--critical', type=float, | ||
235 | + help='Response time for critical status (seconds)') | ||
236 | + | ||
237 | + pgroup = parser.add_mutually_exclusive_group(required=False) | ||
238 | + pgroup.add_argument('-4', '--ipv4', action=SetSocketAddrFamily, | ||
239 | + const=socket.AF_INET, help='connect using IPv4') | ||
240 | + pgroup.add_argument('-6', '--ipv6', action=SetSocketAddrFamily, | ||
241 | + const=socket.AF_INET6,help='connect using IPv6') | ||
242 | + | ||
243 | + pgroup = parser.add_mutually_exclusive_group(required=False) | ||
244 | + pgroup.add_argument('-q', '--quiet', default=logging.CRITICAL, | ||
245 | + action=SetLogLevel, const=LOGGING_NONE, | ||
246 | + help='quiet (no output, only exit with exit code)') | ||
247 | + pgroup.add_argument('-v', '--verbose', help='more verbose output', | ||
248 | + action=SetLogLevel, const=logging.INFO) | ||
249 | + pgroup.add_argument('-d', '--debug', help='debug output (more verbose)', | ||
250 | + action=SetLogLevel, const=logging.DEBUG) | ||
251 | + | ||
252 | + parser.add_argument('-l', '--logfile', action=SetLogFile, | ||
253 | + help='send logging output to logfile') | ||
254 | + | ||
255 | + commonparser = ArgumentParser(add_help=False) | ||
256 | + pgroup = commonparser.add_mutually_exclusive_group(required=False) | ||
257 | + pgroup.add_argument('-l', '--login', default=envvar(ENV_VAR_USER), | ||
258 | + help='username to login with, can only be omitted when ' | ||
259 | + '%s is set or -s/--serial is used' % ENV_VAR_USER) | ||
260 | + pgroup.add_argument('-s', '--serial', default=envvar(ENV_VAR_SERIAL), | ||
261 | + help='token serial to use, can only be omitted when %s ' | ||
262 | + 'is set or -l/--login is used' % ENV_VAR_SERIAL) | ||
263 | + | ||
264 | + pgroup = commonparser.add_mutually_exclusive_group(required=False) | ||
265 | + pgroup.add_argument('-p', '--password', nargs='?', action=PasswordPrompt, | ||
266 | + default=envvar(ENV_VAR_PWD), | ||
267 | + help='password or OTP+PIN to authenticate, uses env. ' | ||
268 | + 'var %s if not present and prompts when PASSWORD ' | ||
269 | + 'omitted (use "" for empty password)'%ENV_VAR_PWD) | ||
270 | + pgroup.add_argument('-P', '--passwordfile', | ||
271 | + action=LoadFromFile,type=FileLoadType('password'), | ||
272 | + help='read password/authentication secret from file'), | ||
273 | + | ||
274 | + subparser = parser.add_subparsers(title='Implemented test modes / checks') | ||
275 | + | ||
276 | + cmdparser = subparser.add_parser('password', parents=[commonparser], | ||
277 | + help='perform test with provided secret') | ||
278 | + cmdparser.set_defaults(func=Password, cmdparser=cmdparser) | ||
279 | + | ||
280 | + otpparser = ArgumentParser(add_help=False) | ||
281 | + pgroup = otpparser.add_mutually_exclusive_group(required=False) | ||
282 | + pgroup.add_argument('-k', '--key', default=envvar(ENV_VAR_KEY), | ||
283 | + type=base16_32_64, help='HOTP/TOTP key, can only be' | ||
284 | + 'omitted if %s is set or -K is used' % ENV_VAR_KEY) | ||
285 | + pgroup.add_argument('-K', '--keyfile', action=LoadFromFile, | ||
286 | + type=FileLoadType('key', base16_32_64), | ||
287 | + help='read HOTP key from the file specified'), | ||
288 | + | ||
289 | + otpparser.add_argument('-m','--merge', choices=['pwdOTP', 'OTPpwd' ], | ||
290 | + default='pwdOTP', help='how to merge password and OTP') | ||
291 | + | ||
292 | + | ||
293 | + cmdparser = subparser.add_parser('hotp', parents=[commonparser, otpparser], | ||
294 | + help='perform HOTP check using provided key and count') | ||
295 | + | ||
296 | + pgroup = cmdparser.add_mutually_exclusive_group(required=True) | ||
297 | + pgroup.add_argument('-c', '--count', type=int, | ||
298 | + help='count to be used to calculate the HTOP value') | ||
299 | + pgroup.add_argument('-C', '--countfile',type=FileLoadType('count',int,'r+'), | ||
300 | + action=LoadFromFile, | ||
301 | + help='read HOTP count from file and update it') | ||
302 | + cmdparser.add_argument('-i', '--increment', type=int, default=2, | ||
303 | + help='increment value for count (default=1)') | ||
304 | + cmdparser.set_defaults(func=HOTP, cmdparser=cmdparser) | ||
305 | + | ||
306 | + cmdparser = subparser.add_parser('totp',parents=[commonparser, otpparser], | ||
307 | + help='perform TOTP test using provided key') | ||
308 | + cmdparser.set_defaults(func=TOTP, cmdparser=cmdparser) | ||
309 | + | ||
310 | + # parse arguments and post-process command line options | ||
311 | + args = parser.parse_args() | ||
312 | + | ||
313 | + # Generate the URL if not provided on the command line | ||
314 | + if isempty(args.url): | ||
315 | + args.url = 'http://' if args.no_ssl else 'https://' | ||
316 | + args.url+= args.host | ||
317 | + if not isempty(args.port): | ||
318 | + args.url+= ':' + args.port | ||
319 | + if not isempty(args.path): | ||
320 | + args.url+= args.path if args.path[0]=='/' else '/' + args.path | ||
321 | + | ||
322 | + # We should now be ready to authenticate, fail if that's not the case | ||
323 | + if args.func == Password: | ||
324 | + if isempty(args.login) and isempty(args.serial) \ | ||
325 | + or isempty(args.password): | ||
326 | + args.cmdparser.error('user/serial and a secret are required!') | ||
327 | + | ||
328 | + elif args.func == HOTP or args.func == TOTP: | ||
329 | + if isempty(args.login) and isempty(args.serial) or isempty(args.key): | ||
330 | + args.cmdparser.error('user/serial and a key are required!') | ||
331 | + | ||
332 | + else: | ||
333 | + args.cmdparser.error("BUG: mode %s is not supported"%args.func.__name__) | ||
334 | + | ||
335 | + # if we got here all seems OK | ||
336 | + return args | ||
337 | + | ||
338 | + | ||
339 | +def checkotp(url, subject, secret, isserial=False, nas=None): | ||
340 | + """Check a subject (user or token) with secret against PrivacyIDEA / LinOTP. | ||
341 | + | ||
342 | + Args: | ||
343 | + url (str) : URL to connect to, URL_API_SUFFIX is added if missing | ||
344 | + subject (str) : subject to authenticate (user or a token serial) | ||
345 | + secret (str) : secret (password+OTP) to authenticate with | ||
346 | + isserial (bool): True if subject is a token serial (optional) | ||
347 | + nas (str) : string to pass-on as the nas string (optional) | ||
348 | + | ||
349 | + Returns: | ||
350 | + The result response from the PrivacyIDEA server (mapping object) | ||
351 | + """ | ||
352 | + # Complete (fix) URL | ||
353 | + if not url.endswith(URL_API_SUFFIX): | ||
354 | + url += URL_API_SUFFIX[1:] if url[-1] == '/' else URL_API_SUFFIX | ||
355 | + logger.info('connecting to %s', url) | ||
356 | + | ||
357 | + # Prepare the parameters | ||
358 | + params = { 'pass': secret, 'serial' if isserial else 'user': subject } | ||
359 | + if not isempty(nas): | ||
360 | + params['nas'] = nas | ||
361 | + if logger.isEnabledFor(logging.DEBUG): | ||
362 | + logger.debug('HTTP request parameters: %s', | ||
363 | + ', '.join(map(lambda (k,v): '%s="%s"' % (k, v if k!='pass' | ||
364 | + else '***MASKED***'), params.iteritems()))) | ||
365 | + | ||
366 | + # Perform the API authentication request | ||
367 | + response = json.load(urlopen(Request(url, data=urlencode(params)))) | ||
368 | + if logger.isEnabledFor(logging.DEBUG): | ||
369 | + logger.debug('result: %s', json.dumps(response, indent=4)) | ||
370 | + | ||
371 | + return response | ||
372 | + | ||
373 | + | ||
374 | +def nagios_exit(status, message, data=None): | ||
375 | + """exit 'nagios-style', print status and message followed by the data""" | ||
376 | + if logger.isEnabledFor(logging.CRITICAL): | ||
377 | + if data is not None and len(data) > 0: | ||
378 | + perfdata=map(lambda (k,v): "'%s'=%s" %(k,v if not isinstance(v,list) | ||
379 | + else ';'.join(map(lambda x:'' if x is None else str(x),v))) | ||
380 | + ,data.iteritems()) | ||
381 | + perfstr = ' | ' + ' '.join(perfdata) | ||
382 | + else: | ||
383 | + perfstr = '' | ||
384 | + print 'OTP %s: %s%s' % (status[0], message, perfstr) | ||
385 | + sys.exit(status[1]) | ||
386 | + | ||
387 | + | ||
388 | +if __name__ == '__main__': | ||
389 | + try: | ||
390 | + args = parse_args() | ||
391 | + except ArgumentParserError as e: | ||
392 | + nagios_exit(NAGIOS_UNKNOWN,'error with setup: ' + e.message) | ||
393 | + except (KeyboardInterrupt, EOFError) as e: | ||
394 | |||
395 | + nagios_exit(NAGIOS_UNKNOWN,'initialization aborted') | ||
396 | + | ||
397 | + message = args.func.__name__ + ' authentication' | ||
398 | + | ||
399 | + if 'key' in args: | ||
400 | + secret = args.func(args.key, args.count if 'count' in args else None) | ||
401 | + if not isempty(args.password): | ||
402 | + secret = (secret + args.password) if args.merge=='OTPpwd' \ | ||
403 | + else (args.password + secret) | ||
404 | + else: | ||
405 | + secret = args.password | ||
406 | + | ||
407 | + try: | ||
408 | + starttime = time() | ||
409 | + response=checkotp(args.url, | ||
410 | + args.login if isempty(args.serial) else args.serial, | ||
411 | + secret, isserial=not isempty(args.serial), | ||
412 | + nas=args.name) | ||
413 | + endtime = time() | ||
414 | + | ||
415 | + except (HTTPError, URLError) as e: | ||
416 | + nagios_exit(NAGIOS_CRITICAL,'%s request failed: %s' % (message, e)) | ||
417 | + | ||
418 | + except (KeyboardInterrupt, EOFError) as e: | ||
419 | + nagios_exit(NAGIOS_UNKNOWN,'%s request aborted' % message) | ||
420 | + | ||
421 | + resultdata = response.get('result') | ||
422 | + if resultdata is None or not resultdata['status']: | ||
423 | + nagios_exit(NAGIOS_CRITICAL,'%s request processing failed' % message) | ||
424 | + | ||
425 | + authenticated = resultdata['status'] and resultdata['value'] | ||
426 | + detaildata = response.get('detail') | ||
427 | + elapse = endtime-starttime | ||
428 | + if logger.isEnabledFor(logging.INFO): | ||
429 | + logger.info('Got response from : %s', response.get('version')) | ||
430 | + logger.info('Processing time : %.2fs', elapse) | ||
431 | + logger.info('Got valid result : %s', resultdata.get('status')) | ||
432 | + logger.info('Authenticated : %s', authenticated) | ||
433 | + for field in 'message', 'type', 'serial': | ||
434 | + if field in detaildata: | ||
435 | + logger.info('Token %-12s: %s', field, detaildata.get(field)) | ||
436 | + | ||
437 | + errmsgs = [] | ||
438 | + if authenticated and 'countfile' in args and args.increment > 0: | ||
439 | + fname = args.countfile.name | ||
440 | + nwcnt = args.count + args.increment | ||
441 | + logger.info('updating count in %s from %d to %d',fname,args.count,nwcnt) | ||
442 | + try: | ||
443 | + with args.countfile as countfile: | ||
444 | + countfile.seek(0) | ||
445 | + countfile.write(str(nwcnt)) | ||
446 | + countfile.truncate() | ||
447 | + except IOError as e: | ||
448 | + errmsgs.append('unable to update countfile %s: %s' % (fname, e)) | ||
449 | + logger.critical(errmsgs[-1]) | ||
450 | + | ||
451 | + for k, src, g in ('serial',detaildata,' of '),('version',response,' with '): | ||
452 | + if k in src: | ||
453 | + message += g + src.get(k) | ||
454 | + if authenticated: | ||
455 | + nagiosresult = NAGIOS_OK | ||
456 | + message += ' succeeded' | ||
457 | + else: | ||
458 | + nagiosresult = NAGIOS_CRITICAL | ||
459 | + message += ' failed' | ||
460 | + | ||
461 | + if args.critical is not None and elapse > args.critical: | ||
462 | + errmsgs.append('took too long (%.2fs > %.2fs)' % (elapse,args.critical)) | ||
463 | + logger.critical('response %s', errmsgs[-1]) | ||
464 | + elif args.warn is not None and elapse > args.warn: | ||
465 | + if nagiosresult != NAGIOS_CRITICAL: | ||
466 | + nagiosresult = NAGIOS_WARN | ||
467 | + errmsgs.append('is slow (%.2fs > %.2fs)' % (elapse, args.warn)) | ||
468 | + logger.warn('response time %s', errmsgs[-1]) | ||
469 | + else: | ||
470 | + message+= ' in %.1fs' % elapse | ||
471 | + logger.info('response completed in %.1fs', elapse) | ||
472 | + | ||
473 | + if len(errmsgs) > 0: | ||
474 | + message+= ' and ' if nagiosresult == NAGIOS_CRITICAL else ' but ' | ||
475 | + message+= ' and '.join(errmsgs) | ||
476 | + if nagiosresult == NAGIOS_OK: | ||
477 | + nagiosresult = NAGIOS_CRITICAL | ||
478 | + if 'message' in detaildata: | ||
479 | + message += ': ' + detaildata.get('message') | ||
480 | + | ||
481 | + nagios_exit(nagiosresult, message, { | ||
482 | + 'time': [ elapse, args.warn, args.critical, 0, None]}) |