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 | 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 | 6 | Most of these are very custom solutions or modified versions of standard plugins |
7 | 7 | so distributing them through [NagiosExchange](https://exchange.nagios.org/) is |
... | ... | @@ -12,12 +12,23 @@ encounter any issues or require changes. |
12 | 12 | The latest versions, documentation and bugtracker available on my |
13 | 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 | 32 | Nagios check script to monitor the memory on Linux systems. Due to changes in |
22 | 33 | the output of procps v3.3 (the changelog refers to it as modernizing it), it's |
23 | 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 | 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 | 42 | This script is a first attempt to monitor multi-home and dual-stack (i.e. ipv4 |
32 | 43 | and ipv6) servers. In my setup a server should only considered availble if it is |
33 | 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 | 49 | |
39 | 50 | Installation is straightforward, after installing the script on your server, add |
40 | 51 | the following to your `commands.cmd` configuration file to make it available: |
52 | + | |
41 | 53 | ~~~ |
42 | 54 | # 'check-host-alive' command definition for multi-homed/dual-stack servers |
43 | 55 | define command{ |
... | ... | @@ -45,8 +57,10 @@ the following to your `commands.cmd` configuration file to make it available: |
45 | 57 | command_line [install_path]/plugins/check_multiplehost_addresses '$HOSTADDRESS$' '$_HOSTADDRESS6$' |
46 | 58 | } |
47 | 59 | ~~~ |
60 | + | |
48 | 61 | The example above assumes that the IPv6 address of the host is provided as part |
49 | 62 | of the host configuration, i.e.: |
63 | + | |
50 | 64 | ~~~ |
51 | 65 | define host { |
52 | 66 | ... |
... | ... | @@ -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 | 74 | to the specific hosts that should use the check or to the generic host used as |
60 | 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 | 238 | Very simplistic CGI-BIN script that checkes whether nagios is still running and |
66 | 239 | still updating its status. It wil always return an HTTP Status 200 (OK) and a |
67 | 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]}) | |
... | ... |