1 | # Licensed to the StackStorm, Inc ('StackStorm') under one or more |
||
2 | # contributor license agreements. See the NOTICE file distributed with |
||
3 | # this work for additional information regarding copyright ownership. |
||
4 | # The ASF licenses this file to You under the Apache License, Version 2.0 |
||
5 | # (the "License"); you may not use this file except in compliance with |
||
6 | # the License. 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 | from __future__ import absolute_import |
||
17 | |||
18 | import os |
||
19 | import pwd |
||
20 | import json |
||
21 | import logging |
||
22 | import time |
||
23 | import calendar |
||
24 | import traceback |
||
25 | |||
26 | import six |
||
27 | import requests |
||
28 | |||
29 | # pylint: disable=import-error |
||
30 | from requests.packages.urllib3.exceptions import InsecureRequestWarning |
||
31 | |||
32 | from st2client import models |
||
33 | from st2client.config_parser import CLIConfigParser |
||
34 | from st2client.config_parser import ST2_CONFIG_DIRECTORY |
||
35 | from st2client.config_parser import ST2_CONFIG_PATH |
||
36 | from st2client.client import Client |
||
37 | from st2client.config import get_config |
||
38 | from st2client.utils.date import parse as parse_isotime |
||
39 | from st2client.utils.misc import merge_dicts |
||
40 | |||
41 | __all__ = [ |
||
42 | 'BaseCLIApp' |
||
43 | ] |
||
44 | |||
45 | # Fix for "os.getlogin()) OSError: [Errno 2] No such file or directory" |
||
46 | os.getlogin = lambda: pwd.getpwuid(os.getuid())[0] |
||
47 | |||
48 | # How many seconds before the token actual expiration date we should consider the token as |
||
49 | # expired. This is used to prevent the operation from failing durig the API request because the |
||
50 | # token was just about to expire. |
||
51 | TOKEN_EXPIRATION_GRACE_PERIOD_SECONDS = 15 |
||
52 | |||
53 | CONFIG_OPTION_TO_CLIENT_KWARGS_MAP = { |
||
54 | 'base_url': ['general', 'base_url'], |
||
55 | 'auth_url': ['auth', 'url'], |
||
56 | 'stream_url': ['stream', 'url'], |
||
57 | 'api_url': ['api', 'url'], |
||
58 | 'api_version': ['general', 'api_version'], |
||
59 | 'api_key': ['credentials', 'api_key'], |
||
60 | 'cacert': ['general', 'cacert'], |
||
61 | 'debug': ['cli', 'debug'] |
||
62 | } |
||
63 | |||
64 | |||
65 | class BaseCLIApp(object): |
||
66 | """ |
||
67 | Base class for StackStorm CLI apps. |
||
68 | """ |
||
69 | |||
70 | LOG = logging.getLogger(__name__) # logger instance to use |
||
71 | client = None # st2client instance |
||
72 | |||
73 | # A list of command classes for which automatic authentication should be skipped. |
||
74 | SKIP_AUTH_CLASSES = [] |
||
75 | |||
76 | def get_client(self, args, debug=False): |
||
77 | ST2_CLI_SKIP_CONFIG = os.environ.get('ST2_CLI_SKIP_CONFIG', 0) |
||
78 | ST2_CLI_SKIP_CONFIG = int(ST2_CLI_SKIP_CONFIG) |
||
79 | |||
80 | skip_config = args.skip_config |
||
81 | skip_config = skip_config or ST2_CLI_SKIP_CONFIG |
||
82 | |||
83 | # Note: Options provided as the CLI argument have the highest precedence |
||
84 | # Precedence order: cli arguments > environment variables > rc file variables |
||
85 | cli_options = ['base_url', 'auth_url', 'api_url', 'stream_url', 'api_version', 'cacert'] |
||
86 | cli_options = {opt: getattr(args, opt, None) for opt in cli_options} |
||
87 | config_file_options = self._get_config_file_options(args=args) |
||
88 | |||
89 | kwargs = {} |
||
90 | |||
91 | if not skip_config: |
||
92 | # Config parsing is not skipped |
||
93 | kwargs = merge_dicts(kwargs, config_file_options) |
||
94 | |||
95 | kwargs = merge_dicts(kwargs, cli_options) |
||
96 | kwargs['debug'] = debug |
||
97 | |||
98 | client = Client(**kwargs) |
||
99 | |||
100 | if skip_config: |
||
101 | # Config parsing is skipped |
||
102 | self.LOG.info('Skipping parsing CLI config') |
||
103 | return client |
||
104 | |||
105 | # Ok to use config at this point |
||
106 | rc_config = get_config() |
||
107 | |||
108 | # Silence SSL warnings |
||
109 | silence_ssl_warnings = rc_config.get('general', {}).get('silence_ssl_warnings', False) |
||
110 | if silence_ssl_warnings: |
||
111 | # pylint: disable=no-member |
||
112 | requests.packages.urllib3.disable_warnings(InsecureRequestWarning) |
||
113 | |||
114 | # We skip automatic authentication for some commands such as auth |
||
115 | try: |
||
116 | command_class_name = args.func.__self__.__class__.__name__ |
||
117 | except Exception: |
||
118 | command_class_name = None |
||
119 | |||
120 | if command_class_name in self.SKIP_AUTH_CLASSES: |
||
121 | return client |
||
122 | |||
123 | # We also skip automatic authentication if token is provided via the environment variable |
||
124 | # or as a command line argument |
||
125 | env_var_token = os.environ.get('ST2_AUTH_TOKEN', None) |
||
126 | cli_argument_token = getattr(args, 'token', None) |
||
127 | env_var_api_key = os.environ.get('ST2_API_KEY', None) |
||
128 | cli_argument_api_key = getattr(args, 'api_key', None) |
||
129 | if env_var_token or cli_argument_token or env_var_api_key or cli_argument_api_key: |
||
130 | return client |
||
131 | |||
132 | # If credentials are provided in the CLI config use them and try to authenticate |
||
133 | credentials = rc_config.get('credentials', {}) |
||
134 | username = credentials.get('username', None) |
||
135 | password = credentials.get('password', None) |
||
136 | cache_token = rc_config.get('cli', {}).get('cache_token', False) |
||
137 | |||
138 | if username: |
||
139 | # Credentials are provided, try to authenticate agaist the API |
||
140 | try: |
||
141 | token = self._get_auth_token(client=client, username=username, password=password, |
||
142 | cache_token=cache_token) |
||
143 | except requests.exceptions.ConnectionError as e: |
||
144 | self.LOG.warn('Auth API server is not available, skipping authentication.') |
||
145 | self.LOG.exception(e) |
||
146 | return client |
||
147 | except Exception as e: |
||
148 | print('Failed to authenticate with credentials provided in the config.') |
||
149 | raise e |
||
150 | client.token = token |
||
151 | # TODO: Hack, refactor when splitting out the client |
||
152 | os.environ['ST2_AUTH_TOKEN'] = token |
||
153 | |||
154 | return client |
||
155 | |||
156 | def _get_config_file_options(self, args, validate_config_permissions=False): |
||
157 | """ |
||
158 | Parse the config and return kwargs which can be passed to the Client |
||
159 | constructor. |
||
160 | |||
161 | :rtype: ``dict`` |
||
162 | """ |
||
163 | rc_options = self._parse_config_file( |
||
164 | args=args, validate_config_permissions=validate_config_permissions) |
||
165 | result = {} |
||
166 | for kwarg_name, (section, option) in six.iteritems(CONFIG_OPTION_TO_CLIENT_KWARGS_MAP): |
||
167 | result[kwarg_name] = rc_options.get(section, {}).get(option, None) |
||
168 | |||
169 | return result |
||
170 | |||
171 | def _parse_config_file(self, args, validate_config_permissions=False): |
||
172 | config_file_path = self._get_config_file_path(args=args) |
||
173 | |||
174 | parser = CLIConfigParser(config_file_path=config_file_path, |
||
175 | validate_config_exists=False, |
||
176 | validate_config_permissions=validate_config_permissions, |
||
177 | log=self.LOG) |
||
178 | result = parser.parse() |
||
179 | return result |
||
180 | |||
181 | def _get_config_file_path(self, args): |
||
182 | """ |
||
183 | Retrieve path to the CLI configuration file. |
||
184 | |||
185 | :rtype: ``str`` |
||
186 | """ |
||
187 | path = os.environ.get('ST2_CONFIG_FILE', ST2_CONFIG_PATH) |
||
188 | |||
189 | if args.config_file: |
||
190 | path = args.config_file |
||
191 | |||
192 | path = os.path.abspath(os.path.expanduser(path)) |
||
193 | if path != ST2_CONFIG_PATH and not os.path.isfile(path): |
||
194 | raise ValueError('Config "%s" not found' % (path)) |
||
195 | |||
196 | return path |
||
197 | |||
198 | def _get_auth_token(self, client, username, password, cache_token): |
||
199 | """ |
||
200 | Retrieve a valid auth token. |
||
201 | |||
202 | If caching is enabled, we will first try to retrieve cached token from a |
||
203 | file system. If cached token is expired or not available, we will try to |
||
204 | authenticate using the provided credentials and retrieve a new auth |
||
205 | token. |
||
206 | |||
207 | :rtype: ``str`` |
||
208 | """ |
||
209 | if cache_token: |
||
210 | token = self._get_cached_auth_token(client=client, username=username, |
||
211 | password=password) |
||
212 | else: |
||
213 | token = None |
||
214 | if not token: |
||
215 | # Token is either expired or not available |
||
216 | token_obj = self._authenticate_and_retrieve_auth_token(client=client, |
||
217 | username=username, |
||
218 | password=password) |
||
219 | |||
220 | self._cache_auth_token(token_obj=token_obj) |
||
221 | token = token_obj.token |
||
222 | |||
223 | return token |
||
224 | |||
225 | def _get_cached_auth_token(self, client, username, password): |
||
226 | """ |
||
227 | Retrieve cached auth token from the file in the config directory. |
||
228 | |||
229 | :rtype: ``str`` |
||
230 | """ |
||
231 | if not os.path.isdir(ST2_CONFIG_DIRECTORY): |
||
232 | os.makedirs(ST2_CONFIG_DIRECTORY, mode=0o2770) |
||
233 | # os.makedirs straight up ignores the setgid bit, so we have to set |
||
234 | # it manually |
||
235 | os.chmod(ST2_CONFIG_DIRECTORY, 0o2770) |
||
236 | |||
237 | cached_token_path = self._get_cached_token_path_for_user(username=username) |
||
238 | |||
239 | if not os.access(ST2_CONFIG_DIRECTORY, os.R_OK): |
||
240 | # We don't have read access to the file with a cached token |
||
241 | message = ('Unable to retrieve cached token from "%s" (user %s doesn\'t have read ' |
||
242 | 'access to the parent directory). Subsequent requests won\'t use a ' |
||
243 | 'cached token meaning they may be slower.' % (cached_token_path, |
||
244 | os.getlogin())) |
||
245 | self.LOG.warn(message) |
||
246 | return None |
||
247 | |||
248 | if not os.path.isfile(cached_token_path): |
||
249 | return None |
||
250 | |||
251 | if not os.access(cached_token_path, os.R_OK): |
||
252 | # We don't have read access to the file with a cached token |
||
253 | message = ('Unable to retrieve cached token from "%s" (user %s doesn\'t have read ' |
||
254 | 'access to this file). Subsequent requests won\'t use a cached token ' |
||
255 | 'meaning they may be slower.' % (cached_token_path, os.getlogin())) |
||
256 | self.LOG.warn(message) |
||
257 | return None |
||
258 | |||
259 | # Safety check for too permissive permissions |
||
260 | file_st_mode = oct(os.stat(cached_token_path).st_mode & 0o777) |
||
261 | others_st_mode = int(file_st_mode[-1]) |
||
262 | |||
263 | if others_st_mode >= 2: |
||
264 | # Every user has access to this file which is dangerous |
||
265 | message = ('Permissions (%s) for cached token file "%s" are too permissive. Please ' |
||
266 | 'restrict the permissions and make sure only your own user can read ' |
||
267 | 'from or write to the file.' % (file_st_mode, cached_token_path)) |
||
268 | self.LOG.warn(message) |
||
269 | |||
270 | with open(cached_token_path) as fp: |
||
271 | data = fp.read() |
||
272 | |||
273 | try: |
||
274 | data = json.loads(data) |
||
275 | |||
276 | token = data['token'] |
||
277 | expire_timestamp = data['expire_timestamp'] |
||
278 | except Exception as e: |
||
279 | msg = ('File "%s" with cached token is corrupted or invalid (%s). Please delete ' |
||
280 | ' this file' % (cached_token_path, str(e))) |
||
281 | raise ValueError(msg) |
||
282 | |||
283 | now = int(time.time()) |
||
284 | if (expire_timestamp - TOKEN_EXPIRATION_GRACE_PERIOD_SECONDS) < now: |
||
285 | self.LOG.debug('Cached token from file "%s" has expired' % (cached_token_path)) |
||
0 ignored issues
–
show
Coding Style
Best Practice
introduced
by
![]() |
|||
286 | # Token has expired |
||
287 | return None |
||
288 | |||
289 | self.LOG.debug('Using cached token from file "%s"' % (cached_token_path)) |
||
0 ignored issues
–
show
|
|||
290 | return token |
||
291 | |||
292 | def _cache_auth_token(self, token_obj): |
||
293 | """ |
||
294 | Cache auth token in the config directory. |
||
295 | |||
296 | :param token_obj: Token object. |
||
297 | :type token_obj: ``object`` |
||
298 | """ |
||
299 | if not os.path.isdir(ST2_CONFIG_DIRECTORY): |
||
300 | os.makedirs(ST2_CONFIG_DIRECTORY, mode=0o2770) |
||
301 | # os.makedirs straight up ignores the setgid bit, so we have to set |
||
302 | # it manually |
||
303 | os.chmod(ST2_CONFIG_DIRECTORY, 0o2770) |
||
304 | |||
305 | username = token_obj.user |
||
306 | cached_token_path = self._get_cached_token_path_for_user(username=username) |
||
307 | |||
308 | if not os.access(ST2_CONFIG_DIRECTORY, os.W_OK): |
||
309 | # We don't have write access to the file with a cached token |
||
310 | message = ('Unable to write token to "%s" (user %s doesn\'t have write ' |
||
311 | 'access to the parent directory). Subsequent requests won\'t use a ' |
||
312 | 'cached token meaning they may be slower.' % (cached_token_path, |
||
313 | os.getlogin())) |
||
314 | self.LOG.warn(message) |
||
315 | return None |
||
316 | |||
317 | if os.path.isfile(cached_token_path) and not os.access(cached_token_path, os.W_OK): |
||
318 | # We don't have write access to the file with a cached token |
||
319 | message = ('Unable to write token to "%s" (user %s doesn\'t have write ' |
||
320 | 'access to this file). Subsequent requests won\'t use a ' |
||
321 | 'cached token meaning they may be slower.' % (cached_token_path, |
||
322 | os.getlogin())) |
||
323 | self.LOG.warn(message) |
||
324 | return None |
||
325 | |||
326 | token = token_obj.token |
||
327 | expire_timestamp = parse_isotime(token_obj.expiry) |
||
328 | expire_timestamp = calendar.timegm(expire_timestamp.timetuple()) |
||
329 | |||
330 | data = {} |
||
331 | data['token'] = token |
||
332 | data['expire_timestamp'] = expire_timestamp |
||
333 | data = json.dumps(data) |
||
334 | |||
335 | # Note: We explictly use fdopen instead of open + chmod to avoid a security issue. |
||
336 | # open + chmod are two operations which means that during a short time frame (between |
||
337 | # open and chmod) when file can potentially be read by other users if the default |
||
338 | # permissions used during create allow that. |
||
339 | fd = os.open(cached_token_path, os.O_WRONLY | os.O_CREAT, 0o660) |
||
340 | with os.fdopen(fd, 'w') as fp: |
||
341 | fp.write(data) |
||
342 | os.chmod(cached_token_path, 0o660) |
||
343 | |||
344 | self.LOG.debug('Token has been cached in "%s"' % (cached_token_path)) |
||
0 ignored issues
–
show
|
|||
345 | return True |
||
346 | |||
347 | def _authenticate_and_retrieve_auth_token(self, client, username, password): |
||
348 | manager = models.ResourceManager(models.Token, client.endpoints['auth'], |
||
349 | cacert=client.cacert, debug=client.debug) |
||
350 | instance = models.Token() |
||
351 | instance = manager.create(instance, auth=(username, password)) |
||
352 | return instance |
||
353 | |||
354 | def _get_cached_token_path_for_user(self, username): |
||
355 | """ |
||
356 | Retrieve cached token path for the provided username. |
||
357 | """ |
||
358 | file_name = 'token-%s' % (username) |
||
359 | result = os.path.abspath(os.path.join(ST2_CONFIG_DIRECTORY, file_name)) |
||
360 | return result |
||
361 | |||
362 | def _print_config(self, args): |
||
363 | config = self._parse_config_file(args=args, validate_config_permissions=False) |
||
364 | |||
365 | for section, options in six.iteritems(config): |
||
366 | print('[%s]' % (section)) |
||
367 | |||
368 | for name, value in six.iteritems(options): |
||
369 | print('%s = %s' % (name, value)) |
||
370 | |||
371 | def _print_debug_info(self, args): |
||
372 | # Print client settings |
||
373 | self._print_client_settings(args=args) |
||
374 | |||
375 | # Print exception traceback |
||
376 | traceback.print_exc() |
||
377 | |||
378 | def _print_client_settings(self, args): |
||
379 | client = self.client |
||
380 | |||
381 | if not client: |
||
382 | return |
||
383 | |||
384 | config_file_path = self._get_config_file_path(args=args) |
||
385 | |||
386 | print('CLI settings:') |
||
387 | print('----------------') |
||
388 | print('Config file path: %s' % (config_file_path)) |
||
389 | print('Client settings:') |
||
390 | print('----------------') |
||
391 | print('ST2_BASE_URL: %s' % (client.endpoints['base'])) |
||
392 | print('ST2_AUTH_URL: %s' % (client.endpoints['auth'])) |
||
393 | print('ST2_API_URL: %s' % (client.endpoints['api'])) |
||
394 | print('ST2_STREAM_URL: %s' % (client.endpoints['stream'])) |
||
395 | print('ST2_AUTH_TOKEN: %s' % (os.environ.get('ST2_AUTH_TOKEN'))) |
||
396 | print('') |
||
397 | print('Proxy settings:') |
||
398 | print('---------------') |
||
399 | print('HTTP_PROXY: %s' % (os.environ.get('HTTP_PROXY', ''))) |
||
400 | print('HTTPS_PROXY: %s' % (os.environ.get('HTTPS_PROXY', ''))) |
||
401 | print('') |
||
402 |