root/src/modules-lua/noit/module/http.lua

Revision f800a3f5ed787dc4da4fcaffeba2071690a0a91b, 24.5 kB (checked in by Philip Maddox <pmaddox@circonus.com>, 2 years ago)

Updated SSL connection to correct use the header_Host field to compare to the CN and SANs in the SSL certificates to verify that they match

  • Property mode set to 100644
Line 
1 -- Copyright (c) 2008, OmniTI Computer Consulting, Inc.
2 -- All rights reserved.
3 --
4 -- Redistribution and use in source and binary forms, with or without
5 -- modification, are permitted provided that the following conditions are
6 -- met:
7 --
8 --     * Redistributions of source code must retain the above copyright
9 --       notice, this list of conditions and the following disclaimer.
10 --     * Redistributions in binary form must reproduce the above
11 --       copyright notice, this list of conditions and the following
12 --       disclaimer in the documentation and/or other materials provided
13 --       with the distribution.
14 --     * Neither the name OmniTI Computer Consulting, Inc. nor the names
15 --       of its contributors may be used to endorse or promote products
16 --       derived from this software without specific prior written
17 --       permission.
18 --
19 -- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20 -- "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21 -- LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22 -- A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23 -- OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24 -- SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25 -- LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26 -- DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27 -- THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 -- (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 -- OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30
31 module(..., package.seeall)
32
33 function onload(image)
34   image.xml_description([=[
35 <module>
36   <name>http</name>
37   <description><para>The http module performs GET requests over either HTTP or HTTPS and checks the return code and optionally the body.</para>
38   </description>
39   <loader>lua</loader>
40   <object>noit.module.http</object>
41   <checkconfig>
42     <parameter name="url"
43                required="required"
44                allowed=".+">The URL including schema and hostname (as you would type into a browser's location bar).</parameter>
45     <parameter name="header_(\S+)"
46                required="optional"
47                allowed=".+">Allows the setting of arbitrary HTTP headers in the request.</parameter>
48     <parameter name="method"
49                required="optional"
50                allowed="\S+"
51                default="GET">The HTTP method to use.</parameter>
52     <parameter name="payload"
53                required="optional"
54                allowed=".*">The information transferred as the payload of an HTTP request.</parameter>
55     <parameter name="auth_method"
56                required="optional"
57                allowed="^(?:Basic|Digest|Auto)$">HTTP Authentication method to use.</parameter>
58     <parameter name="auth_user"
59                required="optional"
60                allowed="[^:]*">The user to authenticate as.</parameter>
61     <parameter name="auth_password"
62                required="optional"
63                allowed=".*">The password to use during authentication.</parameter>
64     <parameter name="ca_chain"
65                required="optional"
66                allowed=".+">A path to a file containing all the certificate authorities that should be loaded to validate the remote certificate (for SSL checks).</parameter>
67     <parameter name="certificate_file"
68                required="optional"
69                allowed=".+">A path to a file containing the client certificate that will be presented to the remote server (for SSL checks).</parameter>
70     <parameter name="key_file"
71                required="optional"
72                allowed=".+">A path to a file containing key to be used in conjunction with the cilent certificate (for SSL checks).</parameter>
73     <parameter name="ciphers"
74                required="optional"
75                allowed=".+">A list of ciphers to be used in the SSL protocol (for SSL checks).</parameter>
76     <parameter name="code"
77                required="optional"
78                default="^200$"
79                allowed=".+">The HTTP code that is expected.  If the code received does not match this regular expression, the check is marked as "bad."</parameter>
80     <parameter name="redirects"
81                required="optional"
82                default="0"
83                allowed="\d+">The maximum number of Location header redirects to follow.</parameter>
84     <parameter name="body"
85                required="optional"
86                allowed=".+">This regular expression is matched against the body of the response. If a match is not found, the check will be marked as "bad."</parameter>
87     <parameter name="body_match_*"
88                required="optional"
89                allowed=".+">This regular expression is matched against the body of the response. If a match is found it is captured and added as a metric. For example, if setting is named 'body_match_foo_bar' and a match is found new metric called 'foo_bar' will be added.</parameter>
90     <parameter name="extract"
91                required="optional"
92                allowed=".+">This regular expression is matched against the body of the response globally.  The first capturing match is the key and the second capturing match is the value.  Each key/value extracted is registered as a metric for the check.</parameter>
93     <parameter name="pcre_match_limit"
94                required="optional"
95                default="10000"
96                allowed="\d+">This sets the PCRE internal match limit (see pcreapi documentation).</parameter>
97     <parameter name="include_body"
98                required="optional"
99                allowed="^(?:true|false|on|off)$"
100                default="false">Include whole response body as a metric with the key 'body'.</parameter>
101     <parameter name="read_limit"
102                required="optional"
103                default="0"
104                allowed="\d+">Sets an approximate limit on the data read (0 means no limit).</parameter>
105   </checkconfig>
106   <examples>
107     <example>
108       <title>Checking an HTTP and HTTPS URL.</title>
109       <para>This example checks the OmniTI Labs website over both HTTP and HTTPS.</para>
110       <programlisting><![CDATA[
111       <noit>
112         <modules>
113           <loader image="lua" name="lua">
114             <config><directory>/opt/reconnoiter/libexec/modules-lua/?.lua</directory></config>
115           </loader>
116           <module loader="lua" name="http" object="noit.module.http" />
117         </modules>
118         <checks>
119           <labs target="8.8.38.5" module="http">
120             <check uuid="fe3e984c-7895-11dd-90c1-c74c31b431f0" name="http">
121               <config><url>http://labs.omniti.com/</url></config>
122             </check>
123             <check uuid="1ecd887a-7896-11dd-b28d-0b4216877f83" name="https">
124               <config><url>https://labs.omniti.com/</url></config>
125             </check>
126           </labs>
127         </checks>
128       </noit>
129     ]]></programlisting>
130     </example>
131   </examples>
132 </module>
133 ]=]);
134   return 0
135 end
136
137 function init(module)
138   return 0
139 end
140
141 function config(module, options)
142   return 0
143 end
144
145 local HttpClient = require 'noit.HttpClient'
146
147 local BODY_MATCHES_PREFIX = 'body_match_'
148
149 function elapsed(check, name, starttime, endtime)
150     local elapsedtime = endtime - starttime
151     local seconds = string.format('%.3f', noit.timeval.seconds(elapsedtime))
152     check.metric_uint32(name, math.floor(seconds * 1000 + 0.5))
153     return seconds
154 end
155
156 function populate_cookie_jar(cookies, host, hdr)
157     local path = nil
158     if hdr ~= nil then
159         local name, value, trailer =
160             string.match(hdr, "([^=]+)=([^;]+);?%s*(.*)")
161         if name ~= nil then
162             local jar = { }
163             local fields = noit.extras.split(trailer, ";")
164             if fields ~= nil then
165                 for k, v in pairs(fields) do
166                     local pair = noit.extras.split(v, "=", 1);
167                     if pair ~= nil and pair[1] ~= nil and pair[2] ~= nil then
168                         local name = (string.gsub(pair[1], "^%s*(.-)%s*$", "%1"));
169                         local setting = (string.gsub(pair[2], "^%s*(.-)%s*$", "%1"));
170                         if name == "path" then
171                             path = setting
172                         end
173                     end
174                 end
175             end
176             if string.sub(name, 1, 1) ~= ";" and string.sub(value, 1, 1) ~= ";" then
177                 if path == nil then path = "/" end
178                 if cookies[host] == nil then cookies[host] = { } end
179                 if cookies[host][path] == nil then cookies[host][path] = { } end
180                 jar.name = name
181                 jar.value = value
182                 table.insert(cookies[host][path], jar)
183             end
184         end
185     end
186 end
187
188 function has_host(pat, host)
189     if pat == host then return true end
190     if pat:sub(1,1) ~= "." then return false end
191     local revpat = pat:sub(2):reverse()
192     local revhost = host:reverse()
193     if revpat == revhost then return true end
194     if revpat == revhost:sub(1, revpat:len()) then
195         if revhost:sub(pat:len(), pat:len()) == "." then return true end
196     end
197     return false
198 end
199
200 function apply_cookies(headers, cookies, host, uri)
201     local use_cookies = { }
202     for h, paths in pairs(cookies) do
203         if has_host(h, host) then
204             local split_uri = noit.extras.split(uri, "/")
205             if split_uri ~= nil then
206                 local path = ""
207                 for i, val in pairs(split_uri) do
208                     local append = true
209                     if val == nil then val = "" end
210                     if #split_uri == i and string.find(val, "%.") ~= nil then append = false end
211                     if append == true then
212                         path = path .. "/" .. val
213                         if string.len(path) >= 2 and string.sub(path, 1, 2) == "//" then
214                             path = string.sub(path, 2)
215                         end
216                     end
217                     if path == "" then path = "/" end
218                     local rindex = string.match(path, '.*()'..'%?')
219                     if rindex ~= nil then
220                         path = string.sub(path, 1, rindex-1)
221                     end
222                     if path ~= "/" then
223                         while string.find(path, "/", -1) ~= nil do
224                             path = string.sub(path, 1, -2)
225                         end
226                     end
227                     if paths[path] ~= nil then
228                         local jars = paths[path]
229                         for index, jar in ipairs(jars) do
230                             use_cookies[jar.name] = jar.value
231                         end
232                     end
233                 end
234             end
235         end
236     end
237     for name, value in pairs(use_cookies) do
238         if headers["Cookie"] == nil then
239             headers["Cookie"] = name .. "=" .. value
240         else
241             headers["Cookie"] = headers["Cookie"] .. "; " .. name .. "=" .. value
242         end
243     end
244 end
245
246 function get_new_uri(old_uri, new_uri)
247     if new_uri == nil then return "/" end
248     if new_uri == "/" then return new_uri end
249     local toReturn = old_uri
250     while string.find(toReturn, "/", -1) ~= nil do
251         toReturn = string.sub(toReturn, 1, -2)
252     end
253     if string.sub(new_uri, 1, 1) == '?' then
254         local rindex = string.match(toReturn, '.*()'.."/")
255         toReturn = string.sub(toReturn, 1, rindex-1)
256         toReturn = toReturn .. new_uri
257     elseif string.sub(new_uri, 1, 1) ~= "." then
258         toReturn = new_uri
259     else
260         toReturn = string.gsub(toReturn, "%/%?", "?")
261         while string.sub(new_uri, 1, 1) == "." do
262             if string.find(new_uri, "%./") == 1 then
263                 new_uri = string.gsub("%./", "", 1)
264             elseif string.find(new_uri, "%.%./") == 1 then
265                 --strip out last bit from toReturn
266                 local rindex = string.match(toReturn, '.*()'.."/")
267                 toReturn = string.sub(toReturn, 1, rindex-1)
268                 new_uri = string.gsub(new_uri, "../", "", 1)
269             else
270                 -- bad URI... just return /
271                 return "/"
272             end
273         end
274         toReturn = toReturn .. "/" .. new_uri
275     end
276     return toReturn
277 end
278
279 function get_absolute_path(uri)
280     if uri == nil then return "/" end
281     local toReturn = uri
282     local go_back = string.find(toReturn, "%.%./")
283     while go_back ~= nil do
284         local tojoin = go_back + 3
285         go_back = go_back - 2
286         local back_substring = string.sub(toReturn, 1, go_back)
287         local forward_substring = string.sub(toReturn, tojoin)
288         local rindex = string.match(back_substring, '.*()' .. "/")
289         if rindex ~= nil then
290             toReturn = string.sub(toReturn, 1, rindex) .. forward_substring
291         end
292         go_back = string.find(toReturn, "%.%./")
293     end
294     toReturn = string.gsub(toReturn, "%./", "")
295     return toReturn
296 end
297
298 function san_list_check(array, value)
299   for i, line in ipairs(array) do
300     if line == value then
301       return true
302     else
303       line = string.gsub(line, '%.', "%%%.")
304       line = string.gsub(line, "%*", "[^\.]*")
305       local match = string.match(value, line)
306       if match == value then
307         return true
308       end
309     end
310   end
311   return false
312 end
313
314 function check_host_header_against_certificate(host_header, cert_subject, san_list)
315   -- First, check for SAN values if they exist - if they do, check for a match
316   local san_array = { }
317   if san_list ~= nil then
318     san_array = noit.extras.split(san_list, ", ")
319   end
320   if san_list_check(san_array, host_header) then
321     -- The host header was in the SAN list, so we're done
322     return nil
323   end
324   -- Next, pull out the CN value
325   local cn = string.sub(cert_subject, string.find(cert_subject, 'CN=[^/\n]*'))
326   if cn == nil or cn == '' then
327     -- no common name given, give an error
328     return 'CN not found in certificate'
329   end
330   cn = string.sub(cn, 4)
331   if cn == host_header then
332     -- CN and host_header match exactly, so no error
333     return nil
334   end
335   cn = string.gsub(cn, '%.', "%%%.")
336   cn = string.gsub(cn, "%*", "[^\.]*")
337   local match = string.match(host_header, cn)
338   if match == host_header then
339     return nil
340   end
341   return 'host header does not match CN or SANs in certificate'
342 end
343
344 function initiate(module, check)
345     local url = check.config.url or 'http:///'
346     local schema, host, port, uri = string.match(url, "^(https?)://([^:/]*):?([0-9]*)(/?.*)$");
347     local use_ssl = false
348     local codere = noit.pcre(check.config.code or '^200$')
349     local good = false
350     local starttime = noit.timeval.now()
351     local method = check.config.method or "GET"
352     local max_len = 80
353     local pcre_match_limit = check.config.pcre_match_limit or 10000
354     local redirects = check.config.redirects or 0
355     local include_body = false
356     local read_limit = tonumber(check.config.read_limit) or nil
357     local host_header = check.config.header_Host or ''
358
359     -- expect the worst
360     check.bad()
361     check.unavailable()
362
363     if host == nil then host = check.target end
364     if schema == nil then
365         schema = 'http'
366         uri = '/'
367     end
368     if uri == '' then
369         uri = '/'
370     end
371     if port == '' or port == nil then
372         if schema == 'http' then
373             port = check.config.port or 80
374         elseif schema == 'https' then
375             port = check.config.port or 443
376         else
377             error(schema .. " not supported")
378         end
379     end
380     if schema == 'https' then
381         use_ssl = true
382     end
383
384     -- Include body as a metric
385     if check.config.include_body == "true" or check.config.include_body == "on" then
386         include_body = true
387     end
388
389     local output = ''
390     local connecttime, firstbytetime
391     local next_location
392     local cookies = { }
393     local setfirstbyte = 1
394
395     -- callbacks from the HttpClient
396     local callbacks = { }
397     callbacks.consume = function (str)
398         if setfirstbyte == 1 then
399             firstbytetime = noit.timeval.now()
400             setfirstbyte = 0
401         end
402         output = output .. (str or '')
403     end
404     callbacks.headers = function (hdrs, setcookies)
405         next_location = hdrs.location
406         for key, value in pairs(setcookies) do
407             populate_cookie_jar(cookies, host, value)
408         end
409     end
410
411     callbacks.connected = function () connecttime = noit.timeval.now() end
412
413     -- setup SSL info
414     local default_ca_chain =
415         noit.conf_get_string("/noit/eventer/config/default_ca_chain")
416     callbacks.certfile = function () return check.config.certificate_file end
417     callbacks.keyfile = function () return check.config.key_file end
418     callbacks.cachain = function ()
419         return check.config.ca_chain and check.config.ca_chain
420                                       or default_ca_chain
421     end
422     callbacks.ciphers = function () return check.config.ciphers end
423
424     -- set the stage
425     local headers = {}
426     headers.Host = host
427     for header, value in pairs(check.config) do
428         hdr = string.match(header, '^header_(.+)$')
429         if hdr ~= nil then
430           headers[hdr] = value
431         end
432     end
433     if check.config.auth_method == "Basic" then
434         local user = check.config.auth_user or ''
435         local password = check.config.auth_password or ''
436         local encoded = noit.base64_encode(user .. ':' .. password)
437         headers["Authorization"] = "Basic " .. encoded
438     elseif check.config.auth_method == "Digest" or
439            check.config.auth_method == "Auto" then
440         -- this is handled later as we need our challenge.
441         local client = HttpClient:new()
442         local rv, err = client:connect(check.target_ip, port, use_ssl)
443         if rv ~= 0 then
444             check.status(err or "unknown error")
445             return
446         end
447         local headers_firstpass = {}
448         for k,v in pairs(headers) do
449             headers_firstpass[k] = v
450         end
451         client:do_request(method, uri, headers_firstpass)
452         client:get_response(read_limit)
453         if client.code ~= 401 or
454            client.headers["www-authenticate"] == nil then
455             check.status("expected digest challenge, got " .. client.code)
456             return
457         end
458         local user = check.config.auth_user or ''
459         local password = check.config.auth_password or ''
460         local ameth, challenge =
461             string.match(client.headers["www-authenticate"], '^(%S+)%s+(.+)$')
462         if check.config.auth_method == "Auto" and ameth == "Basic" then
463             local encoded = noit.base64_encode(user .. ':' .. password)
464             headers["Authorization"] = "Basic " .. encoded
465         elseif ameth == "Digest" then
466             headers["Authorization"] =
467                 "Digest " .. client:auth_digest(method, uri,
468                                          user, password, challenge)
469         else
470             check.status("Unexpected auth '" .. ameth .. "' in challenge")
471             return
472         end
473     elseif check.config.auth_method ~= nil then
474       check.status("Unknown auth method: " .. check.config.auth_method)
475       return
476     end
477
478     -- perform the request
479     local client
480     local dns = noit.dns()
481     local target = check.target_ip
482     local payload = check.config.payload
483     -- artificially increase redirects as the initial request counts
484     redirects = redirects + 1
485     starttime = noit.timeval.now()
486     repeat
487         local optclient = HttpClient:new(callbacks)
488         local rv, err = optclient:connect(target, port, use_ssl)
489
490         if rv ~= 0 then
491             check.status(err or "unknown error")
492             return
493         end
494         optclient:do_request(method, uri, headers, payload)
495         optclient:get_response(read_limit)
496         setfirstbyte = 1
497
498         redirects = redirects - 1
499         client = optclient
500
501         if next_location ~= nil then
502             -- reset some stuff for the redirect
503             local prev_port = port
504             local prev_host = host
505             local prev_uri = uri
506             method = 'GET'
507             payload = nil
508             schema, host, port, uri =
509                 string.match(next_location,
510                              "^(https?)://([^:/]*):?([0-9]*)(/?.*)$")
511             if schema == nil then
512                 port = prev_port
513                 host = prev_host
514                 uri = get_new_uri(prev_uri, next_location)
515             elseif schema == 'http' then
516                 use_ssl = false
517                 if port == "" then port = 80 end
518             elseif schema == 'https' then
519                 use_ssl = true
520                 if port == "" then port = 443 end
521             end
522             uri = get_absolute_path(uri)
523             if host ~= nil then
524                 headers.Host = host
525                 local r = dns:lookup(host)
526                 if not r or r.a == nil then
527                     check.status("failed to resolve " .. host)
528                     return
529                 end
530                 target = r.a
531             end
532             while string.find(host, "/", -1) ~= nil do
533                 host = string.sub(host, 1, -2)
534             end
535             headers["Cookie"] = check.config["header_Cookie"]
536             apply_cookies(headers, cookies, host, uri)
537         end
538     until redirects <= 0 or next_location == nil
539
540     local endtime = noit.timeval.now()
541     check.available()
542
543     local status = ''
544     -- setup the code
545     check.metric_string("code", client.code)
546     status = status .. 'code=' .. client.code
547     if codere ~= nil and codere(client.code) then
548       good = true
549     end
550
551     -- truncated response
552     check.metric_uint32("truncated", client.truncated and 1 or 0)
553
554     -- turnaround time
555     local seconds = elapsed(check, "duration", starttime, endtime)
556     status = status .. ',rt=' .. seconds .. 's'
557     elapsed(check, "tt_connect", starttime, connecttime)
558
559     if firstbytetime ~= nil then
560       elapsed(check, "tt_firstbyte", starttime, firstbytetime)
561     end
562
563     -- size
564     status = status .. ',bytes=' .. client.content_bytes
565     check.metric_int32("bytes", client.content_bytes)
566
567     if check.config.extract ~= nil then
568       local exre = noit.pcre(check.config.extract)
569       local rv = true
570       local m = nil
571       while rv and m ~= '' do
572         rv, m, key, value = exre(output or '', { limit = pcre_match_limit })
573         if rv and key ~= nil then
574           check.metric(key, value)
575         end
576       end
577     end
578
579     -- check body
580     if check.config.body ~= nil then
581       local bodyre = noit.pcre(check.config.body)
582       local rv, m, m1 = bodyre(output or '')
583       if rv then
584         m = m1 or m or output
585         if string.len(m) > max_len then
586           m = string.sub(m,1,max_len)
587         end
588         status = status .. ',body=matched'
589         check.metric_string('body_match', m)
590       else
591         status = status .. ',body=failed'
592         check.metric_string('body_match', nil)
593         good = false
594       end
595     end
596
597     -- check body matches
598     local matches = 0
599     has_body_matches = false
600     for key, value in pairs(check.config) do
601       m = string.find(key, BODY_MATCHES_PREFIX)
602
603       if m == 1 then
604         has_body_matches = true
605         key = string.gsub(key, BODY_MATCHES_PREFIX, '')
606
607         local bodyre = noit.pcre(value)
608         local rv, m, m1 = bodyre(output or '')
609
610         if rv then
611           matches = matches + 1
612           m = m1 or m or output
613           if string.len(m) > max_len then
614             m = string.sub(m,1,max_len)
615           end
616           check.metric_string('body_match_' .. key, m)
617         else
618           check.metric_string('body_match_' .. key, nil)
619         end
620       end
621     end
622
623     if has_body_matches then
624       status = status .. ',body_matches=' .. tostring(matches) .. ' matches'
625     end
626
627     -- Include body
628     if include_body then
629         check.metric_string('body', output or '')
630     end
631
632     -- ssl ctx
633     local ssl_ctx = client:ssl_ctx()
634     if ssl_ctx ~= nil then
635       local header_match_error = check_host_header_against_certificate(host_header, ssl_ctx.subject, ssl_ctx.san_list)
636       if ssl_ctx.error ~= nil then status = status .. ',sslerror' end
637       if header_match_error == nil then
638         check.metric_string("cert_error", ssl_ctx.error)
639       elseif ssl_ctx.error == nil then
640         check.metric_string("cert_error", header_match_error)
641       else
642         check.metric_string("cert_error", ssl_ctx.error .. ', ' .. header_match_error)
643       end
644       check.metric_string("cert_issuer", ssl_ctx.issuer)
645       check.metric_string("cert_subject", ssl_ctx.subject)
646       if ssl_ctx.san_list ~= nil then
647         check.metric_string("cert_subject_alternative_names", ssl_ctx.san_list)
648       end
649       check.metric_uint32("cert_start", ssl_ctx.start_time)
650       check.metric_uint32("cert_end", ssl_ctx.end_time)
651       check.metric_int32("cert_end_in", ssl_ctx.end_time - os.time())
652       if noit.timeval.seconds(starttime) > ssl_ctx.end_time then
653         good = false
654         status = status .. ',ssl=expired'
655       end
656     end
657
658     if good then check.good() else check.bad() end
659     check.status(status)
660 end
661
Note: See TracBrowser for help on using the browser.