1 | /**
|
2 | * Copyright 2016-2018 F5 Networks, Inc.
|
3 | *
|
4 | * Licensed under the Apache License, Version 2.0 (the "License");
|
5 | * you may not use this file except in compliance with the License.
|
6 | * You may obtain a copy of the License at
|
7 | *
|
8 | * http://www.apache.org/licenses/LICENSE-2.0
|
9 | *
|
10 | * Unless required by applicable law or agreed to in writing, software
|
11 | * distributed under the License is distributed on an "AS IS" BASIS,
|
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 | * See the License for the specific language governing permissions and
|
14 | * limitations under the License.
|
15 | */
|
16 |
|
17 | ;
|
18 |
|
19 | const Logger = require('./logger');
|
20 | const http = require('http');
|
21 | const https = require('https');
|
22 | const q = require('q');
|
23 |
|
24 | /**
|
25 | * Creates an IControl object
|
26 | * @class
|
27 | *
|
28 | * @param {Object} [options] - Options for the creation.
|
29 | * @param {String} [options.host] - IP Address to connect to. Default 127.0.0.1
|
30 | * @param {Number} [options.port] - Port to use. Default 443.
|
31 | * @param {String} [options.user] - User to use for auth. Default admin.
|
32 | * @param {String} [options.password] - Password to use for auth. Default admin.
|
33 | * @param {String} [options.authToken] - Auth token to use rather than user and password.
|
34 | * @param {String} [options.basePath] - Base path to prepend to paths for all requests. Default /mgmt
|
35 | * @param {Boolean} [options.strict] - Whether or not to validate SSL certificates.
|
36 | * @param {Object} [options.logger] - Logger to use. Or, pass loggerOptions to get your own logger.
|
37 | * @param {Object} [options.loggerOptions] - Options for the logger.
|
38 | * See {@link module:logger.getLogger} for details.
|
39 | */
|
40 | function IControl(options) {
|
41 | const opts = options || {};
|
42 |
|
43 | const logger = options ? options.logger : undefined;
|
44 | let loggerOptions = options ? options.loggerOptions : undefined;
|
45 |
|
46 | if (logger) {
|
47 | this.logger = logger;
|
48 | } else {
|
49 | loggerOptions = loggerOptions || { logLevel: 'none' };
|
50 | loggerOptions.module = module;
|
51 | this.logger = Logger.getLogger(loggerOptions);
|
52 | }
|
53 |
|
54 | // Set default options
|
55 | this.host = opts.host || '127.0.0.1';
|
56 | this.port = opts.port || 443;
|
57 | this.user = opts.user || 'admin';
|
58 | this.password = opts.password || 'admin';
|
59 | this.authToken = opts.authToken;
|
60 | this.basePath = opts.basePath || getBasePath.call(this, opts);
|
61 | this.strict = typeof opts.strict !== 'undefined' ? opts.strict : true;
|
62 |
|
63 | if (this.host !== 'localhost' || this.port !== 8100) {
|
64 | this.auth = `${this.user}:${this.password}`;
|
65 | }
|
66 |
|
67 | this.http = http;
|
68 | this.https = https;
|
69 | }
|
70 |
|
71 | /**
|
72 | * Executes a list (GET) request
|
73 | *
|
74 | * @param {String} path - Path for the request.
|
75 | * @param {Object} [options] - Options for the request.
|
76 | * @param {Boolean} [options.noWait] - Don't wait for a response. Default false (wait for a response).
|
77 | * @param {Object} [options.headers] - Headers to use in the request. Default
|
78 | * {
|
79 | * 'Content-Type': 'application/json'
|
80 | * }
|
81 | *
|
82 | * @returns {Promise} A promise which is resolved with the results of the request
|
83 | * or rejected if an error occurs. If the response is JSON that
|
84 | * has 'items' in it, only the items are returned.
|
85 | */
|
86 | IControl.prototype.list = function list(path, options) {
|
87 | return this.request('GET', path, undefined, options);
|
88 | };
|
89 |
|
90 | /**
|
91 | * Executes a create (POST) request
|
92 | *
|
93 | * @param {String} path - Path for the request.
|
94 | * @param {Object} [body] - Body of the request.
|
95 | * @param {Object} [options] - Options for the request.
|
96 | * @param {Boolean} [options.noWait] - Don't wait for a response. Default false (wait for a response).
|
97 | * @param {Object} [options.headers] - Headers to use in the request. Default
|
98 | * {
|
99 | * 'Content-Type': 'application/json'
|
100 | * }
|
101 | *
|
102 | * @returns {Promise} A promise which is resolved with the results of the request
|
103 | * or rejected if an error occurs.
|
104 | */
|
105 | IControl.prototype.create = function create(path, body, options) {
|
106 | return this.request('POST', path, body, options);
|
107 | };
|
108 |
|
109 | /**
|
110 | * Executes a modify (PATCH) request
|
111 | *
|
112 | * @param {String} path - Path for the request.
|
113 | * @param {Object} [body] - Body of the request.
|
114 | * @param {Object} [options] - Options for the request.
|
115 | * @param {Boolean} [options.noWait] - Don't wait for a response. Default false (wait for a response).
|
116 | * @param {Object} [options.headers] - Headers to use in the request. Default
|
117 | * {
|
118 | * 'Content-Type': 'application/json'
|
119 | * }
|
120 | *
|
121 | * @returns {Promise} A promise which is resolved with the results of the request
|
122 | * or rejected if an error occurs.
|
123 | */
|
124 | IControl.prototype.modify = function modify(path, body, options) {
|
125 | return this.request('PATCH', path, body, options);
|
126 | };
|
127 |
|
128 |
|
129 | /**
|
130 | * Executes a replace (PUT) request
|
131 | *
|
132 | * @param {String} path - Path for the request.
|
133 | * @param {Object} [body] - Body of the request.
|
134 | * @param {Object} [options] - Options for the request.
|
135 | * @param {Boolean} [options.noWait] - Don't wait for a response. Default false (wait for a response).
|
136 | * @param {Object} [options.headers] - Headers to use in the request. Default
|
137 | * {
|
138 | * 'Content-Type': 'application/json'
|
139 | * }
|
140 | *
|
141 | * @returns {Promise} A promise which is resolved with the results of the request
|
142 | * or rejected if an error occurs.
|
143 | */
|
144 | IControl.prototype.replace = function replace(path, body, options) {
|
145 | return this.request('PUT', path, body, options);
|
146 | };
|
147 |
|
148 | /**
|
149 | * Executes a delete (DELETE) request
|
150 | *
|
151 | * @param {String} path - Path for the request.
|
152 | * @param {Object} [body] - Body of the request.
|
153 | * @param {Object} [options] - Options for the request.
|
154 | * @param {Boolean} [options.noWait] - Don't wait for a response. Default false (wait for a response).
|
155 | * @param {Object} [options.headers] - Headers to use in the request. Default
|
156 | * {
|
157 | * 'Content-Type': 'application/json'
|
158 | * }
|
159 | *
|
160 | * @returns {Promise} A promise which is resolved with the results of the request
|
161 | * or rejected if an error occurs. If the response is JSON that
|
162 | * has 'items' in it, only the items are returned.
|
163 | */
|
164 | IControl.prototype.delete = function deletex(path, body, options) {
|
165 | return this.request('DELETE', path, body, options);
|
166 | };
|
167 |
|
168 | IControl.prototype.setAuthToken = function setAuthToken(authToken) {
|
169 | this.authToken = authToken;
|
170 | };
|
171 |
|
172 | IControl.prototype.setRefreshToken = function setRefreshToken(refreshToken) {
|
173 | this.refreshToken = refreshToken;
|
174 | };
|
175 |
|
176 | /**
|
177 | * Executes a request
|
178 | *
|
179 | * @param {String} method - HTTP method for the request.
|
180 | * @param {String} path - Path for the request.
|
181 | * @param {Object} [body] - Body of the request.
|
182 | * @param {Object} [options] - Options for the request.
|
183 | * @param {Boolean} [options.noWait] - Don't wait for a response. Default false (wait for a response).
|
184 | * @param {Object} [options.headers] - Headers to use in the request.
|
185 | *
|
186 | * {
|
187 | * 'Content-Type': 'application/json'
|
188 | * }
|
189 | *
|
190 | * @param {Object} [existingDeferred] - An existing deferred to use instead of creating a new one. Used
|
191 | * for recursion (for retrying the request).
|
192 | *
|
193 | * @param {Boolean} [options.noRefresh] - Do not try to refresh the auth token if the request gets a 401
|
194 | *
|
195 | * @returns {Promise} A promise which is resolved with the results of the request
|
196 | * or rejected if an error occurs.
|
197 | */
|
198 | IControl.prototype.request = function request(method, path, body, options, existingDeferred) {
|
199 | const deferred = existingDeferred || q.defer();
|
200 | const requestOptions = {
|
201 | method,
|
202 | hostname: this.host,
|
203 | port: this.port,
|
204 | path: this.basePath + path,
|
205 | rejectUnauthorized: this.strict,
|
206 | headers: {
|
207 | 'Content-Type': 'application/json'
|
208 | }
|
209 | };
|
210 |
|
211 | if (this.authToken) {
|
212 | requestOptions.headers['X-F5-Auth-Token'] = this.authToken;
|
213 | } else {
|
214 | requestOptions.auth = this.auth;
|
215 | }
|
216 |
|
217 | const noWait = options ? options.noWait : undefined;
|
218 | const headers = options ? options.headers : undefined;
|
219 | const noRefresh = options ? options.noRefresh : undefined;
|
220 |
|
221 | let mungedBody = body;
|
222 |
|
223 | // Add any headers that were specified
|
224 | if (headers) {
|
225 | Object.keys(headers).forEach((header) => {
|
226 | if (header.toLowerCase() === 'content-type') {
|
227 | requestOptions.headers['Content-Type'] = headers[header];
|
228 | } else {
|
229 | requestOptions.headers[header] = headers[header];
|
230 | }
|
231 | });
|
232 | }
|
233 |
|
234 | if (requestOptions.headers['Content-Type'] === 'application/json') {
|
235 | if (mungedBody) {
|
236 | mungedBody = JSON.stringify(body);
|
237 | }
|
238 | }
|
239 |
|
240 | if (mungedBody) {
|
241 | requestOptions.headers['Content-Length'] = Buffer.byteLength(mungedBody);
|
242 | }
|
243 |
|
244 | const responseHandler = noWait ? undefined : (response) => {
|
245 | let totalResponse = '';
|
246 | response.on('data', (chunk) => {
|
247 | totalResponse += chunk;
|
248 | });
|
249 |
|
250 | response.on('end', () => {
|
251 | const responseHeaders = response.headers;
|
252 | let parsedResponse;
|
253 | let contentType;
|
254 |
|
255 | if (responseHeaders['content-type']
|
256 | && responseHeaders['content-type'].indexOf('application/json') !== -1
|
257 | ) {
|
258 | contentType = 'application/json';
|
259 | try {
|
260 | parsedResponse = JSON.parse(totalResponse || '{}');
|
261 | } catch (err) {
|
262 | deferred.reject(new Error(`Unable to parse JSON response: ${err.message}`));
|
263 | return;
|
264 | }
|
265 |
|
266 | if (method === 'GET' && parsedResponse.items) {
|
267 | parsedResponse = parsedResponse.items;
|
268 | }
|
269 | } else {
|
270 | parsedResponse = totalResponse;
|
271 | }
|
272 |
|
273 | if (response.statusCode >= 300) {
|
274 | if (response.statusCode === 401 && this.authToken && !noRefresh) {
|
275 | const retryOptions = {};
|
276 | Object.assign(retryOptions, options);
|
277 | retryOptions.noRefresh = true;
|
278 |
|
279 | // BIG-IQ requests using Token Auth are required to originate from the same IP address.
|
280 | // Requests from a client may originate from multiple IP addresses (ex: SNAT pools).
|
281 | // Fall back to Basic Auth if BIG-IQ reports the token was issued to another client.
|
282 | const invalidClient =
|
283 | 'Tokens are only valid to be used by the client of which they were issued.';
|
284 | if (parsedResponse.message && parsedResponse.message.indexOf(invalidClient) > -1) {
|
285 | this.logger.debug('auth token authentication failed, trying basic auth');
|
286 |
|
287 | this.authToken = undefined;
|
288 | this.refreshToken = undefined;
|
289 |
|
290 | this.request(method, path, body, retryOptions, deferred);
|
291 | } else if (this.refreshToken) {
|
292 | // Our auth token expired - try to refresh
|
293 | this.logger.debug('auth token expired, refreshing');
|
294 | refreshAuthToken.call(this)
|
295 | .then(() => {
|
296 | this.request(method, path, body, retryOptions, deferred);
|
297 | })
|
298 | .catch((err) => {
|
299 | this.logger.info('auth token expired and refresh failed');
|
300 | deferred.reject(err);
|
301 | });
|
302 | }
|
303 | } else {
|
304 | const error = parseError.call(this, response, parsedResponse, contentType);
|
305 | deferred.reject(error);
|
306 | }
|
307 | } else {
|
308 | deferred.resolve(parsedResponse);
|
309 | }
|
310 | });
|
311 | };
|
312 |
|
313 | const httpRequest = getRequest.call(this, requestOptions, responseHandler);
|
314 |
|
315 | if (mungedBody) {
|
316 | httpRequest.write(mungedBody);
|
317 | }
|
318 |
|
319 | httpRequest.on('error', (err) => {
|
320 | if (!noWait) {
|
321 | deferred.reject(err);
|
322 | }
|
323 | });
|
324 |
|
325 | httpRequest.end();
|
326 |
|
327 | if (noWait) {
|
328 | deferred.resolve();
|
329 | }
|
330 |
|
331 | return deferred.promise;
|
332 | };
|
333 |
|
334 | function parseError(response, parsedResponse, contentType) {
|
335 | let error;
|
336 |
|
337 | if (contentType === 'application/json') {
|
338 | if (parsedResponse.code && parsedResponse.message) {
|
339 | error = new Error(parsedResponse.message);
|
340 | error.code = parsedResponse.code;
|
341 | } else {
|
342 | error = new Error(JSON.stringify(parsedResponse));
|
343 | error.code = response.statusCode;
|
344 | }
|
345 | } else {
|
346 | error = new Error(parsedResponse);
|
347 | error.code = response.statusCode;
|
348 | }
|
349 |
|
350 | return error;
|
351 | }
|
352 |
|
353 | function refreshAuthToken() {
|
354 | return this.create(
|
355 | '/shared/authn/exchange',
|
356 | {
|
357 | refreshToken: {
|
358 | token: this.refreshToken
|
359 | }
|
360 | }
|
361 | )
|
362 | .then((response) => {
|
363 | if (response && response.token && response.token.token) {
|
364 | this.setAuthToken(response.token.token);
|
365 | this.setRefreshToken(response.refreshToken.token);
|
366 | return q();
|
367 | }
|
368 | return q.reject(new Error('Did not receive auth token while refreshing'));
|
369 | })
|
370 | .catch((err) => {
|
371 | return q.reject(err);
|
372 | });
|
373 | }
|
374 |
|
375 | function getBasePath(options) {
|
376 | if (options.host === 'localhost' && options.port === 8100) {
|
377 | return '';
|
378 | }
|
379 | return '/mgmt';
|
380 | }
|
381 |
|
382 | function getRequest(requestOptions, responseHandler) {
|
383 | if (this.host === 'localhost' && this.port === 8100) {
|
384 | return this.http.request(requestOptions, responseHandler);
|
385 | }
|
386 | return this.https.request(requestOptions, responseHandler);
|
387 | }
|
388 |
|
389 | module.exports = IControl;
|