-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathhook.js
330 lines (262 loc) · 9.78 KB
/
hook.js
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
'use strict';
// Require fs, dns, path, and async
const fs = require('fs-extra');
const dns = require('dns');
const path = require('path');
const async = require('async');
// Require global config and log config
const config = require(path.join(__dirname, '..', 'config'));
const logConfig = require(path.join(__dirname, '..', 'config', 'logging'));
// Require cmr1-logger to extend from
const Logger = require('cmr1-logger');
// Require cmr1-aws to use for Route53 access
const cmr1aws = require('cmr1-aws');
/**
* Hook class
* Base dehydrated hook
*/
class Hook extends Logger {
/**
* Constructor - Build a Hook object
*/
constructor() {
// Call Logger constructor with options from current running listener
super(config);
// Enable logging based on hook config
this.enableLogging(logConfig);
// Create an acm & route53 object for this hook
this.aws_acm = new cmr1aws.ACM();
this.route53 = new cmr1aws.Route53();
// Get CLI arguments (from dehydrated hook execution)
const args = Object.assign([], process.argv);
// First argument is the binary for NodeJS (/usr/bin/node)
this.executable = args.shift();
// Second argument is the filepath (/path/to/this/file)
this.filepath = args.shift();
// After shifting the first two arguments, the remaining ones are sent from dehydrated
// These two arguments should always be present
this.stage = args[0]; // Get the stage of this hook execution
this.domain = args[1]; // Get the domain for this hook execution
// These arguments should be present during the deploy & clean challenge stages
this.token = args[2]; // Get the token for this hook execution
this.challenge = args[3]; // Get the challenge for this hook execution
// These arguments should be present during the deploy cert stage
this.pem_files = { // Get the pem files (file paths) for this hook execution
privkey: args[2],
cert: args[3],
fullchain: args[4],
chain: args[5]
};
// Show debug message about this hook
this.debug('Hook constructed:', JSON.stringify(this, null, 2));
}
/**
* Run the current hook
*/
run() {
// Validate this hook before running
this.validate();
// Switch on the stage of the hook execution
switch(this.stage) {
// Run deploy challenge stage
case 'deploy_challenge':
this.verifyOwnership(error => {
if (error) {
this.error(error)
} else {
this.deployChallenge();
}
});
break;
// Run clean challenge stage
case 'clean_challenge':
this.cleanChallenge();
break;
// Run deploy cert stage
case 'deploy_cert':
this.deployCert();
break;
// Run unchanged cert stage
// - Cert has already been obtained, and isn't expiring within dehydrated threshold to renew
case 'unchanged_cert':
this.log(`Cert for domain: '${this.domain}' is unchanged.`);
this.copyCert();
break;
// Run invalid challenge stage
// - Challange verification failed
case 'invalid_challenge':
this.error(`Invalid hooked challenge for domain: ${this.domain}`);
break;
// Run request failure stage
case 'request_failure':
this.error(`Hook request failure for domain: ${this.domain}`);
break;
// Run exit hook stage
case 'exit_hook':
process.exit();
break;
// Unknown stage
default:
this.warn('Unknown stage:', this.stage);
break;
}
}
/**
* Verify the existence and validity of required arguments
*/
validate(callback) {
// Verify stage exists
if (typeof this.stage === 'undefined') {
this.error('No stage provided to hook!');
}
// Verify domain is valid
if (!config.validateDomain(this.domain)) {
this.error(`Invalid domain passed to hook: ${this.domain}`);
}
}
/**
* Placeholder ("abstract") method definition for deployChallenge
* This should be implemented by subclass(es)
*/
deployChallenge() {
this.error('Must implement deployChallenge() in extended class(es)!');
}
/**
* Placeholder ("abstract") method definition for cleanChallenge
* This should be implemented by subclass(es)
*/
cleanChallenge() {
this.error('Must implement cleanChallenge() in extended class(es)!');
}
/**
* Deploy certs
* - Certs already exist in dehydrated/certs directory at this point
*/
deployCert() {
this.log('Deploying cert...');
// Copy certs
this.copyCert();
}
/**
* Verify ownership of current subject (domain)
* @param {callback} callback - Callback when ownership is verified
*/
verifyOwnership(callback) {
// Get the hosted zone for this domain
this.getHostedZone(this.domain, zone => {
this.log('Verifying ownership of domain:', this.domain, zone);
// Lookup record for zone.Name instead
zone.getRecordSets({ StartRecordName: zone.Name, StartRecordType: 'NS' }, recordSets => {
this.debug(recordSets);
if (recordSets.length > 0) {
const expectedNs = recordSets[0].ResourceRecords.map(r => { return r.Value.substr(0, r.Value.length-1) });
const expectedStr = expectedNs.sort().join(',');
this.log('Expected NS records from Route53:', expectedStr);
// Resolve zone.Name instead
dns.resolveNs(zone.Name.substr(0, zone.Name.length-1), (err, actualNs) => {
if (err) {
return callback(err);
}
const actualStr = actualNs.sort().join(',');
this.log('Actual NS records from DNS:', actualStr);
if (expectedStr === actualStr) {
return callback();
}
return callback(`Unable to verify NS records. Expected: '${expectedStr}' | Actual: '${actualStr}'`);
});
} else {
return callback(`No NS record sets found for domain: '${this.domain}'`);
}
});
});
}
/**
* Get hosted zone by
* @param {string} host - Hostname to search for zone
* @param {callback} callback - Callback when zone is found
*/
getHostedZone(host, callback) {
// Search Route53 by name for host
this.route53.getZoneByName(host, zone => {
// If a zone was found, return it (using callback)
if (zone) {
callback(zone);
// Otherwise, try to find potential parent zone
} else {
this.log(`Missing zone for host: '${host}'`);
// Split host (domain) into array
const hostParts = host.split('.');
// Verify we're not yet at host apex
if (hostParts.length > 2) {
// Get subDomain and parentDomain from array
const subDomain = hostParts.shift();
const parentDomain = hostParts.join('.');
this.log(`Trying parent domain: '${parentDomain}' (without subdomain: '${subDomain}')`);
// Attempt to find hosted zone for parent domain (host)
this.getHostedZone(parentDomain, callback);
// Otherwise, we were unable to find the hosted zone for host
} else {
this.error(`Unable to find a zone for host: '${host}' (scanned to apex)`);
}
}
});
}
/**
* Copy cert files
* - Only if the 'copy_cert_dir' option is set
*/
copyCert() {
this.debug(this.pem_files);
// Only if the out option was set with last running listener
if (config.output_dir && config.output_dir.trim() !== '') {
this.log(`Copying to: ${config.output_dir}`);
// Verify existence of dir
fs.ensureDir(config.output_dir, err => {
// Only proceed without errors
if (!err) {
this.warn('Copying files to:', config.output_dir);
// For each pem file (key & cert/chain), copy to new directory
async.each([ this.pem_files.privkey, this.pem_files.fullchain ], (filepath, next) => {
// Get filename info from pem path.
var matches = filepath.match(/.*\/([^\/]+)\/([^\/]+)\.pem/);
// Create variables for matches
const hostname = matches[1];
const filetype = matches[2];
// Build filename for destination file
const copyfile = hostname + '.' + (filetype === 'privkey' ? 'key' : 'crt');
// Build destination copy path
const copypath = path.join(config.output_dir, copyfile);
// Stream contents of pem file to destination file
fs.createReadStream(filepath).pipe(fs.createWriteStream(copypath));
// Show copied message
this.log(`Copied file: ${filepath} -> ${copypath}`);
// Update permissions and exec "next" callback (for async.each)
fs.chmod(copypath, 0o644, next);
}, err => {
// Error copying files
if (err) this.error(err);
// Finished copying cert files
this.warn('Hook.deployCert() - copy cert files to output_dir finished.');
});
}
});
}
if (config.output_acm && config.output_acm.trim().toLowerCase() === 'yes') {
this.warn('Importing SSL cert to ACM');
const certData = {
Certificate: fs.readFileSync(this.pem_files.cert), /* required */
PrivateKey: fs.readFileSync(this.pem_files.privkey), /* required */
CertificateChain: fs.readFileSync(this.pem_files.chain) /* recommended */
};
this.aws_acm.createOrUpdateCert(this.domain, certData, cert => {
if (!cert || !cert.CertificateArn) {
this.error('Unable to import cert to ACM!', cert);
} else {
this.warn(`Hook.deployCert() - cert imported to ACM with ARN: ${cert.CertificateArn}`);
}
});
}
}
}
// Export the Hook class
module.exports = Hook;