Skip to content

Commit ea7cb7c

Browse files
author
Vidas P
committed
Implement TLS Check agent
1 parent f91d4f9 commit ea7cb7c

File tree

5 files changed

+376
-0
lines changed

5 files changed

+376
-0
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ group :development do
103103
gem 'rspec_junit_formatter', '~> 0.4.1'
104104
gem 'selenium-webdriver', '~> 3.142.7'
105105
gem 'shoulda-matchers', '~> 4.3.0'
106+
gem 'timecop', '~> 0.9.2'
106107
gem 'vcr', '~> 5.1.0'
107108
gem 'webmock', '~> 3.8.3'
108109
end

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ GEM
483483
thor (0.20.3)
484484
thread_safe (0.3.6)
485485
tilt (2.0.10)
486+
timecop (0.9.2)
486487
twilio-ruby (3.11.6)
487488
builder (>= 2.1.2)
488489
jwt (>= 0.1.2)
@@ -595,6 +596,7 @@ DEPENDENCIES
595596
spectrum-rails (~> 1.8.0)
596597
sprockets (~> 4.0.2)
597598
sqlite3 (~> 1.4.2)
599+
timecop (~> 0.9.2)
598600
twilio-ruby (~> 3.11.5)
599601
typhoeus (~> 1.4.0)
600602
uglifier (~> 4.2.0)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
require 'socket'
2+
require 'openssl'
3+
4+
module Agents
5+
class TlsCheckAgent < Agent
6+
display_name 'TLS Check Agent'
7+
8+
default_schedule 'every_12h'
9+
10+
def default_options
11+
{
12+
'url' => 'https://example.com',
13+
'merge': false
14+
}
15+
end
16+
17+
description <<-MD
18+
19+
The TLS Check agent will check a URL and emit the certificate information
20+
for that URL. Its main intended use is to check certificates for
21+
expiration.
22+
23+
*Note*: certificate signatures are not checked against any certificate
24+
chain/authority, therefore TLS Check agent should only be used to
25+
check certificates you control.
26+
27+
Specify a `url` and TLS Check Agent will produce a message with the
28+
validity info for that certificate. The message will include the dates
29+
that mark the certificate validity period. Days remaining until
30+
certificate expires will be returned as a separate field. Field `expired`
31+
will indicate if a certificate has expired.
32+
33+
Provided `url` *should* include URI scheme (i.e. https) and can also
34+
include port (optional for https):
35+
36+
```
37+
https://www.example.com
38+
```
39+
40+
If you want to check TLS for other types of services (i.e. IMAP/SMTP) you
41+
can use generic 'tcp' scheme and provide service port explicitly:
42+
43+
```
44+
tcp://imap.example.org:993
45+
```
46+
47+
STARTTLS is currently not supported.
48+
49+
Set option `merge` to 'true' so result is merged with incoming message
50+
payload.
51+
MD
52+
53+
# TODO
54+
message_description <<-MD
55+
Messages will have the following fields:
56+
57+
{
58+
"url": "...",
59+
"host": "...",
60+
"port": "...",
61+
"hostname_verify_result": "...",
62+
"certificate": {
63+
"valid": "...", // is certificate valid?
64+
"expired": "..." // has certificate expired?
65+
"days_left": "...", // days left until expiration
66+
"subject": [[], [], ..],
67+
"issuer": [[], [], ..],
68+
"not_before": "...",
69+
"not_after": "...",
70+
}
71+
}
72+
MD
73+
74+
def check
75+
check_this_url(options[:url])
76+
end
77+
78+
def receive(message)
79+
interpolate_with(message) do
80+
check_this_url(interpolated[:url], message.payload)
81+
end
82+
end
83+
84+
private
85+
86+
def merge?
87+
options[:merge]
88+
end
89+
90+
def days_left(cert)
91+
return unless cert.not_after.is_a?(Time)
92+
93+
today = Time.now
94+
((cert.not_after - today) / 1.day).floor
95+
end
96+
97+
def expired?(cert)
98+
return unless cert.not_after.is_a?(Time)
99+
100+
Time.now > cert.not_after
101+
end
102+
103+
def valid_cert?(cert)
104+
return unless cert.not_after.is_a?(Time)
105+
return unless cert.not_before.is_a?(Time)
106+
107+
now = Time.now
108+
(now > cert.not_before) && (now < cert.not_after)
109+
end
110+
111+
def emit_error(payload, error)
112+
create_message(payload: payload.merge(error: error))
113+
end
114+
115+
def emit_cert_info(payload, cert)
116+
cert_payload = {
117+
certificate: {
118+
valid: valid_cert?(cert),
119+
subject: cert.subject.to_a,
120+
issuer: cert.issuer.to_a,
121+
not_before: cert.not_before,
122+
not_after: cert.not_after,
123+
days_left: days_left(cert),
124+
expired: expired?(cert)
125+
}
126+
}
127+
create_message(payload: payload.merge(cert_payload))
128+
end
129+
130+
def check_this_url(url, incoming_payload = {})
131+
uri = URI(url)
132+
host = uri.host
133+
port = uri.port || 443
134+
cert_info = get_cert_info(host, port)
135+
error = cert_info[:error]
136+
137+
payload = { url: url, host: host, port: port }
138+
payload = incoming_payload.merge(payload) if merge?
139+
140+
return emit_error(payload, error) if error
141+
142+
emit_cert_info(payload, cert_info[:cert])
143+
end
144+
145+
def get_cert_info(host, port)
146+
socket = TCPSocket.open(host, port)
147+
148+
ssl_context = OpenSSL::SSL::SSLContext.new
149+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
150+
cert_store = OpenSSL::X509::Store.new
151+
cert_store.set_default_paths
152+
ssl_context.cert_store = cert_store
153+
154+
ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)
155+
ssl_socket.hostname = host
156+
ssl_socket.connect
157+
rescue SocketError => e
158+
error_message = "Socket error: #{e.message}"
159+
error(error_message)
160+
{ error: error_message }
161+
rescue OpenSSL::SSL::SSLError => e
162+
error_message = "SSL error: #{e.message}"
163+
error(error_message)
164+
{ error: error_message }
165+
# TODO: handle Errno::ETIMEDOUT errors explicitly? (retry?)
166+
# TODO: handle Errno::ECONNREFUSED errors explicitly?
167+
rescue StandardError => e
168+
error(e.message)
169+
{ error: e.message }
170+
else
171+
{ cert: ssl_socket.peer_cert }
172+
end
173+
end
174+
end

app/models/message.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def update_agent_last_message_at
4949
end
5050
end
5151

52+
# TODO: Is this used? Can we remove it with agent drop as well?
5253
class MessageDrop
5354
def initialize(object)
5455
@payload = object.payload

0 commit comments

Comments
 (0)