This repository has been archived by the owner on Feb 12, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 381
/
certs.rb
330 lines (286 loc) · 10.8 KB
/
certs.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
require "heroku/command/base"
require "heroku/open_ssl"
require "excon"
# manage ssl endpoints for an app
#
class Heroku::Command::Certs < Heroku::Command::Base
SSL_DOCTOR = Excon.new(ENV["SSL_DOCTOR_URL"] || "https://ssl-doctor.heroku.com/")
class UsageError < StandardError; end
# certs
#
# List ssl endpoints for an app.
#
def index
endpoints = heroku.ssl_endpoint_list(app)
if endpoints.empty?
display "#{app} has no SSL Endpoints."
display "Use `heroku certs:add CRT KEY` to add one."
else
endpoints.map! do |endpoint|
ssl_cert_attributes = {}
if cert = endpoint['ssl_cert']
ssl_cert_attributes.merge!(
'domains' => cert['cert_domains'].join(', '),
'expires_at' => format_date(cert['expires_at']),
'ca_signed?' => cert['ca_signed?'].to_s.capitalize)
end
{ 'cname' => endpoint['cname'] }.merge(ssl_cert_attributes)
end
display_table(
endpoints,
%w( cname domains expires_at ca_signed? ),
[ "Endpoint", "Common Name(s)", "Expires", "Trusted" ]
)
end
end
# certs:chain CRT [CRT ...]
#
# Print the ordered and complete chain for the given certificate.
#
# Optional intermediate certificates may be given too, and will
# be used during chain resolution.
#
def chain
puts read_crt_through_ssl_doctor
rescue UsageError
fail("Usage: heroku certs:chain CRT [CRT ...]\nMust specify at least one certificate file.")
end
# certs:key CRT KEY [KEY ...]
#
# Print the correct key for the given certificate.
#
# You must pass one single certificate, and one or more keys.
# The first key that signs the certificate will be printed back.
#
def key
_, key = read_crt_and_key_through_ssl_doctor("Testing for signing key")
puts key
rescue UsageError
fail("Usage: heroku certs:key CRT KEY [KEY ...]\nMust specify one certificate file and at least one key file.")
end
# certs:add CRT KEY
#
# Add an ssl endpoint to an app.
#
# --bypass # bypass the trust chain completion step
#
def add
crt, key = read_crt_and_key
endpoint = action("Adding SSL Endpoint to #{app}") { heroku.ssl_endpoint_add(app, crt, key) }
display_warnings(endpoint)
display "#{app} now served by #{endpoint['cname']}"
display "Certificate details:"
display_certificate_info(endpoint)
rescue UsageError
fail("Usage: heroku certs:add CRT KEY\nMust specify CRT and KEY to add cert.")
end
# certs:update CRT KEY
#
# Update an SSL Endpoint on an app.
#
# --bypass # bypass the trust chain completion step
# -e, --endpoint ENDPOINT # name of the endpoint to update
#
def update
crt, key = read_crt_and_key
cname = options[:endpoint] || current_endpoint
message = "WARNING: Potentially Destructive Action\nThis command will change the certificate of endpoint #{cname} on #{app}."
return unless confirm_command(app, message)
endpoint = action("Updating SSL Endpoint #{cname} for #{app}") { heroku.ssl_endpoint_update(app, cname, crt, key) }
display_warnings(endpoint)
display "Updated certificate details:"
display_certificate_info(endpoint)
rescue UsageError
fail("Usage: heroku certs:update CRT KEY\nMust specify CRT and KEY to update cert.")
end
# certs:info
#
# Show certificate information for an ssl endpoint.
#
# -e, --endpoint ENDPOINT # name of the endpoint to check info on
#
def info
cname = options[:endpoint] || current_endpoint
endpoint = action("Fetching SSL Endpoint #{cname} info for #{app}") do
heroku.ssl_endpoint_info(app, cname)
end
if endpoint
display "Certificate details:"
display_certificate_info(endpoint)
else
error "No certificate found."
end
end
# certs:remove
#
# Remove an SSL Endpoint from an app.
#
# -e, --endpoint ENDPOINT # name of the endpoint to remove
#
def remove
cname = options[:endpoint] || current_endpoint
message = "WARNING: Potentially Destructive Action\nThis command will remove the endpoint #{cname} from #{app}."
return unless confirm_command(app, message)
action("Removing SSL Endpoint #{cname} from #{app}") do
heroku.ssl_endpoint_remove(app, cname)
end
display "NOTE: Billing is still active. Remove SSL Endpoint add-on to stop billing."
end
# certs:rollback
#
# Rollback an SSL Endpoint for an app.
#
# -e, --endpoint ENDPOINT # name of the endpoint to rollback
#
def rollback
cname = options[:endpoint] || current_endpoint
message = "WARNING: Potentially Destructive Action\nThis command will rollback the certificate of endpoint #{cname} on #{app}."
return unless confirm_command(app, message)
endpoint = action("Rolling back SSL Endpoint #{cname} for #{app}") do
heroku.ssl_endpoint_rollback(app, cname)
end
display "New active certificate details:"
display_certificate_info(endpoint)
end
# certs:generate DOMAIN
#
# Generate a key and certificate signing request (or self-signed certificate)
# for an app. Prompts for information to put in the certificate unless --now
# is used, or at least one of the --subject, --owner, --country, --area, or
# --city options is specified.
#
# --selfsigned # generate a self-signed certificate instead of a CSR
# --keysize BITSIZE # RSA key size in bits (default: 2048)
# --owner NAME # name of organization certificate belongs to
# --country COUNTRY # country of owner, as a two-letter ISO country code
# --area AREA # sub-country area (state, province, etc.) of owner
# --city CITY # city of owner
# --subject SUBJECT # specify entire certificate subject
# --now # do not prompt for any owner information
def generate
request = Heroku::OpenSSL::CertificateRequest.new
request.domain = args[0] || error("certs:generate must specify a domain")
request.subject = cert_subject_for_domain_and_options(request.domain, options)
request.self_signed = options[:selfsigned] || false
request.key_size = (options[:keysize] || request.key_size).to_i
result = request.generate
explain_step_after_generate result
rescue Heroku::OpenSSL::NotInstalledError => ex
error("The OpenSSL command-line tools must be installed to use certs:generate.\n" + ex.installation_hint)
rescue Heroku::OpenSSL::GenericError => ex
error(ex.message)
end
private
def current_endpoint
endpoint = heroku.ssl_endpoint_list(app).first || error("#{app} has no SSL Endpoints.")
endpoint["cname"]
end
def display_certificate_info(endpoint)
data = {
'Common Name(s)' => endpoint['ssl_cert']['cert_domains'],
'Expires At' => format_date(endpoint['ssl_cert']['expires_at']),
'Issuer' => endpoint['ssl_cert']['issuer'],
'Starts At' => format_date(endpoint['ssl_cert']['starts_at']),
'Subject' => endpoint['ssl_cert']['subject']
}
styled_hash(data)
if endpoint["ssl_cert"]["ca_signed?"]
display "SSL certificate is verified by a root authority."
elsif endpoint["issuer"] == endpoint["subject"]
display "SSL certificate is self signed."
else
display "SSL certificate is not trusted."
end
end
def display_warnings(endpoint)
if endpoint["warnings"]
endpoint["warnings"].each do |field, warning|
display "WARNING: #{field} #{warning}"
end
end
end
def post_to_ssl_doctor(path, action_text = nil)
raise UsageError if args.size < 1
action_text ||= "Resolving trust chain"
action(action_text) do
input = args.map { |arg|
begin
certbody=File.read(arg)
rescue => e
error("Unable to read #{arg} file: #{e}")
end
certbody
}.join("\n")
SSL_DOCTOR.post(:path => path, :body => input, :headers => {'Content-Type' => 'application/octet-stream'}, :expects => 200).body
end
rescue Excon::Errors::BadRequest, Excon::Errors::UnprocessableEntity => e
error(e.response.body)
end
def read_crt_and_key_through_ssl_doctor(action_text = nil)
crt_and_key = post_to_ssl_doctor("resolve-chain-and-key", action_text)
MultiJson.load(crt_and_key).values_at("pem", "key")
end
def read_crt_through_ssl_doctor(action_text = nil)
post_to_ssl_doctor("resolve-chain", action_text)
end
def read_crt_and_key_bypassing_ssl_doctor
raise UsageError if args.size != 2
crt = File.read(args[0]) rescue error("Unable to read #{args[0]} CRT")
key = File.read(args[1]) rescue error("Unable to read #{args[1]} KEY")
[crt, key]
end
def read_crt_and_key
options[:bypass] ? read_crt_and_key_bypassing_ssl_doctor : read_crt_and_key_through_ssl_doctor
end
def all_endpoint_domains
endpoints = heroku.ssl_endpoint_list(app)
endpoints.select { |endpoint| endpoint['ssl_cert'] && endpoint['ssl_cert']['cert_domains'] } \
.map { |endpoint| endpoint['ssl_cert']['cert_domains'] } \
.reduce(:+)
end
def prompt(question)
display("#{question}: ", false)
ask
end
def val_empty?(val)
val.nil? or val.empty?
end
def cert_subject_for_domain_and_options(domain, options = {})
raise ArgumentError, "domain cannot be empty" if domain.nil? || domain.empty?
subject, country, area, city, owner, now = options.values_at(:subject, :country, :area, :city, :owner, :now)
if val_empty? subject
if !now && [country, area, city, owner].all? { |v| val_empty? v }
owner = prompt "Owner of this certificate"
country = prompt "Country of owner (two-letter ISO code)"
area = prompt "State/province/etc. of owner"
city = prompt "City of owner"
end
subject = ""
subject += "/C=#{country}" unless val_empty? country
subject += "/ST=#{area}" unless val_empty? area
subject += "/L=#{city}" unless val_empty? city
subject += "/O=#{owner}" unless val_empty? owner
subject += "/CN=#{domain}"
end
subject
end
def explain_step_after_generate(result)
if result.csr_file.nil?
display "Your key and self-signed certificate have been generated."
display "Next, run:"
else
display "Your key and certificate signing request have been generated."
display "Submit the CSR in '#{result.csr_file}' to your preferred certificate authority."
display "When you've received your certificate, run:"
end
needs_addon = false
command = "add"
begin
command = "update" if all_endpoint_domains.include? result.request.domain
rescue RestClient::Forbidden
needs_addon = true
end
display "$ heroku addons:add ssl:endpoint" if needs_addon
display "$ heroku certs:#{command} #{result.crt_file || "CERTFILE"} #{result.key_file}"
end
end